core/util/path.ex

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

use Croma

defmodule AntikytheraCore.Path do
  alias Antikythera.{Env, GearName, TenantId, SecondsSinceEpoch}

  @temp_file_suffix "-temp"

  #
  # paths under antikythera root directory
  #
  if Env.compiling_for_cloud?() do
    # Use compile-time application config
    @antikythera_root_dir Application.compile_env!(:antikythera, :antikythera_root_dir)
    defun antikythera_root_dir() :: Path.t(), do: @antikythera_root_dir
    defun compiled_core_dir() :: Path.t(), do: Path.join(antikythera_root_dir(), "releases")
  else
    # Use runtime application config
    defun antikythera_root_dir() :: Path.t(),
      do: Application.fetch_env!(:antikythera, :antikythera_root_dir)

    defun compiled_core_dir() :: Path.t() do
      parent_dir = Path.join([__DIR__, "..", "..", ".."]) |> Path.expand()

      release_generating_project_dir =
        case Path.basename(parent_dir) do
          # from antikythera instance or gear projects
          "deps" -> Path.join([parent_dir, ".."])
          # as a standalone project
          _ -> Path.join([__DIR__, "..", ".."])
        end
        |> Path.expand()

      Path.join([
        release_generating_project_dir,
        "rel_local_erlang-#{System.otp_release()}",
        "#{Env.antikythera_instance_name()}",
        "releases"
      ])
    end
  end

  defun core_config_file_path() :: Path.t(),
    do: Path.join([antikythera_root_dir(), "config", "antikythera"])

  defun gear_config_dir() :: Path.t(), do: Path.join(antikythera_root_dir(), "gear_config")
  defun history_dir() :: Path.t(), do: Path.join(antikythera_root_dir(), "history")

  defun compiled_gears_dir() :: Path.t(),
    do: Path.join(antikythera_root_dir(), "compiled_gears_erlang-#{System.otp_release()}")

  defunp tenant_dir() :: Path.t(), do: Path.join(antikythera_root_dir(), "tenant")

  defun tenant_ids_file_path() :: Path.t(), do: Path.join(tenant_dir(), "ids.gz")
  defun tenant_setting_dir() :: Path.t(), do: Path.join(tenant_dir(), "setting")

  defun tenant_setting_file_path(id :: v[TenantId.t()]) :: Path.t(),
    do: Path.join(tenant_setting_dir(), id)

  defun gear_config_file_path(gear_name :: v[GearName.t()]) :: Path.t() do
    Path.join(gear_config_dir(), Atom.to_string(gear_name))
  end

  #
  # paths under unpacked release directory
  #
  defun gear_log_dir(gear_name :: v[GearName.t()]) :: Path.t() do
    Path.join([Application.app_dir(:antikythera), "..", "..", "log", Atom.to_string(gear_name)])
    |> Path.expand()
  end

  defun gear_log_file_path(gear_name :: v[GearName.t()]) :: Path.t() do
    Path.join(gear_log_dir(gear_name), "#{gear_name}.log.gz")
  end

  defun core_log_file_path(name :: v[String.t()]) :: Path.t() do
    Path.join(gear_log_dir(:antikythera), "#{name}.log.gz")
  end

  #
  # paths under "secret" directory in each node
  #
  defunp secret_dir() :: Path.t() do
    if Env.running_in_cloud?() do
      # At runtime `:code.root_dir/0` returns the unpacked "release" directory; "secret" is placed next to "release"
      Path.join([:code.root_dir(), "..", "secret"]) |> Path.expand()
    else
      case System.get_env("ANTIKYTHERA_SECRET_DIR") do
        nil -> Path.join([antikythera_root_dir(), "..", "secret"]) |> Path.expand()
        dir -> dir
      end
    end
  end

  defun config_encryption_key_path() :: Path.t() do
    Path.join(secret_dir(), "config_encryption_key")
  end

  defun system_info_access_token_path() :: Path.t() do
    Path.join(secret_dir(), "system_info_access_token")
  end

  #
  # path for Antikythera.Tmpdir
  #
  defun gear_tmp_dir() :: Path.t() do
    # This must be fetched at runtime at least in non-cloud environment in order to use
    # path with the current OS pid, not with the OS pid of `$ mix compile` command.
    Application.fetch_env!(:antikythera, :gear_tmp_dir)
  end

  #
  # path for RaftedValue & RaftFleet
  #
  defun raft_persistence_dir_parent() :: Path.t() do
    # This must be fetched at runtime at least in non-cloud environment in order to use
    # path with the current OS pid, not with the OS pid of `$ mix compile` command.
    Application.fetch_env!(:antikythera, :raft_persistence_dir_parent)
  end

  #
  # utilities
  #
  # In order not to miss file modifications due to NFS caching (if any) and/or clock skew,
  # we make margin by shifting `since` by a few seconds.
  # We don't care if the same modification is observed multiple times here.
  # The value comes from: default max lifetime of NFS client-side caches (60s) added by 2s to avoid issues due to clock skew.
  # (Should we make this a mix config item?)
  @file_modification_time_margin_in_seconds if Env.compiling_for_cloud?(), do: 62, else: 0

  defun changed?(path :: Path.t(), since :: v[SecondsSinceEpoch.t()]) :: boolean do
    since_with_margin = max(0, since - @file_modification_time_margin_in_seconds)
    %File.Stat{mtime: mtime} = File.stat!(path, time: :posix)
    since_with_margin <= mtime
  end

  defun list_modified_files(dir :: Path.t(), since :: v[SecondsSinceEpoch.t()]) :: [String.t()] do
    since_with_margin = max(0, since - @file_modification_time_margin_in_seconds)

    Path.wildcard(Path.join(dir, "*"))
    |> Enum.filter(fn path ->
      modified_regular_file?(path, since_with_margin) and
        not String.ends_with?(path, @temp_file_suffix)
    end)
    |> Enum.sort()
  end

  defunp modified_regular_file?(path :: Path.t(), since :: v[SecondsSinceEpoch.t()]) :: boolean do
    case File.stat(path, time: :posix) do
      {:ok, %File.Stat{type: :regular, mtime: t}} when t >= since -> true
      {:ok, _unchanged_or_not_regular} -> false
      # removed between `Path.wildcard/1` and `File.stat/2`
      {:error, :enoent} -> false
    end
  end

  defun atomic_write!(
          path :: Path.t(),
          content :: iodata()
        ) :: :ok do
    node_name = Node.self() |> Atom.to_string()
    # Convert `#PID<0.23069.636>` to `0.23069.636`
    pid_path_safe_string =
      inspect(self()) |> String.split("<") |> Enum.at(1) |> String.replace(">", "")

    temp_path = "#{path}-#{node_name}-#{pid_path_safe_string}#{@temp_file_suffix}"
    File.write!(temp_path, content, [:sync])
    File.rename!(temp_path, path)
  end
end