lib/nerves/artifact.ex

defmodule Nerves.Artifact do
  @moduledoc """
  Package artifacts are the product of compiling a package with a
  specific toolchain.

  """
  alias Nerves.Artifact.{Cache, BuildRunners, Resolvers}

  @checksum_short 7

  @doc """
  Builds the package and produces an  See Nerves.Artifact
  for more information.
  """
  @spec build(Nerves.Package.t(), Nerves.Package.t()) :: :ok
  def build(pkg, toolchain) do
    case pkg.build_runner do
      {build_runner, opts} ->
        case build_runner.build(pkg, toolchain, opts) do
          {:ok, path} ->
            Cache.put(pkg, path)

          {:error, error} ->
            Mix.raise("""
            Nerves encountered an error while constructing the artifact
            #{error}
            """)
        end

      :noop ->
        :ok
    end
  end

  @doc """
  Produces an archive of the package artifact which can be fetched when
  calling `nerves.artifact.get`.
  """
  def archive(%{app: app, build_runner: nil}, _toolchain, _opts) do
    Mix.raise("""
    #{inspect(app)} does not declare a build_runner and therefore cannot
    be used to produce an artifact archive.
    """)
  end

  def archive(pkg, toolchain, opts) do
    Mix.shell().info("Creating Artifact Archive")
    opts = default_archive_opts(pkg, opts)

    case pkg.build_runner do
      {build_runner, _opts} ->
        Code.ensure_compiled(pkg.platform)
        {:ok, archive_path} = build_runner.archive(pkg, toolchain, opts)
        archive_path = Path.expand(archive_path)

        path =
          opts[:path]
          |> Path.expand()
          |> Path.join(download_name(pkg) <> ext(pkg))

        if path != archive_path do
          File.cp!(archive_path, path)
        end

        {:ok, archive_path}

      _ ->
        Mix.shell().info("No build_runner specified for #{pkg.app}")
        :noop
    end
  end

  @doc """
  Cleans the artifacts for the package build_runners of all packages.
  """
  @spec clean(Nerves.Package.t()) :: :ok | {:error, term}
  def clean(pkg) do
    Mix.shell().info("Cleaning Nerves Package #{pkg.app}")

    case pkg.build_runner do
      {build_runner, _opts} ->
        build_runner.clean(pkg)

      _ ->
        Mix.shell().info("No build_runner specified for #{pkg.app}")
        :noop
    end
  end

  @doc """
  Determines if the artifact for a package is stale and needs to be rebuilt.
  """
  @spec stale?(Nerves.Package.t()) :: boolean
  def stale?(pkg) do
    if env_var?(pkg) do
      false
    else
      !Cache.valid?(pkg)
    end
  end

  @doc """
  Get the artifact name
  """
  @spec name(Nerves.Package.t()) :: String.t()
  def name(pkg) do
    "#{pkg.app}-#{host_tuple(pkg)}-#{pkg.version}"
  end

  @doc """
  Get the artifact download name
  """
  @spec download_name(Nerves.Package.t()) :: String.t()
  def download_name(pkg, opts \\ []) do
    checksum_short = opts[:checksum_short] || @checksum_short
    "#{pkg.app}-#{host_tuple(pkg)}-#{pkg.version}-#{checksum(pkg, short: checksum_short)}"
  end

  def parse_download_name(name) when is_binary(name) do
    name = Regex.run(~r/(.*)-([^-]*)-(.*)-([^-]*)/, name)

    case name do
      [_, app, host_tuple, version, checksum] ->
        {:ok,
         %{
           app: app,
           host_tuple: host_tuple,
           checksum: checksum,
           version: version
         }}

      _ ->
        {:error, "Unable to parse artifact name #{name}"}
    end
  end

  @doc """
  Get the base dir for where an artifact for a package should be stored.

  The directory for artifacts will be found in the directory returned
  by `Nerves.Env.data_dir/0` (i.e. `"#{Nerves.Env.data_dir()}/artifacts/"`).
  This location can be overriden by the environment variable `NERVES_ARTIFACTS_DIR`.
  """
  @spec base_dir() :: String.t()
  def base_dir() do
    System.get_env("NERVES_ARTIFACTS_DIR") || Path.join(Nerves.Env.data_dir(), "artifacts")
  end

  @doc """
  Get the path to where the artifact is built
  """
  def build_path(pkg) do
    Path.join([pkg.path, ".nerves", "artifacts", name(pkg)])
  end

  @doc """
  Get the path where the global artifact will be linked to.
  This path is typically a location within build_path, but can be
  vary on different build platforms.
  """
  def build_path_link(pkg) do
    case pkg.platform do
      platform when is_atom(platform) ->
        if :erlang.function_exported(platform, :build_path_link, 1) do
          apply(platform, :build_path_link, [pkg])
        else
          build_path(pkg)
        end

      _ ->
        build_path(pkg)
    end
  end

  @doc """
  Produce a base16 encoded checksum for the package from the list of files
  and expanded folders listed in the checksum config key.
  """
  @spec checksum(Nerves.Package.t()) :: String.t()
  def checksum(pkg, opts \\ []) do
    blob =
      (pkg.config[:checksum] || [])
      |> expand_paths(pkg.path)
      |> Enum.map(&File.read!/1)
      |> Enum.map(&:crypto.hash(:sha256, &1))
      |> Enum.join()

    checksum =
      :crypto.hash(:sha256, blob)
      |> Base.encode16()

    case Keyword.get(opts, :short) do
      nil ->
        checksum

      short_len ->
        {checksum_short, _} = String.split_at(checksum, short_len)
        checksum_short
    end
  end

  @doc """
  The full path to the artifact.
  """
  @spec dir(Nerves.Package.t()) :: String.t()
  def dir(pkg) do
    if env_var?(pkg) do
      System.get_env(env_var(pkg)) |> Path.expand()
    else
      base_dir()
      |> Path.join(name(pkg))
    end
  end

  @doc """
  Check to see if the artifact path is being set from the system env.
  """
  @spec env_var?(Nerves.Package.t()) :: boolean
  def env_var?(pkg) do
    name = env_var(pkg)
    dir = System.get_env(name)
    dir != nil and File.dir?(dir)
  end

  @doc """
  Determine the environment variable which would be set to override the path.
  """
  @spec env_var(Nerves.Package.t()) :: String.t()
  def env_var(pkg) do
    case pkg.type do
      :toolchain ->
        "NERVES_TOOLCHAIN"

      :system ->
        "NERVES_SYSTEM"

      _ ->
        pkg.app
        |> Atom.to_string()
        |> String.upcase()
    end
  end

  @doc """
  Expands the sites helpers from `artifact_sites` in the nerves_package config.

  Artifact sites can pass options as a third parameter for adding headers
  or query string parameters. For example, if you are trying to resolve
  artifacts hosted in a private Github repo, use `:github_api` and
  pass a user, tag, and personal access token into the sites helper:

  ```elixir
  {:github_api, "owner/repo", username: "skroob", token: "1234567", tag: "v0.1.0"}
  ```

  Or pass query parameters for the URL:

  ```elixir
  {:prefix, "https://my-organization.com", query_params: %{"id" => "1234567", "token" => "abcd"}}
  ```

  You can also use this to add an authorization header for files behind basic auth.

  ```elixir
  {:prefix, "http://my-organization.com/", headers: [{"Authorization", "Basic " <> System.get_env("BASIC_AUTH")}}]}
  ```
  """
  def expand_sites(pkg) do
    case pkg.config[:artifact_url] do
      nil ->
        # |> Enum.map(&expand_site(&1, pkg))
        # Check entire checksum length
        # This code can be removed sometime after nerves 1.0
        # and instead use the commented line above
        Keyword.get(pkg.config, :artifact_sites, [])
        |> Enum.reduce([], fn site, urls ->
          [expand_site(site, pkg), expand_site(site, pkg, checksum_short: 64) | urls]
        end)

      urls when is_list(urls) ->
        # artifact_url is deprecated and this code can be removed sometime following
        # nerves 1.0
        if Enum.any?(urls, &(!is_binary(&1))) do
          Mix.raise("""
          artifact_urls can only be strings.
          Please use artifact_sites instead.
          """)
        end

        urls

      _invalid ->
        Mix.raise("Invalid artifact_url. Please use artifact_sites instead")
    end
  end

  @doc """
  Get the path to where the artifact archive is downloaded to.
  """
  def download_path(pkg) do
    name = download_name(pkg) <> ext(pkg)

    Nerves.Env.download_dir()
    |> Path.join(name)
    |> Path.expand()
  end

  @doc """
  Get the host_tuple for the package. Toolchains are specifically build to run
  on a host for a target. Other packages are host agnostic for now. They are
  marked as `portable`.
  """
  def host_tuple(%{type: :system}) do
    "portable"
  end

  def host_tuple(_pkg) do
    (Nerves.Env.host_os() <> "_" <> Nerves.Env.host_arch())
    |> normalize_osx()
  end

  # Workaround for OTP 24 returning 'aarch64-apple-darwin20.4.0'
  # and OTP 23 and earlier returning 'arm-apple-darwin20.4.0'.
  #
  # The current Nerves tooling naming uses "arm".
  defp normalize_osx("darwin_aarch64"), do: "darwin_arm"
  defp normalize_osx(other), do: other

  @doc """
  Determines the extension for an artifact based off its type.
  Toolchains use xz compression.
  """
  @spec ext(Nerves.Package.t()) :: String.t()
  def ext(%{type: :toolchain}), do: ".tar.xz"
  def ext(_), do: ".tar.gz"

  def build_runner(config) do
    opts = config[:nerves_package][:build_runner_opts] || []

    mod =
      config[:nerves_package][:build_runner] || build_runner_type(config[:nerves_package][:type])

    {mod, opts}
  end

  defp build_runner_type(:system_platform), do: nil
  defp build_runner_type(:toolchain_platform), do: nil
  defp build_runner_type(:toolchain), do: BuildRunners.Local

  defp build_runner_type(:system) do
    case :os.type() do
      {_, :linux} -> BuildRunners.Local
      _ -> BuildRunners.Docker
    end
  end

  defp build_runner_type(_), do: BuildRunners.Local

  defp expand_paths(paths, dir) do
    paths
    |> Enum.map(&Path.join(dir, &1))
    |> Enum.flat_map(&Path.wildcard/1)
    |> Enum.flat_map(&dir_files/1)
    |> Enum.map(&Path.expand/1)
    |> Enum.filter(&File.regular?/1)
    |> Enum.uniq()
  end

  defp dir_files(path) do
    if File.dir?(path) do
      Path.wildcard(Path.join(path, "**"))
    else
      [path]
    end
  end

  defp default_archive_opts(pkg, opts) do
    name = download_name(pkg) <> ext(pkg)

    opts
    |> Keyword.put_new(:name, name)
    |> Keyword.put_new(:path, File.cwd!())
  end

  defp expand_site(_, _, _ \\ [])

  defp expand_site({:github_releases, org_proj}, pkg, opts) do
    expand_site(
      {:prefix, "https://github.com/#{org_proj}/releases/download/v#{pkg.version}/"},
      pkg,
      opts
    )
  end

  defp expand_site({:prefix, url}, pkg, opts) do
    expand_site({:prefix, url, []}, pkg, opts)
  end

  defp expand_site({:prefix, path, resolver_opts}, pkg, opts) do
    path = Path.join(path, download_name(pkg, opts) <> ext(pkg))
    {Resolvers.URI, {path, resolver_opts}}
  end

  defp expand_site({:github_api, org_proj, resolver_opts}, pkg, opts) do
    resolver_opts =
      Keyword.put(resolver_opts, :artifact_name, download_name(pkg, opts) <> ext(pkg))

    {Resolvers.GithubAPI, {org_proj, resolver_opts}}
  end

  defp expand_site(site, _pkg, _opts),
    do:
      Mix.raise("""
      Unsupported artifact site
      #{inspect(site)}

      Supported artifact sites:
      {:github_releases, "owner/repo"}
      {:github_api, "owner/repo", username: "skroob", token: "1234567", tag: "v0.1.0"}
      {:prefix, "http://myserver.com/artifacts"}
      {:prefix, "http://myserver.com/artifacts", headers: [{"Authorization", "Basic: 1234567=="}]}
      {:prefix, "http://myserver.com/artifacts", query_params: %{"id" => "1234567"}}
      {:prefix, "file:///my_artifacts/"}
      {:prefix, "/users/my_user/artifacts/"}
      """)
end