lib/util/env.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule Antikythera.Env do
  @moduledoc """
  Module to provide helpers to access environment variables defined by antikythera.

  ## Environments and deployments

  Antikythera instances and gears may run in the following two modes:

  - As a mix project, when invoked by running `iex` or `mix` command-line tool.
  - As an OTP release, which is generated by e.g. `antikythera_core.generate_release` task.

  In general there are multiple deployments per antikythera instance.
  To distingish the target deployment from code, antikythera defines the following environment variables:

  - At compile time (for metaprogramming):
    `ANTIKYTHERA_COMPILE_ENV` must be appropriately set; the value can be retrieved by `compile_env/0`.
  - At runtime:
    `ANTIKYTHERA_RUNTIME_ENV` must be appropriately set; the value can be retrieved by `runtime_env/0`.

  It is the responsibility of antikythera instance administrators to correctly set these environment variables
  when compiling/running antikythera and gears.
  Possible values returned by `compile_env/0` and `runtime_env/0` are:

  - names of deployments given by `:deployments` application config
  - `:local` (running an OTP release at local machine for testing purpose)
  - `:undefined`

  As an example, if you set `:dev` and `:prod` in `:deployments`, then you get:

      |                           | Mix.env() at compile-time              | Mix.env() at runtime    | compile_env(), runtime_env() |
      |---------------------------+----------------------------------------+-------------------------+------------------------------|
      | $ iex -S mix              | :dev  (toplevel), :prod (dependencies) | :dev                    | :undefined                   |
      | $ mix test                | :test (toplevel), :prod (dependencies) | :test                   | :undefined                   |
      | $ mix antikythera_local.* | :prod                                  | (:mix is not available) | :local                       |
      | :dev deployment           | :prod                                  | (:mix is not available) | :dev                         |
      | :prod deployment          | :prod                                  | (:mix is not available) | :prod                        |

  You can use these values to distinguish the current context from your code.
  """

  deployment_envs = Application.compile_env!(:antikythera, :deployments) |> Keyword.keys()
  use Croma.SubtypeOfAtom, values: deployment_envs ++ [:local, :undefined]
  alias Antikythera.{GearName, GearNameStr, Url}
  alias AntikytheraCore.Handler.CowboyRouting, as: Routing

  defmodule Mapping do
    Enum.each(deployment_envs, fn env ->
      env_str = Atom.to_string(env)
      def env_var_to_atom(unquote(env_str)), do: unquote(env)
    end)

    def env_var_to_atom("local"), do: :local
    def env_var_to_atom(_), do: :undefined

    Enum.each(deployment_envs, fn env ->
      def cloud?(unquote(env)), do: true
    end)

    def cloud?(_), do: false
  end

  defun no_listen?() :: boolean do
    case System.get_env() do
      %{"NO_LISTEN" => "true"} -> true
      %{"TEST_MODE" => "blackbox_" <> _} -> true
      _otherwise -> false
    end
  end

  defun runtime_env() :: t,
    do: System.get_env("ANTIKYTHERA_RUNTIME_ENV") |> Mapping.env_var_to_atom()

  defun running_on_mix_task?() :: boolean,
    do: System.get_env("ANTIKYTHERA_MIX_TASK_MODE") == "true"

  defun running_with_release?() :: boolean,
    do: !running_on_mix_task?() && runtime_env() != :undefined

  defun running_in_cloud?() :: boolean,
    do: !running_on_mix_task?() && Mapping.cloud?(runtime_env())

  @compile_env System.get_env("ANTIKYTHERA_COMPILE_ENV") |> Mapping.env_var_to_atom()
  @compiling_for_mix_task? System.get_env("ANTIKYTHERA_MIX_TASK_MODE") == "true"
  @compiling_for_release? @compile_env != :undefined
  @compiling_for_cloud? Mapping.cloud?(@compile_env)

  defun compile_env() :: t, do: @compile_env
  defun compiling_for_mix_task?() :: boolean, do: @compiling_for_mix_task?

  defun compiling_for_release?() :: boolean,
    do: !compiling_for_mix_task?() && @compiling_for_release?

  defun compiling_for_cloud?() :: boolean, do: !compiling_for_mix_task?() && @compiling_for_cloud?

  @antikythera_instance_name Application.compile_env!(:antikythera, :antikythera_instance_name)
  defun antikythera_instance_name() :: atom, do: @antikythera_instance_name

  @gear_action_timeout_default 10_000
  @gear_action_timeout String.to_integer(
                         System.get_env("GEAR_ACTION_TIMEOUT") ||
                           "#{@gear_action_timeout_default}"
                       )
  @doc """
  Default timeout (in milli-seconds) for gear actions.

  This can be configurable by specifying `"GEAR_ACTION_TIMEOUT"` environment variable when compiling antikythera.
  Defaults to `#{@gear_action_timeout_default}`.
  """
  defun gear_action_timeout() :: pos_integer, do: @gear_action_timeout

  @default_port 8080
  @default_port_during_test 8081

  # To see which port number to use, we have to get the current mix environment at runtime,
  # since `Mix.env()` always returns `:prod` during compilation of antikythera within a gear project.
  # However, when running with an OTP release, `Mix.env/0` is not available at runtime
  # (as `:mix` is not included in antikythera's runtime dependencies).
  # Therefore we need both compile-time- and runtime-branching.
  if @compiling_for_release? do
    defp default_port_at_runtime(), do: @default_port
  else
    defp default_port_at_runtime(),
      do: if(Mix.env() == :test, do: @default_port_during_test, else: @default_port)
  end

  @doc """
  TCP port to listen to for incoming web requests.

  The port can be specified by `"PORT"` runtime environment variable.
  Defaults to `#{@default_port_during_test}` during `mix test`, and `#{@default_port}` otherwise
  (thus one can run both `iex -S mix` and `mix test` at the same time).
  """
  defun port_to_listen() :: non_neg_integer do
    case System.get_env("PORT") do
      nil -> default_port_at_runtime()
      port_str -> String.to_integer(port_str)
    end
  end

  if Antikythera.Env.Mapping.cloud?(@compile_env) do
    @scheme "https"
  else
    @scheme "http"
  end

  @doc """
  Return the base URL which based on the `Host` HTTP header of the request.

  This function is useful if you use a custom domain.
  Whether the scheme of the URL is `https` or `http` depends on whether `Antikythera.Env.Mapping.cloud?/1` returns true or false.
  """
  defun base_url(%Antikythera.Conn{request: %Antikythera.Request{headers: headers}}) :: v[Url.t()] do
    case Map.get(headers, "host") do
      nil -> raise "`Host` header is not in the request"
      host -> @scheme <> "://" <> host
    end
  end

  @doc """
  Return the base URL of the gear.

  If the gear name is `my_gear` and the Antikythera is deployed at `antikythera.example.com`,
  this function returns `https://my-gear.antikythera.example.com`.
  """
  defun default_base_url(
          gear_name :: v[GearName.t() | GearNameStr.t()],
          env :: v[t] \\ @compile_env
        ) :: Url.t() do
    if Mapping.cloud?(env) do
      "https://" <> Routing.default_domain(gear_name, env)
    else
      "http://" <> Routing.default_domain(gear_name, env) <> ":#{port_to_listen()}"
    end
  end

  defun asset_base_url(gear_name :: v[GearName.t() | GearNameStr.t()]) :: Url.t() do
    case Application.fetch_env!(:antikythera, :asset_cdn_endpoint) do
      nil -> default_base_url(gear_name)
      base_url -> base_url
    end
  end
end