lib/owl/system.ex

defmodule Owl.System do
  @moduledoc """
  An alternative to some `System` functions.
  """

  @doc """
  Runs `command` as a daemon, executes `operation` and kills the daemon aftewards.

  Automatically puts messages from `stderr` and `stdout` to `device` prepending them with `prefix`.
  Returns result of invoking `operation`.

  ## Options

  * `:prefix` - a prefix for `stderr` and `stdout` messages from daemon. Defaults to `command` followed by colon.
  * `:device` - device to which messages from `stderr` and `stdout` are put. Defaults to `:stdio`.
  * `:ready_check` - a function which checks the content of the messages produced by `command` before writing to `device`.
    If the function is set, then the execution of the `operation` will be blocked until `ready_check` returns `true`.
    By default this check is absent and `operation` is invoked immediately without awaiting any message.

  ## Example

      ex> Owl.System.daemon_cmd("ping", ["8.8.8.8"], fn ->
      ..>   Process.sleep(3_000)
      ..>   2 + 2
      ..> end)
      # 00:36:33.963 [debug] $ ping 8.8.8.8
      #
      # 00:36:33.964 [debug] Started daemon ping with OS pid 576077
      # ping:  PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
      # ping:  64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=28.3 ms
      # ping:  64 bytes from 8.8.8.8: icmp_seq=2 ttl=118 time=26.9 ms
      # ping:  64 bytes from 8.8.8.8: icmp_seq=3 ttl=118 time=28.6 ms

      # 00:36:36.965 [debug] $ kill 576077
      4

      ex> Owl.System.daemon_cmd(
      ..>   "kubectl",
      ..>   [
      ..>     "port-forward",
      ..>     "--namespace=my-app",
      ..>     "--kubeconfig",
      ..>     "~/.kube/myapp",
      ..>     "my-pod",
      ..>     "5432:5432"
      ..>   ],
      ..>   &dump_database/0,
      ..>   prefix: "kubectl(my-pod): ",
      ..>   ready_check: &String.contains?(&1, "Forwarding from")
      ..> )
      # Forwarding from 127.0.0.1:5432 -> 5432
      # Forwarding from [::1]:5432 -> 5432
      :ok
  """
  @spec daemon_cmd(binary(), [binary() | {:secret, binary()}], (() -> result),
          prefix: Owl.Data.t(),
          device: IO.device(),
          ready_check: (String.t() -> boolean())
        ) :: result
        when result: any()
  def daemon_cmd(command, args, operation, options \\ []) when is_function(operation, 0) do
    handle_data_opts =
      case Keyword.get(options, :ready_check) do
        nil ->
          send(self(), :run_operation)
          []

        ready_check when is_function(ready_check, 1) ->
          caller_pid = self()

          [
            handle_data:
              {false,
               fn data, ready? when is_boolean(ready?) ->
                 ready? = ready? || ready_check.(data)

                 if ready?, do: send(caller_pid, :run_operation)

                 ready?
               end}
          ]
      end

    {:ok, pid} =
      Owl.Daemon.start(
        [
          command: command,
          args: args
        ] ++ handle_data_opts ++ Keyword.take(options, [:prefix, :device])
      )

    Process.link(pid)

    try do
      receive do
        :run_operation -> operation.()
      end
    after
      Owl.Daemon.stop(pid)
    end
  end

  @doc """
  A wrapper around `System.cmd/3` which additionally logs executed `command` and `args`.

  If URL is found in logged message, then password in it is masked with asterisks.
  Additionally, it is possible to explicitly mark a whole argument as secret.

  ## Examples

      > Owl.System.cmd("echo", ["test"])
      # 10:25:34.252 [debug] $ echo test
      {"test\\n", 0}

      > Owl.System.cmd("echo", ["hello", secret: "world"])
      # 10:25:40.516 [debug] $ echo hello ********
      {"hello world\\n", 0}

      > Owl.System.cmd("psql", ["postgresql://postgres:postgres@127.0.0.1:5432", "-tAc", "SELECT 1;"])
      # 10:25:50.947 [debug] $ psql postgresql://postgres:********@127.0.0.1:5432 -tAc 'SELECT 1;'
      {"1\\n", 0}

  """
  @spec cmd(binary(), [binary() | {:secret, binary()}], keyword()) ::
          {Collectable.t(), exit_status :: non_neg_integer()}
  def cmd(command, args, opts \\ []) when is_binary(command) and is_list(args) do
    Owl.System.Helpers.log_shell_command(command, args)

    args =
      Enum.map(
        args,
        fn
          {:secret, arg} when is_binary(arg) -> arg
          arg when is_binary(arg) -> arg
        end
      )

    System.cmd(command, args, opts)
  end

  @doc """
  A wrapper around `System.shell/2` which additionally logs executed `command`.

  Similarly to `cmd/3`, it automatically hides password in found URLs.

  ## Examples

      > Owl.System.shell("echo hello world")
      # 22:36:01.440 [debug] $ echo hello world
      {"hello world\\n", 0}

      > Owl.System.shell("echo postgresql://postgres:postgres@127.0.0.1:5432")
      # 22:36:51.797 [debug] $ echo postgresql://postgres:********@127.0.0.1:5432
      {"postgresql://postgres:postgres@127.0.0.1:5432\\n", 0}
  """
  @spec shell(
          binary(),
          keyword()
        ) :: {Collectable.t(), exit_status :: non_neg_integer()}
  def shell(command, opts \\ []) when is_binary(command) do
    Owl.System.Helpers.log_shell_command(command)
    System.shell(command, opts)
  end
end