defmodule Unitctl do
@moduledoc """
Docker-like process controls backed by pure systemd primitives.
`unitctl` is a small, opinionated layer over `systemdkit`. It starts isolated
transient services, then delegates lifecycle and inspection operations to
systemd over D-Bus. It does not shell out to `systemctl` and it does not use
Docker.
## Example
{:ok, instance} =
Unitctl.start(
name: "demo-worker",
command: ["/usr/bin/env", "sleep", "60"],
description: "Demo worker",
resources: %{memory_max: 256 * 1024 * 1024, tasks_max: 64},
sandbox: %{no_new_privileges: true, private_tmp: true}
)
{:ok, state} = Unitctl.inspect(instance)
:ok = Unitctl.stop(instance)
This package is intentionally higher-level than `systemdkit`: `systemdkit`
exposes systemd APIs; `unitctl` exposes runtime/app controls.
"""
alias Unitctl.{Instance, Spec, Stats}
@doc """
Starts a transient systemd service from a `Unitctl.Spec` or keyword list.
"""
@spec start(Spec.t() | keyword() | map()) :: {:ok, Instance.t()} | {:error, term()}
def start(%Spec{} = spec) do
Systemd.with_connection([bus: spec.bus], fn conn ->
with {:ok, job} <-
Systemd.Manager.start_transient_unit(
conn,
Spec.unit_name(spec),
Spec.to_properties(spec)
),
:ok <- maybe_await_job(conn, job, spec) do
{:ok, Instance.new(spec, job)}
end
end)
end
def start(attrs) when is_list(attrs) or is_map(attrs) do
with {:ok, spec} <- Spec.new(attrs), do: start(spec)
end
@doc """
Stops an instance or unit name.
"""
@spec stop(Instance.t() | String.t(), keyword()) ::
:ok | {:ok, Systemd.Job.t()} | {:error, term()}
def stop(instance_or_unit, opts \\ []) do
instance_or_unit
|> unit_name()
|> Systemd.stop_unit(opts)
end
@doc """
Restarts an instance or unit name.
"""
@spec restart(Instance.t() | String.t(), keyword()) ::
:ok | {:ok, Systemd.Job.t()} | {:error, term()}
def restart(instance_or_unit, opts \\ []) do
instance_or_unit
|> unit_name()
|> Systemd.restart_unit(opts)
end
@doc """
Reads common systemd state for an instance or unit name.
"""
@spec inspect(Instance.t() | String.t(), keyword()) ::
{:ok, Systemd.UnitState.t()} | {:error, term()}
def inspect(instance_or_unit, opts \\ []) do
instance_or_unit
|> unit_name()
|> Systemd.unit_state(opts)
end
@doc """
Reads runtime stats for an instance or unit name.
Stats are sourced from systemd/cgroup D-Bus properties. Some fields may be
`nil` when the host systemd version does not expose the property or when the
corresponding accounting feature is disabled for the unit.
"""
@spec stats(Instance.t() | String.t(), keyword()) :: {:ok, Stats.t()} | {:error, term()}
def stats(instance_or_unit, opts \\ []) do
unit_name = unit_name(instance_or_unit)
Systemd.with_connection(opts, fn conn ->
with {:ok, unit} <- Systemd.Manager.get_unit(conn, unit_name) do
{:ok, build_stats(conn, unit_name, unit)}
end
end)
end
defp build_stats(conn, unit_name, unit) do
Stats.new(%{
unit: unit_name,
active_state: property(conn, unit, "org.freedesktop.systemd1.Unit", "ActiveState"),
sub_state: property(conn, unit, "org.freedesktop.systemd1.Unit", "SubState"),
main_pid: property(conn, unit, "org.freedesktop.systemd1.Service", "MainPID"),
control_group: property(conn, unit, "org.freedesktop.systemd1.Unit", "ControlGroup"),
memory_current: property(conn, unit, "org.freedesktop.systemd1.Unit", "MemoryCurrent"),
memory_peak: property(conn, unit, "org.freedesktop.systemd1.Unit", "MemoryPeak"),
tasks_current: property(conn, unit, "org.freedesktop.systemd1.Unit", "TasksCurrent"),
cpu_usage_nsec: property(conn, unit, "org.freedesktop.systemd1.Unit", "CPUUsageNSec"),
ip_ingress_bytes: property(conn, unit, "org.freedesktop.systemd1.Unit", "IPIngressBytes"),
ip_egress_bytes: property(conn, unit, "org.freedesktop.systemd1.Unit", "IPEgressBytes"),
io_read_bytes: property(conn, unit, "org.freedesktop.systemd1.Unit", "IOReadBytes"),
io_write_bytes: property(conn, unit, "org.freedesktop.systemd1.Unit", "IOWriteBytes")
})
end
defp property(conn, unit, interface, property) do
case Systemd.Properties.get(conn, unit.object_path, interface, property) do
{:ok, value} -> normalize_unset_counter(value)
{:error, _error} -> nil
end
end
defp normalize_unset_counter(value) when value in [18_446_744_073_709_551_615], do: nil
defp normalize_unset_counter(value), do: value
defp maybe_await_job(_conn, _job, %Spec{wait: false}), do: :ok
defp maybe_await_job(conn, job, %Spec{wait: true, wait_timeout: timeout}) do
Systemd.Job.await_signal(conn, job, timeout: timeout)
end
defp unit_name(%Instance{unit: unit}), do: unit
defp unit_name(unit) when is_binary(unit), do: unit
end