lib/nerves_runtime/init.ex

defmodule Nerves.Runtime.Init do
  @moduledoc """
  GenServer that handles device initialization.

  Initialization currently consists of:

  1. Mounting the application partition
  2. If the application partition can't be mounted, format it, and then mount it.

  Device initialization is usually a first boot only operation. It's possible
  that device filesystems get corrupt enough to cause them to be reinitialized.
  Since corruption should be rare, Nerves systems create firmware images
  without formatting the application partition. This has the benefit of
  exercising the corruption repair code. It's also required since some
  filesystem types can only be formatted on device.

  Long format times can be problematic in manufacturing. If this is an issue,
  see if you can use F2FS since it formats much faster than ext4. Some devices
  have also had stalls when formatting while waiting for enough entropy to
  generate a UUID. Look into hardcoding UUIDs or enabling a hw random number
  generator to increase entropy.
  """
  use GenServer

  alias Nerves.Runtime
  alias Nerves.Runtime.KV
  alias Nerves.Runtime.MountParser

  require Logger

  # Use a fixed UUID for the application partition. This has two
  # purposes:
  #
  #   1. mkfs.ext4 calls generate_uuid which calls getrandom(). That
  #      call can block indefinitely until the urandom pool has been
  #      initialized. This will delay startup for a long time if the
  #      app partition needs to be reformated. (mkfs.ext4 has two calls
  #      to getrandom() so this only fixes one of them.)
  #   2. Applications that would prefer to look up a partition by UUID
  #      can do so.
  @app_partition_uuid "3041e38d-615b-48d4-affb-a7787b5c4c39"

  @spec start_link(any()) :: GenServer.on_start()
  def start_link(_args) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  @impl GenServer
  def init(_args) do
    init_application_partition()

    # This GenServer is only used as a hook to initialize the application data
    # partition after the logging GenServers get started. It doesn't do
    # anything afterwards, so exit the process.
    :ignore
  end

  @spec init_application_partition :: :mounted | :mounted_with_error | :noop | :unmounted
  def init_application_partition() do
    prefix = "nerves_fw_application_part0"
    fstype = KV.get_active("#{prefix}_fstype")
    target = KV.get_active("#{prefix}_target")
    devpath = KV.get_active("#{prefix}_devpath")

    %{mounted: nil, fstype: fstype, target: target, devpath: devpath, format_performed: false}
    |> do_format()
  end

  defp do_format(%{fstype: nil}), do: :noop
  defp do_format(%{target: nil}), do: :noop
  defp do_format(%{devpath: nil}), do: :noop

  defp do_format(s) do
    s
    |> mounted_state()
    |> unmount_if_error()
    |> mount()
    |> unmount_if_error()
    |> format_if_unmounted()
    |> mount()
    |> maybe_set_shell_history()
    |> validate_mount()
  end

  defp mounted_state(s) do
    {mount_output, 0} = Runtime.cmd("mount", [], :return)

    %{s | mounted: parse_mount_state(s.devpath, s.target, mount_output)}
  end

  @doc false
  @spec parse_mount_state(String.t(), String.t(), String.t()) ::
          :mounted | :mounted_with_error | :unmounted
  def parse_mount_state(devpath, target, mount_output) do
    mounts = MountParser.parse(mount_output)

    case Map.get(mounts, target) do
      %{device: ^devpath, flags: flags} ->
        if "rw" in flags do
          :mounted
        else
          # Mounted as read only
          :mounted_with_error
        end

      _ ->
        :unmounted
    end
  end

  defp mount(%{mounted: :mounted} = s), do: s

  defp mount(s) do
    check_cmd("mount", ["-t", s.fstype, "-o", "rw", s.devpath, s.target], :info)
    mounted_state(s)
  end

  defp unmount_if_error(%{mounted: :mounted_with_error} = s) do
    check_cmd("umount", [s.target], :info)
    mounted_state(s)
  end

  defp unmount_if_error(s), do: s

  defp format_if_unmounted(%{mounted: :unmounted, fstype: fstype, devpath: devpath} = s) do
    Logger.warn(
      "Formatting application partition. If this hangs, it could be waiting on the urandom pool to be initialized"
    )

    mkfs(fstype, devpath)
    %{s | format_performed: true}
  end

  defp format_if_unmounted(s), do: s

  defp mkfs("f2fs", devpath) do
    check_cmd("mkfs.f2fs", ["-f", "#{devpath}"], :info)
  end

  defp mkfs(fstype, devpath) do
    check_cmd("mkfs.#{fstype}", ["-U", @app_partition_uuid, "-F", "#{devpath}"], :info)
  end

  defp check_cmd(cmd, args, out) do
    case Runtime.cmd(cmd, args, out) do
      {_, 0} ->
        :ok

      _ ->
        Logger.warn("Ignoring non-zero exit status from #{cmd} #{inspect(args)}")
        :ok
    end
  end

  defp validate_mount(s), do: s.mounted

  defp maybe_set_shell_history(%{mounted: :mounted, format_performed: true} = s) do
    # If we formatted and it successfully mounted, we need to
    # set the shell history again since it would have failed to
    # start previously
    Logger.info("Application data partition successfully formatted")
    Application.put_env(:kernel, :shell_history, shell_history_arg())
    s
  end

  defp maybe_set_shell_history(s), do: s

  defp shell_history_arg() do
    case :init.get_argument(:kernel) do
      {:ok, [args]} ->
        find_shell_history_arg(args)

      _ ->
        :enabled
    end
  end

  defp find_shell_history_arg([]), do: :enabled

  defp find_shell_history_arg(['shell_history', arg | _rem]) do
    # :disabled, :enabled, or user defined module
    List.to_atom(arg)
  end

  defp find_shell_history_arg([_ | rem]) do
    find_shell_history_arg(rem)
  end
end