Skip to main content

notebooks/learn/hostkit_demo_helpers.exs

defmodule HostKit.LivebookDemo do
  alias Kino.Markdown
  alias HostKit.Runner.SSH.Connection

  def ssh_opts(settings) do
    identity_file = String.trim(settings.identity_file || "")

    [
      host: settings.server,
      user: settings.user,
      sudo: true,
      port: settings.ssh_port,
      silently_accept_hosts: true,
      retry: [attempts: Map.get(settings, :ssh_retries, 3)],
      connect_timeout: 5_000
    ]
    |> maybe_put(:identity_file, identity_file, &Path.expand/1)
    |> maybe_put(:password, settings.password)
  end

  def with_uploaded_key(%{key_file: %{file_ref: file_ref}} = settings) do
    source = Kino.Input.file_path(file_ref)

    path =
      Path.join(
        System.tmp_dir!(),
        "hostkit-livebook-ssh-key-#{System.unique_integer([:positive])}"
      )

    File.cp!(source, path)
    File.chmod!(path, 0o600)
    %{settings | identity_file: path}
  end

  def with_uploaded_key(settings), do: settings

  def target_form(defaults \\ []) do
    defaults = Map.new(defaults)
    caller = self()

    form =
      Kino.Control.form(
        [
          server: Kino.Input.text("Server", default: Map.get(defaults, :server, "127.0.0.1")),
          user: Kino.Input.text("SSH user", default: Map.get(defaults, :user, "root")),
          password:
            Kino.Input.password("SSH password", default: Map.get(defaults, :password, "")),
          key_file: Kino.Input.file("Upload SSH key"),
          identity_file:
            Kino.Input.text("SSH key path on server",
              default: Map.get(defaults, :identity_file, "")
            ),
          ssh_port: Kino.Input.number("SSH port", default: Map.get(defaults, :ssh_port, 22)),
          public_port:
            Kino.Input.number("Public port", default: Map.get(defaults, :public_port, 18_080)),
          message:
            Kino.Input.text("Message",
              default: Map.get(defaults, :message, "Deployed by HostKit")
            )
        ],
        submit: "Check SSH connection"
      )

    status = Kino.Frame.new()

    Kino.Frame.render(
      status,
      Markdown.new("Enter SSH details and click **Check SSH connection**.")
    )

    Kino.listen(form, fn %{type: :submit, data: settings} ->
      settings = with_uploaded_key(settings)
      send(caller, {:demo_settings, settings})
      Kino.Frame.render(status, check_ssh(settings))
    end)

    Kino.Layout.grid([form, status], columns: 1)
  end

  def phoenix_target_form(defaults \\ []) do
    defaults = Map.new(defaults)
    caller = self()

    form =
      Kino.Control.form(
        [
          server: Kino.Input.text("Server", default: Map.get(defaults, :server, "127.0.0.1")),
          user: Kino.Input.text("SSH user", default: Map.get(defaults, :user, "root")),
          password:
            Kino.Input.password("SSH password", default: Map.get(defaults, :password, "")),
          key_file: Kino.Input.file("Upload SSH key"),
          identity_file:
            Kino.Input.text("SSH key path on server",
              default: Map.get(defaults, :identity_file, "")
            ),
          ssh_port: Kino.Input.number("SSH port", default: Map.get(defaults, :ssh_port, 22)),
          public_hostname:
            Kino.Input.text("Public hostname", default: Map.get(defaults, :public_hostname, "")),
          public_port:
            Kino.Input.number("Public port", default: Map.get(defaults, :public_port, 18_081)),
          app_port:
            Kino.Input.number("Phoenix port", default: Map.get(defaults, :app_port, 14_000))
        ],
        submit: "Check SSH connection"
      )

    status = Kino.Frame.new()

    Kino.Frame.render(
      status,
      Markdown.new("Enter SSH details and click **Check SSH connection**.")
    )

    Kino.listen(form, fn %{type: :submit, data: settings} ->
      settings = with_uploaded_key(settings)
      send(caller, {:demo_settings, settings})
      Kino.Frame.render(status, check_ssh(settings))
    end)

    Kino.Layout.grid([form, status], columns: 1)
  end

  def await_target(_form) do
    receive do
      {:demo_settings, settings} -> settings
    end
  end

  def check_ssh(settings) do
    case Connection.open(ssh_opts(settings)) do
      {:ok, conn} ->
        Connection.close(conn)
        Markdown.new("✅ **SSH works** — #{settings.user}@#{settings.server}:#{settings.ssh_port}")

      {:error, reason} ->
        Markdown.new("⚠️ **SSH failed** — #{ssh_error(reason)}")
    end
  end

  def plan_summary(plan) do
    counts = Enum.frequencies_by(plan.changes, & &1.action)

    Markdown.new("""
    ### Plan

    **#{Map.get(counts, :create, 0)}** create · **#{Map.get(counts, :update, 0)}** update · **#{Map.get(counts, :delete, 0)}** delete · **#{Map.get(counts, :no_op, 0)}** unchanged · **#{Map.get(counts, :read, 0)}** read errors
    """)
  end

  def plan_table(plan) do
    plan.changes
    |> Enum.map(fn change ->
      resource = resource_parts(change.resource_id)

      %{
        action: change.action,
        type: resource.type,
        name: resource.name,
        status: format_reason(change.reason)
      }
    end)
    |> Kino.DataTable.new(
      keys: [:action, :type, :name, :status],
      name: "Plan changes",
      num_rows: 20
    )
  end

  def collect_apply_progress do
    Stream.repeatedly(fn ->
      receive do
        {HostKit.Apply, event} -> HostKit.Apply.Event.format(event)
      after
        0 -> nil
      end
    end)
    |> Enum.take_while(& &1)
  end

  def apply_summary({:ok, results}, progress) when is_list(results) do
    counts = Enum.frequencies_by(results, &Map.get(&1, :status, :unknown))

    Markdown.new("""
    ### Deploy summary

    - Applied: #{Map.get(counts, :applied, 0)}
    - Skipped: #{Map.get(counts, :skipped, 0)}
    - Failed: #{Map.get(counts, :failed, 0)}
    - Events: #{length(progress)}
    """)
  end

  def apply_summary(other, progress) do
    Markdown.new("""
    ### Deploy result

    - Result: `#{inspect(other)}`
    - Events: #{length(progress)}
    """)
  end

  def apply_table({:ok, results}) when is_list(results) do
    results
    |> Enum.map(fn result ->
      change = Map.get(result, :change)
      resource = change && resource_parts(change.resource_id)

      %{
        status: Map.get(result, :status),
        action: change && change.action,
        type: resource && resource.type,
        name: resource && resource.name
      }
    end)
    |> Kino.DataTable.new(
      keys: [:status, :action, :type, :name],
      name: "Deploy results",
      num_rows: 20
    )
  end

  def apply_table(result),
    do: Kino.DataTable.new([%{result: inspect(result)}], name: "Deploy result")

  def verify_summary(response, public_url) do
    Markdown.new("""
    ### Verify

    ✅ Site is reachable

    - Status: `#{response.status}`
    - URL: [#{public_url}](#{public_url})
    """)
  end

  defp resource_parts(%HostKit.Addr.Resource{} = resource) do
    %{type: to_string(resource.type), name: to_string(resource.name)}
  end

  defp resource_parts({type, name}), do: %{type: to_string(type), name: to_string(name)}
  defp resource_parts(other), do: %{type: "resource", name: inspect(other)}

  defp format_reason(nil), do: "in sync"
  defp format_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
  defp format_reason(reason), do: inspect(reason)

  defp ssh_error(reason) when is_list(reason) do
    reason
    |> List.to_string()
    |> ssh_error()
  rescue
    _error -> inspect(reason)
  end

  defp ssh_error(reason) when is_binary(reason) do
    cond do
      String.contains?(reason, "Unable to connect using the available authentication methods") ->
        "authentication failed. Check the SSH user, password, or uploaded key."

      String.contains?(reason, "Connection refused") ->
        "connection refused. Check the server address and SSH port."

      true ->
        reason
    end
  end

  defp ssh_error(reason), do: inspect(reason)

  defp maybe_put(opts, _key, ""), do: opts
  defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
  defp maybe_put(opts, _key, "", _fun), do: opts
  defp maybe_put(opts, key, value, fun), do: Keyword.put(opts, key, fun.(value))
end