lib/mix/tasks/firmware.ex

defmodule Mix.Tasks.Firmware do
  @shortdoc "Build a firmware bundle"

  @moduledoc """
  Build a firmware image for the selected target platform.

  This task builds the project, combines the generated OTP release with
  a Nerves system image, and creates a `.fw` file that may be written
  to an SDCard or sent to a device.

  ## Command line options

    * `--verbose` - produce detailed output about release assembly
    * `--output` - (Optional) The path to the .fw file used to write the patch
      firmware. Defaults to `Nerves.Env.firmware_path/1`
  ## Environment variables

    * `NERVES_SYSTEM`    - may be set to a local directory to specify the Nerves
      system image that is used

    * `NERVES_TOOLCHAIN` - may be set to a local directory to specify the
      Nerves toolchain (C/C++ crosscompiler) that is used
  """
  use Mix.Task
  import Mix.Nerves.Utils
  alias Mix.Nerves.Preflight

  @default_mksquashfs_flags ["-no-xattrs", "-quiet"]

  @switches [verbose: :boolean, output: :string]

  @impl Mix.Task
  def run(args) do
    Preflight.check!()
    debug_info("Nerves Firmware Assembler")

    {opts, _, _} = OptionParser.parse(args, switches: @switches)

    system_path = check_nerves_system_is_set!()

    _ = check_nerves_toolchain_is_set!()

    # By this point, paths have already been loaded.
    # We just want to ensure any custom systems are compiled
    # via the precompile checks
    Mix.Task.run("nerves.precompile", ["--no-loadpaths"])
    Mix.Task.run("compile", [])

    Mix.Nerves.IO.shell_info("Building OTP Release...")

    build_release()

    config = Mix.Project.config()
    fw_out = opts[:output] || Nerves.Env.firmware_path(config)
    build_firmware(config, system_path, fw_out)
  end

  @doc false
  @spec result({Collectable.t(), exit_status :: non_neg_integer()}) :: :ok
  def result({_, 0}) do
    Mix.shell().info("""
    Firmware built successfully! 🎉

    Now you may install it to a MicroSD card using `mix burn` or upload it
    to a device with `mix upload` or `mix firmware.gen.script`+`./upload.sh`.
    """)
  end

  def result({%IO.Stream{}, err}) do
    # Any output was already sent through the stream,
    # so just halt at this point
    System.halt(err)
  end

  def result({result, _}) do
    Mix.raise("""
    Nerves encountered an error. #{inspect(result)}
    """)
  end

  defp build_release() do
    Mix.Task.run("release", [])
  end

  defp build_firmware(config, system_path, fw_out) do
    otp_app = config[:app]
    compiler_check()
    firmware_config = Application.get_env(:nerves, :firmware)

    mksquashfs_flags = firmware_config[:mksquashfs_flags] || @default_mksquashfs_flags
    set_mksquashfs_flags(mksquashfs_flags)

    rootfs_priorities =
      Nerves.Env.package(:nerves_system_br)
      |> rootfs_priorities()

    rel2fw_path = Path.join(system_path, "scripts/rel2fw.sh")
    cmd = "bash"
    args = [rel2fw_path]

    if firmware_config[:rootfs_additions] do
      Mix.shell().error(
        "The :rootfs_additions configuration option has been deprecated. Please use :rootfs_overlay instead."
      )
    end

    build_rootfs_overlay = Path.join([Mix.Project.build_path(), "nerves", "rootfs_overlay"])
    File.mkdir_p!(build_rootfs_overlay)

    write_erlinit_config(build_rootfs_overlay)

    project_rootfs_overlay =
      case firmware_config[:rootfs_overlay] || firmware_config[:rootfs_additions] do
        nil ->
          []

        overlays when is_list(overlays) ->
          overlays

        overlay ->
          [Path.expand(overlay)]
      end

    prevent_overlay_overwrites!(project_rootfs_overlay)

    rootfs_overlays =
      [build_rootfs_overlay | project_rootfs_overlay]
      |> Enum.map(&["-a", &1])
      |> List.flatten()

    fwup_conf =
      case firmware_config[:fwup_conf] do
        nil -> []
        fwup_conf -> ["-c", Path.join(File.cwd!(), fwup_conf)]
      end

    fw = ["-f", fw_out]
    release_path = Path.join(Mix.Project.build_path(), "rel/#{otp_app}")
    output = [release_path]
    args = args ++ fwup_conf ++ rootfs_overlays ++ fw ++ rootfs_priorities ++ output
    env = [{"MIX_BUILD_PATH", Mix.Project.build_path()} | standard_fwup_variables(config)]

    set_provisioning(firmware_config[:provisioning])

    config
    |> Nerves.Env.images_path()
    |> File.mkdir_p!()

    shell(cmd, args, env: env)
    |> result()
  end

  defp standard_fwup_variables(config) do
    # Assuming the fwup.conf file respects these variable like the official
    # systems do, this will set the .fw metadata to what's in the mix.exs.
    [
      {"NERVES_FW_VERSION", config[:version]},
      {"NERVES_FW_PRODUCT", config[:name] || to_string(config[:app])},
      {"NERVES_FW_DESCRIPTION", config[:description]},
      {"NERVES_FW_AUTHOR", config[:author]}
    ]
  end

  # Need to check min version for nerves_system_br to check if passing the
  # rootfs priorities option is supported. This was added in the 1.7.1 release
  # https://github.com/nerves-project/nerves_system_br/releases/tag/v1.7.1
  defp rootfs_priorities(%Nerves.Package{app: :nerves_system_br, version: vsn}) do
    case Version.compare(vsn, "1.7.1") do
      r when r in [:gt, :eq] ->
        rootfs_priorities_file =
          Path.join([Mix.Project.build_path(), "nerves", "rootfs.priorities"])

        if File.exists?(rootfs_priorities_file) do
          ["-p", rootfs_priorities_file]
        else
          []
        end

      _ ->
        []
    end
  end

  defp rootfs_priorities(_), do: []

  defp compiler_check() do
    {:ok, otpc} = erlang_compiler_version()
    {:ok, elixirc} = elixir_compiler_version()

    if otpc.major != elixirc.major do
      Mix.raise("""
      Elixir was compiled by a different version of the Erlang/OTP compiler
      than is being used now. This may not work.

      Erlang compiler used for Elixir: #{elixirc.major}.#{elixirc.minor}.#{elixirc.patch}
      Current Erlang compiler:         #{otpc.major}.#{otpc.minor}.#{otpc.patch}

      Please use a version of Elixir that was compiled using the same major
      version.

      For example:

      If your target is running OTP 25, you should use a version of Elixir
      that was compiled using OTP 25.

      If you're using asdf to manage Elixir versions, run:

      asdf install elixir #{System.version()}-otp-#{System.otp_release()}
      asdf global elixir #{System.version()}-otp-#{System.otp_release()}
      """)
    end
  end

  defp erlang_compiler_version() do
    Application.spec(:compiler, :vsn)
    |> to_string()
    |> parse_otp_version()
  end

  defp elixir_compiler_version() do
    {:file, path} = :code.is_loaded(Kernel)
    {:ok, {_, [compile_info: compile_info]}} = :beam_lib.chunks(path, [:compile_info])
    {:ok, vsn} = Keyword.fetch(compile_info, :version)

    vsn
    |> to_string()
    |> parse_otp_version()
  end

  defp write_erlinit_config(build_overlay) do
    with user_opts <- Application.get_env(:nerves, :erlinit, []),
         {:ok, system_config_file} <- Nerves.Erlinit.system_config_file(Nerves.Env.system()),
         {:ok, system_config_file} <- File.read(system_config_file),
         system_opts <- Nerves.Erlinit.decode_config(system_config_file),
         erlinit_opts <- Nerves.Erlinit.merge_opts(system_opts, user_opts),
         erlinit_config <- Nerves.Erlinit.encode_config(erlinit_opts) do
      erlinit_config_file = Path.join(build_overlay, "etc/erlinit.config")

      Path.dirname(erlinit_config_file)
      |> File.mkdir_p!()

      header = erlinit_config_header(user_opts)

      File.write!(erlinit_config_file, header <> erlinit_config)
    else
      {:error, :no_config} ->
        Nerves.Utils.Shell.warn("There was no system erlinit.config found")
        :ok

      e ->
        Nerves.Utils.Shell.warn("Error constructing  erlinit.config: #{inspect(e)}")
        :ok
    end
  end

  @doc false
  @spec erlinit_config_header(Keyword.t()) :: String.t()
  def erlinit_config_header(opts) do
    """
    # Generated from rootfs_overlay/etc/erlinit.config
    """ <>
      if opts != [] do
        """
        # with overrides from the application config
        """
      else
        """
        """
      end
  end

  defp set_mksquashfs_flags(flags) when is_list(flags) do
    System.put_env("NERVES_MKSQUASHFS_FLAGS", Enum.join(flags, " "))
  end

  @restricted_fs ["data", "root", "tmp", "dev", "sys", "proc"]
  defp prevent_overlay_overwrites!(overlay_dirs) do
    shadow_mounts =
      for dir <- overlay_dirs,
          p <- Path.wildcard([dir, "/*"]),
          fs_dir = Path.relative_to(p, dir),
          fs_dir in @restricted_fs,
          Path.wildcard([p, "/*"]) != [],
          do: Path.relative_to_cwd(p)

    if length(shadow_mounts) > 0 do
      Mix.raise("""
      The firmware contains overlay files which reference directories that are
      mounted as file systems on the device. The filesystem mount will completely
      overwrite the overlay and these files will be lost.

      Remove the following overlay directories and build the firmware again:

      #{for dir <- shadow_mounts, do: "  * #{dir}\n"}
      #{IO.ANSI.reset()}https://hexdocs.pm/nerves/advanced-configuration.html#root-filesystem-overlays
      """)
    end
  end
end