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