core/config/gear.ex

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

use Croma

defmodule AntikytheraCore.Config.Gear do
  alias Croma.Result, as: R
  alias Antikythera.{GearName, SecondsSinceEpoch, Domain, CowboyWildcardSubdomain}
  alias Antikythera.Crypto.Aes
  alias AntikytheraCore.Path, as: CorePath
  alias AntikytheraCore.Ets.ConfigCache.Gear, as: GCache
  alias AntikytheraCore.Config.EncryptionKey
  alias AntikytheraCore.GearManager
  alias AntikytheraCore.GearLog.{Writer, Level}
  alias AntikytheraCore.Alert.Manager, as: CoreAlertManager
  alias AntikytheraCore.Alert.HandlerConfigsMap
  require AntikytheraCore.Logger, as: L

  defmodule CustomDomainList do
    use Croma.SubtypeOfList,
      elem_module: Croma.TypeGen.union([Domain, CowboyWildcardSubdomain]),
      max_length: 10
  end

  use Croma.Struct,
    recursive_new?: true,
    fields: [
      kv: Croma.Map,
      domains: CustomDomainList,
      log_level: Level,
      alerts: HandlerConfigsMap,
      # can be used to store instance-specific information
      # whose structure needs to be managed by administrative gears
      internal_kv: {Croma.Map, default: %{}}
    ]

  defun default() :: t do
    %__MODULE__{kv: %{}, domains: [], log_level: Level.default(), alerts: %{}}
  end

  defun read(gear_name :: v[GearName.t()]) :: t do
    case CorePath.gear_config_file_path(gear_name) |> File.read() do
      {:ok, encrypted} ->
        Aes.ctr128_decrypt(encrypted, EncryptionKey.get())
        |> R.bind(&decode/1)
        |> case do
          {:ok, conf} ->
            conf

          {:error, reason} ->
            msg = "failed to decode gear config JSON (#{gear_name}): #{inspect(reason)}"
            L.error(msg)
            raise msg
        end

      {:error, :enoent} ->
        default()
    end
  end

  defunp decode(b :: v[binary]) :: R.t(t) do
    Poison.decode(b) |> R.bind(&new/1)
  end

  defun write(gear_name :: v[GearName.t()], config :: v[t]) :: :ok do
    path = CorePath.gear_config_file_path(gear_name)
    content = Aes.ctr128_encrypt(Poison.encode!(config), EncryptionKey.get())
    CorePath.atomic_write!(path, content)
  end

  defunp gear_names_having_modified_config_files(last_checked_at :: v[SecondsSinceEpoch.t()]) :: [
           GearName.t()
         ] do
    CorePath.list_modified_files(CorePath.gear_config_dir(), last_checked_at)
    # generate atom from trusted data source
    |> Enum.map(fn path -> Path.basename(path) |> String.to_atom() end)
  end

  defun load_all(last_checked_at :: v[SecondsSinceEpoch.t()]) :: :ok do
    gear_names = gear_names_having_modified_config_files(last_checked_at)

    if !Enum.empty?(gear_names) do
      L.info("found change in gear config: #{inspect(gear_names)}")
    end

    gear_configs = Enum.map(gear_names, fn gear_name -> {gear_name, read(gear_name)} end)
    any_domains_changed? = apply_changes(gear_configs)

    if any_domains_changed? do
      AntikytheraCore.StartupManager.update_routing(GearManager.running_gear_names())
    end

    :ok
  end

  defunpt apply_changes(gear_configs :: Keyword.t(t)) :: boolean do
    Enum.map(gear_configs, fn {gear_name,
                               %__MODULE__{domains: domains, log_level: level, alerts: alerts} =
                                 conf} ->
      case GCache.read(gear_name) do
        nil ->
          GCache.write(gear_name, conf)
          if level != Level.default(), do: Writer.set_min_level(gear_name, level)
          CoreAlertManager.update_handler_installations(gear_name, alerts)
          !Enum.empty?(domains)

        %__MODULE__{domains: cached_domains, log_level: cached_log_level, alerts: cached_alerts} =
            cached ->
          if conf != cached, do: GCache.write(gear_name, conf)
          if level != cached_log_level, do: Writer.set_min_level(gear_name, level)

          if alerts != cached_alerts,
            do: CoreAlertManager.update_handler_installations(gear_name, alerts)

          domains != cached_domains
      end
    end)
    |> Enum.any?()
  end

  defun ensure_loaded(gear_name :: v[GearName.t()]) :: :ok do
    # Assuming that this function is called from `GearApplication.start/2`,
    # (unlike `apply_changes/1` above) it's not necessary to notify gear's Logger and cowboy router of this config,
    # as it will be done within `GearApplication.start/2`.
    GCache.write(gear_name, read(gear_name))
  end

  defun dump_all_from_env_to_file() :: :ok do
    System.get_env()
    |> Enum.filter(fn {k, _} -> String.ends_with?(k, "_CONFIG_JSON") end)
    |> Enum.each(fn {key, json} ->
      # Only in dev/test environment, no problem
      gear_name =
        String.replace_suffix(key, "_CONFIG_JSON", "") |> String.downcase() |> String.to_atom()

      config = %__MODULE__{default() | kv: Poison.decode!(json)}
      write(gear_name, config)
    end)
  end

  # To be used by administrative gears
  defun read_all() :: Keyword.t(t) do
    all_known_gears =
      Enum.uniq(gear_names_having_modified_config_files(0) ++ GearManager.running_gear_names())

    Enum.map(all_known_gears, fn gear_name -> {gear_name, read(gear_name)} end)
  end
end