lib/mix/tasks/upload.ex

defmodule Mix.Tasks.Upload do
  use Mix.Task

  @shortdoc "Uploads firmware to a Nerves device over SSH"

  @moduledoc """
  Upgrade the firmware on a Nerves device using SSH.

  By default, `mix upload` reads the firmware built by the current `MIX_ENV`
  and `MIX_TARGET` settings, and sends it to `nerves.local`. Pass in a another
  hostname to send the firmware elsewhere.

  NOTE: This implementation cannot ask for passphrases, and therefore, cannot
  connect to devices protected by username/passwords or decrypt
  password-protected private keys. One workaround is to use the `ssh-agent` to
  pass credentials.

  ## Command line options

   * `--firmware` - The path to a fw file

  ## Examples

  Upgrade a Raspberry Pi Zero at `nerves.local`:

      MIX_TARGET=rpi0 mix upload nerves.local

  Upgrade `192.168.1.120` and explicitly pass the `.fw` file:

      mix upload 192.168.1.120 --firmware _build/rpi0_prod/nerves/images/app.fw

  """

  @switches [
    firmware: :string
  ]

  @doc false
  @spec run([String.t()]) :: :ok
  def run(argv) do
    {opts, args, unknown} = OptionParser.parse(argv, strict: @switches)

    if unknown != [] do
      [{param, _} | _] = unknown
      Mix.raise("unknown parameter passed to mix upload: #{param}")
    end

    ip =
      case args do
        [address] ->
          address

        [] ->
          "nerves.local"

        _other ->
          Mix.raise(target_ip_address_or_name_msg())
      end

    firmware_path = firmware(opts)

    Mix.shell().info("""
    Path: #{firmware_path}
    #{maybe_print_firmware_uuid(firmware_path)}
    Uploading to #{ip}...
    """)

    # Options:
    #
    # ConnectTimeout - don't wait forever to connect
    # PreferredAuthentications=publickey - since keyboard interactivity doesn't
    #                                     work, don't try password entry options.
    # -T - No pseudoterminals since they're not needed for firmware updates
    opts = [
      :stream,
      :binary,
      :exit_status,
      :hide,
      :use_stdio,
      {:args,
       [
         "-o",
         "ConnectTimeout=3",
         "-o",
         "PreferredAuthentications=publickey",
         "-T",
         "-s",
         ip,
         "fwup"
       ]}
    ]

    port = Port.open({:spawn_executable, ssh_path()}, opts)

    fd = File.open!(firmware_path, [:read])

    Process.flag(:trap_exit, true)

    sender_pid = spawn_link(fn -> send_data(port, fd) end)
    port_read(port, sender_pid)
  end

  defp firmware(opts) do
    if fw = opts[:firmware] do
      fw |> Path.expand()
    else
      discover_firmware(opts)
    end
  end

  defp discover_firmware(_opts) do
    if Mix.target() == :host do
      Mix.raise("""
      You must call mix with a target set or pass the firmware's path

      Examples:

        $ MIX_TARGET=rpi0 mix upload nerves.local

      or

        $ mix upload nerves.local --firmware _build/rpi0_prod/nerves/images/app.fw
      """)
    end

    build_path = Mix.Project.build_path()
    app = Mix.Project.config()[:app]

    Path.join([build_path, "nerves", "images", "#{app}.fw"])
    |> Path.expand()
  end

  defp ssh_path() do
    case System.find_executable("ssh") do
      nil ->
        Mix.raise("""
        Cannot find 'ssh'. Check that it exists in your path
        """)

      path ->
        to_charlist(path)
    end
  end

  defp port_read(port, sender_pid) do
    receive do
      {^port, {:data, data}} ->
        IO.write(data)
        port_read(port, sender_pid)

      {^port, {:exit_status, 0}} ->
        :ok

      {^port, {:exit_status, status}} ->
        Mix.raise("ssh failed with status #{status}")

      {:EXIT, ^sender_pid, :normal} ->
        # All data has been sent
        port_read(port, sender_pid)

      {:EXIT, ^port, reason} ->
        Mix.raise("""
        Unexpected exit from ssh (#{inspect(reason)})

        This is known to happen when ssh interactively prompts you for a
        passphrase. The following are workarounds:

        1. Load your private key identity into the ssh agent by running
           `ssh-add`

        2. Use the `upload.sh` script. Create one by running
           `mix firmware.gen.script`.
        """)

      other ->
        Mix.raise("""
        Unexpected message received: #{inspect(other)}

        Please open an issue so that we can fix this.
        """)
    end
  end

  defp send_data(port, fd) do
    case IO.binread(fd, 16384) do
      :eof ->
        :ok

      {:error, _reason} ->
        exit(:read_failed)

      data ->
        Port.command(port, data)
        send_data(port, fd)
    end
  end

  defp target_ip_address_or_name_msg() do
    ~S"""
    mix upload expects a target IP address or hostname

    Example:

      If the device is reachable using `nerves-1234.local`, try:

      `mix upload nerves-1234.local`
    """
  end

  defp maybe_print_firmware_uuid(fw_path) do
    fwup = System.find_executable("fwup")
    {uuid, 0} = System.cmd(fwup, ["-m", "--metadata-key", "meta-uuid", "-i", fw_path])
    "UUID: #{uuid}\n"
  catch
    # fwup may not be on the host or something else failed, but continue
    # on as normal by returning an empty line
    _, _ -> ""
  end
end