Skip to content

Commit b86e1e4

Browse files
committed
Expand validation and release readiness
1 parent 6aee094 commit b86e1e4

17 files changed

Lines changed: 496 additions & 9 deletions

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The current spike exposes a small D-Bus backed manager client:
99
{:ok, units} = Systemd.Manager.list_units(conn)
1010
{:ok, unit} = Systemd.Manager.get_unit(conn, "dbus.service")
1111
{:ok, state} = Systemd.UnitObject.state(conn, unit)
12+
{:ok, service_state} = Systemd.UnitObject.service_state(conn, unit)
1213
```
1314

1415
It also includes a NimbleParsec-backed unit file parser/generator:
@@ -43,7 +44,7 @@ Systemd.list_units(bus: :session)
4344

4445
## Unit files
4546

46-
`Systemd.UnitFile` preserves comments, blank lines, duplicate directives, reset directives, and source spans. Validation is intentionally separate from parsing:
47+
`Systemd.UnitFile` preserves comments, blank lines, duplicate directives, reset directives, and source spans. Validation is intentionally separate from parsing and includes directive-specific value checks for common service, socket, timer, and install keys:
4748

4849
```elixir
4950
unit_file = Systemd.UnitFile.parse!("[Service]\nExecStart=/bin/true\n")
@@ -65,9 +66,17 @@ cd /Users/dannote/Development/systemd
6566
SYSTEMD_INTEGRATION=1 mix test
6667
```
6768

69+
Or from macOS, copy the source into the VM and run the full integration suite:
70+
71+
```sh
72+
scripts/integration_test.sh
73+
```
74+
6875
Quick VM checks:
6976

7077
```sh
7178
~/.local/bin/limactl shell systemd-test -- systemctl is-system-running
7279
~/.local/bin/limactl shell systemd-test -- busctl --system list --no-pager
7380
```
81+
82+
See `docs/RELEASE_CHECKLIST.md` before publishing a release.

docs/RELEASE_CHECKLIST.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Release Checklist
2+
3+
Before publishing a pre-release or Hex package:
4+
5+
- Run `mix ci` locally.
6+
- Run `mix docs` and inspect generated module examples.
7+
- Run `scripts/integration_test.sh` against the Lima systemd VM.
8+
- Confirm public APIs return `{:ok, value}` or `{:error, %Systemd.Error{}}`.
9+
- Confirm mutating D-Bus calls classify permission and polkit failures as `:permission`.
10+
- Review package metadata in `mix.exs` for version, links, licenses, and files.
11+
- Tag the release from a clean git working tree.

lib/systemd.ex

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ defmodule Systemd do
44
55
The package provides a D-Bus backed manager client, unit object/property APIs,
66
job awaiting, installation helpers, and a loss-aware unit file parser/generator.
7+
8+
## Examples
9+
10+
{:ok, units} = Systemd.list_units()
11+
:ok = Systemd.start_unit("example.service")
12+
{:ok, state} = Systemd.unit_state("dbus.service")
13+
14+
unit_file =
15+
Systemd.UnitFile.service(
16+
unit: [description: "Example"],
17+
service: [exec_start: "/bin/true", type: :oneshot],
18+
install: [wanted_by: "multi-user.target"]
19+
)
20+
21+
:ok = Systemd.UnitFile.validate(unit_file, :service)
22+
23+
Mutating calls can return `{:error, %Systemd.Error{category: :permission}}`
24+
when systemd or polkit denies the D-Bus operation.
725
"""
826

927
alias Systemd.{Error, Manager}
@@ -46,7 +64,7 @@ defmodule Systemd do
4664
@doc """
4765
Reads common state for a unit using a short-lived connection.
4866
"""
49-
@spec unit_state(String.t(), keyword()) :: {:ok, map()} | {:error, Error.t()}
67+
@spec unit_state(String.t(), keyword()) :: {:ok, Systemd.UnitState.t()} | {:error, Error.t()}
5068
def unit_state(name, opts \\ []) do
5169
with_connection(opts, fn conn ->
5270
with {:ok, unit} <- Manager.get_unit(conn, name) do

lib/systemd/dbus.ex

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ defmodule Systemd.DBus do
88

99
alias Rebus.Connection
1010
alias Rebus.Message
11-
alias Systemd.DBus.Result
11+
alias Systemd.DBus.{Result, Signature}
1212
alias Systemd.Error
1313

1414
@type bus ::
@@ -42,7 +42,8 @@ defmodule Systemd.DBus do
4242
"""
4343
@spec call(pid(), [call_option()]) :: {:ok, Result.t()} | {:error, Error.t()}
4444
def call(conn, opts) when is_pid(conn) and is_list(opts) do
45-
with {:ok, message} <- message(opts) do
45+
with :ok <- validate_signature(opts),
46+
{:ok, message} <- message(opts) do
4647
send_message(conn, message)
4748
end
4849
end
@@ -55,6 +56,16 @@ defmodule Systemd.DBus do
5556
with {:ok, %Result{body: body}} <- call(conn, opts), do: {:ok, body}
5657
end
5758

59+
defp validate_signature(opts) do
60+
signature = Keyword.get(opts, :signature, "")
61+
62+
if Signature.supported?(signature) do
63+
:ok
64+
else
65+
{:error, Error.encoding_error({:unsupported_signature, signature})}
66+
end
67+
end
68+
5869
defp message(opts) do
5970
Message.new(:method_call,
6071
destination: Keyword.fetch!(opts, :destination),

lib/systemd/dbus/signature.ex

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule Systemd.DBus.Signature do
2+
@moduledoc false
3+
4+
@supported_complex_signatures MapSet.new([
5+
"a(ssssssouso)",
6+
"a{sv}",
7+
"a(sv)",
8+
"a(sa(sv))",
9+
"ssa(sv)a(sa(sv))"
10+
])
11+
12+
@doc false
13+
@spec supported?(String.t()) :: boolean()
14+
def supported?(signature) when is_binary(signature) do
15+
primitive?(signature) or MapSet.member?(@supported_complex_signatures, signature)
16+
end
17+
18+
defp primitive?(""), do: true
19+
20+
defp primitive?(signature) do
21+
String.match?(signature, ~r/^[syobintqxuagdvh]+$/)
22+
end
23+
end

lib/systemd/service_state.ex

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
defmodule Systemd.ServiceState do
2+
@moduledoc """
3+
Common state properties for a systemd service object.
4+
"""
5+
6+
@type t :: %__MODULE__{
7+
type: String.t() | nil,
8+
result: String.t() | nil,
9+
exec_main_pid: non_neg_integer() | nil,
10+
exec_main_status: non_neg_integer() | nil,
11+
restart: String.t() | nil
12+
}
13+
14+
defstruct [:type, :result, :exec_main_pid, :exec_main_status, :restart]
15+
16+
@doc false
17+
@spec from_properties(map()) :: t()
18+
def from_properties(properties) when is_map(properties) do
19+
%__MODULE__{
20+
type: Map.get(properties, "Type"),
21+
result: Map.get(properties, "Result"),
22+
exec_main_pid: Map.get(properties, "ExecMainPID"),
23+
exec_main_status: Map.get(properties, "ExecMainStatus"),
24+
restart: Map.get(properties, "Restart")
25+
}
26+
end
27+
end

lib/systemd/socket_state.ex

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
defmodule Systemd.SocketState do
2+
@moduledoc """
3+
Common state properties for a systemd socket object.
4+
"""
5+
6+
@type t :: %__MODULE__{
7+
result: String.t() | nil,
8+
listen: [term()] | nil,
9+
accept: boolean() | nil,
10+
n_connections: non_neg_integer() | nil
11+
}
12+
13+
defstruct [:result, :listen, :accept, :n_connections]
14+
15+
@doc false
16+
@spec from_properties(map()) :: t()
17+
def from_properties(properties) when is_map(properties) do
18+
%__MODULE__{
19+
result: Map.get(properties, "Result"),
20+
listen: Map.get(properties, "Listen"),
21+
accept: Map.get(properties, "Accept"),
22+
n_connections: Map.get(properties, "NConnections")
23+
}
24+
end
25+
end

lib/systemd/timer_state.ex

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
defmodule Systemd.TimerState do
2+
@moduledoc """
3+
Common state properties for a systemd timer object.
4+
"""
5+
6+
@type t :: %__MODULE__{
7+
result: String.t() | nil,
8+
unit: String.t() | nil,
9+
next_elapse_usec_realtime: non_neg_integer() | nil,
10+
last_trigger_usec: non_neg_integer() | nil,
11+
timers_calendar: [term()] | nil
12+
}
13+
14+
defstruct [:result, :unit, :next_elapse_usec_realtime, :last_trigger_usec, :timers_calendar]
15+
16+
@doc false
17+
@spec from_properties(map()) :: t()
18+
def from_properties(properties) when is_map(properties) do
19+
%__MODULE__{
20+
result: Map.get(properties, "Result"),
21+
unit: Map.get(properties, "Unit"),
22+
next_elapse_usec_realtime: Map.get(properties, "NextElapseUSecRealtime"),
23+
last_trigger_usec: Map.get(properties, "LastTriggerUSec"),
24+
timers_calendar: Map.get(properties, "TimersCalendar")
25+
}
26+
end
27+
end

0 commit comments

Comments
 (0)