Skip to main content

lib/unitctl.ex

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