lib/mix/tasks/bonny.init.ex

defmodule Mix.Tasks.Bonny.Init do
  @moduledoc """
  Initialized an operator wiht bonny.

  * Initializes application configuration
  * Generates helper files for tests
  """

  use Mix.Task
  @shortdoc "Initialized an operator wiht bonny."

  # credo:disable-for-this-file Credo.Check.Refactor.CyclomaticComplexity

  @default_resources %{
    limits: %{cpu: "200m", memory: "200Mi"},
    requests: %{cpu: "200m", memory: "200Mi"}
  }
  @rfc_1123_subdomain_check ~r/^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/
  @dns_1035_label_check ~r/^[a-z]([-a-z0-9]*[a-z0-9])?$/
  @dns_1035_label_check_with_uppercase ~r/^[a-zA-Z]([-A-Za-z0-9]*[A-Za-z0-9])?$/
  def run(_args) do
    input =
      [
        resources: @default_resources,
        app_name: Mix.Bonny.app_name(),
        app_dir_name: Mix.Bonny.app_dir_name()
      ]
      |> get_input()

    input = create_controllers(input)
    create_version_files(input)
    create_operator_file(input)
    create_application_file(input)
    create_discovery_file(input)
    create_conn_file(input)
    create_config_file(input)
    import_bonny_config_in_main_config()
    add_dynamic_http_provder_to_test_helper()
    create_manifest_customizer(input)

    Owl.IO.puts([
      Owl.Data.tag(
        "Don't forget to configure the generated application module #{input[:app_name]}.Application to mix.exs",
        :yellow
      )
    ])
  end

  def get_input(input \\ []) do
    cond do
      is_nil(input[:api_group]) ->
        api_group =
          Owl.IO.input(
            label: "Please enter the API Group of your controller, e.g. your-operator.example.com"
          )

        input
        |> Keyword.put(:api_group, api_group)
        |> get_input()

      !valid_rfc_1123_subdomain?(input[:api_group]) ->
        Mix.Bonny.error(
          "Invalid value: #{inspect(input[:api_group])}. A lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character"
        )

        input
        |> Keyword.delete(:api_group)
        |> get_input()

      is_nil(input[:operator_name]) ->
        default = Mix.Bonny.hyphenated_app_name()

        operator_name =
          Owl.IO.input(
            label:
              ~s(Please enter the name of your operator. It must only consist of only lowercase letters and hyphens. Defaults to "#{default}"),
            optional: true
          )

        input
        |> Keyword.put(:operator_name, operator_name || default)
        |> get_input()

      is_nil(input[:service_account_name]) ->
        default = Mix.Bonny.hyphenated_app_name()

        service_account_name =
          Owl.IO.input(
            label:
              ~s(Please enter the name of the service account when run in kubernetes. It must only consist of only lowercase letters and hyphens. Defaults to "#{default}"),
            optional: true
          )

        input
        |> Keyword.put(:service_account_name, service_account_name || default)
        |> get_input()

      is_nil(input[:define_resources?]) ->
        define_resources? =
          Owl.IO.confirm(
            message:
              ~s(Would you like to customize the resources of your kubernets deployment? Defaults to #{inspect(@default_resources)}")
          )

        input
        |> Keyword.put(:define_resources?, define_resources?)
        |> get_input()

      input[:define_resources?] ->
        cpu_request = Owl.IO.input(label: "Please enter the value for the CPU request, e.g. 200m")

        mem_request =
          Owl.IO.input(label: "Please enter the value for the memory request, e.g. 200Mi")

        cpu_limit = Owl.IO.input(label: "Please enter the value for the CPU limit, e.g. 200m")
        mem_limit = Owl.IO.input(label: "Please enter the value for the memory limit, e.g. 200Mi")

        input
        |> Keyword.put(:resources, %{
          limits: %{cpu: cpu_limit, memory: mem_limit},
          requests: %{cpu: cpu_request, memory: mem_request}
        })
        |> Keyword.put(:define_resources?, false)
        |> get_input()

      is_nil(input[:crd_done]) ->
        proposition = if input[:crds], do: "another", else: "a"

        if Owl.IO.confirm(
             message: "Would you like to define #{proposition} custom resource?",
             default: is_nil(input[:crds])
           ) do
          crd = get_crd_input()

          input
          |> Keyword.update(:crds, [crd], &[crd | &1])
          |> get_input()
        else
          input
          |> Keyword.put(:crds, List.wrap(input[:crds]))
          |> Keyword.put(:crd_done, true)
          |> get_input()
        end

      length(input[:crds]) > 0 && is_nil(input[:create_controllers]) ->
        create_controllers =
          Owl.IO.confirm(
            message: "Would you like to create controllers for your custom resources?",
            default: true
          )

        input
        |> Keyword.put(:create_controllers, create_controllers)
        |> get_input()

      true ->
        input
    end
  end

  defp get_crd_input(input \\ []) do
    cond do
      is_nil(input[:name]) ->
        crd_name = Owl.IO.input(label: "What's the name (kind) of the Custom Resource?")

        input
        |> Keyword.put(:name, crd_name)
        |> get_crd_input()

      !valid_dns_1035_label_with_uppdercase?(input[:name]) ->
        Mix.Bonny.error(
          "The CRD name you defined (#{input[:name]}) is not a valid kubernetes kind!"
        )

        input
        |> Keyword.delete(:name)
        |> get_crd_input()

      is_nil(input[:version]) ->
        version =
          Owl.IO.input(
            label:
              "Please enter the API Version of your custom resource in Elixir module form, e.g. V1 or V1Alpha1"
          )

        next_input =
          if valid_dns_1035_label?(String.downcase(version)) do
            input
            |> Keyword.put(
              :version,
              Mix.Bonny.ensure_module_name(version)
            )
          else
            Mix.Bonny.error(
              "Invalid value: #{inspect(input[:version])}. A DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character"
            )

            input
          end

        get_crd_input(next_input)

      is_nil(input[:scope]) ->
        scope =
          Owl.IO.select(
            [:Namespaced, :Cluster],
            label: "What is the scope of your resource?",
            render_as: &Atom.to_string/1
          )

        input
        |> Keyword.put(:scope, scope)
        |> get_crd_input()

      true ->
        input
    end
  end

  defp create_controllers(input) do
    controllers =
      for crd <- input[:crds] do
        controller_name = crd[:name] <> "Controller"
        controller = Mix.Task.run("bonny.gen.controller", [controller_name])
        Mix.Task.reenable("bonny.gen.controller")
        Keyword.merge(controller, crd)
      end

    Keyword.put(input, :controllers, controllers)
  end

  defp create_version_files(input) do
    for crd <- input[:crds] do
      binding = Keyword.merge(crd, Keyword.take(input, [:app_name]))
      version_out = crd_version_path(crd[:version], Macro.underscore(crd[:name]))

      "version.ex"
      |> Mix.Bonny.template()
      |> Mix.Bonny.render_template(
        version_out,
        binding
      )
    end
  end

  defp create_operator_file(input) do
    input =
      input
      |> update_in([:crds, Access.all()], fn crd ->
        Bonny.API.CRD.new!(
          names: Bonny.API.CRD.kind_to_names(crd[:name]),
          scope: crd[:scope],
          group: input[:api_group],
          versions: [
            Module.concat([String.to_atom(input[:app_name]), API, crd[:version], crd[:name]])
          ]
        )
      end)
      |> update_in([:controllers, Access.all()], fn controller ->
        api_version = "#{input[:api_group]}/#{String.downcase(controller[:version])}"

        controller_module =
          Module.concat([input[:app_name], Controller, controller[:controller_name]])

        if controller[:scope] == :Namespaced do
          quote do
            %{
              query:
                K8s.Client.watch(unquote(api_version), unquote(controller[:name]),
                  namespace: watching_namespace
                ),
              controller: unquote(controller_module)
            }
          end
        else
          quote do
            %{
              query: K8s.Client.watch(unquote(api_version), unquote(controller[:name])),
              controller: unquote(controller_module)
            }
          end
        end
      end)

    output_file = "lib/#{input[:app_dir_name]}/operator.ex"

    "init/operator.ex"
    |> Mix.Bonny.template()
    |> Mix.Bonny.render_template(
      output_file,
      input
    )

    Mix.Task.run("format", [output_file])
  end

  defp create_application_file(input) do
    "init/application.ex"
    |> Mix.Bonny.template()
    |> Mix.Bonny.render_template(
      "lib/#{input[:app_dir_name]}/application.ex",
      input
    )
  end

  defp create_discovery_file(input) do
    grouped_crds =
      Enum.group_by(
        input[:crds],
        & &1[:version],
        fn crd ->
          names = Bonny.API.CRD.kind_to_names(crd[:name])

          %{
            kind: names.kind,
            name: names.plural,
            namespaced: crd[:scope] == :Namespaced,
            verbs: ["*"]
          }
        end
      )

    "init/discovery.json"
    |> Mix.Bonny.template()
    |> Mix.Bonny.render_template("test/support/discovery.json",
      grouped_crds: grouped_crds,
      api_group: input[:api_group]
    )
  end

  defp create_config_file(input) do
    "init/config.exs"
    |> Mix.Bonny.template()
    |> Mix.Bonny.render_template("config/bonny.exs", input)
  end

  defp create_conn_file(input) do
    "init/k8s_conn.ex"
    |> Mix.Bonny.template()
    |> Mix.Bonny.render_template("lib/#{input[:app_dir_name]}/k8s_conn.ex", input)
  end

  defp create_manifest_customizer(input) do
    "init/customizer.ex"
    |> Mix.Bonny.template()
    |> Mix.Bonny.render_template("lib/mix/tasks/bonny.gen.manifest/customizer.ex", input)

    "init/customizer_test.exs"
    |> Mix.Bonny.template()
    |> Mix.Bonny.render_template("test/mix/tasks/bonny.gen.manifest/customizer_test.exs", input)
  end

  defp import_bonny_config_in_main_config() do
    check = ~s(import_config "bonny.exs")

    append_conent = ~s(\n\n#{check})

    new_file_content = """
    import Config
    #{append_conent}
    """

    Mix.Bonny.append_or_create_with("config/config.exs", append_conent, new_file_content, check)
  end

  defp add_dynamic_http_provder_to_test_helper() do
    check = "K8s.Client.DynamicHTTPProvider.start_link(nil)"
    content_to_prepend = "#{check}\n"

    new_file_content = """
    #{content_to_prepend}
    ExUnit.start()
    """

    Mix.Bonny.prepend_or_create_with(
      "test/test_helper.exs",
      content_to_prepend,
      new_file_content,
      check
    )
  end

  defp crd_version_path(version, crd) do
    Path.join(["lib", Mix.Bonny.app_dir_name(), String.downcase(version), "#{crd}.ex"])
  end

  defp valid_rfc_1123_subdomain?(string), do: String.match?(string, @rfc_1123_subdomain_check)
  defp valid_dns_1035_label?(string), do: String.match?(string, @dns_1035_label_check)

  defp valid_dns_1035_label_with_uppdercase?(string),
    do: String.match?(string, @dns_1035_label_check_with_uppercase)
end