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