defmodule Mix.Tasks.HostKit.Apply do
@moduledoc """
Applies supported HostKit plan changes. Requires `--dry-run` or `--confirm`.
Prefer `--host NAME config.exs` for remote targets declared with HostKit's
`host` DSL. Raw SSH flags (`--remote`, `--user`, `--port`, `--identity-file`,
`--password-env`) are available as an escape hatch.
"""
use Mix.Task
alias HostKit.Package.{Manager, TargetRepo}
alias Mix.Tasks.HostKit.Options
@shortdoc "Apply HostKit resources"
@impl true
def run(args) do
Mix.Task.run("app.start")
{opts, positional} =
OptionParser.parse!(args,
strict: [
local: :boolean,
host: :string,
remote: :string,
user: :string,
port: :integer,
identity_file: :string,
password: :string,
password_env: :string,
silently_accept_hosts: :boolean,
sudo: :boolean,
require: :keep,
service: :keep,
ignore: :keep,
package_lock: :string,
plan: :string,
repology_cache: :string,
repology_cache_ttl: :integer,
repology_no_cache: :boolean,
dry_run: :boolean,
confirm: :boolean,
track: :boolean,
runs_root: :string,
backups_root: :string,
quiet: :boolean,
verbose: :boolean
],
aliases: [dry_run: :dry_run]
)
project = load_project(opts, positional)
Options.with_target_opts(opts, project, fn target_opts ->
plan = load_plan(opts, project, positional, target_opts)
reporter = start_reporter(opts)
try do
case HostKit.apply(plan, Keyword.put(apply_opts(opts, target_opts), :reporter, reporter)) do
{:ok, results} -> print_results(results)
{:error, reason} -> Mix.raise("HostKit apply failed: #{inspect(reason)}")
end
after
send(reporter, :stop)
end
end)
end
defp load_project(opts, positional) do
if Keyword.has_key?(opts, :plan) && !Keyword.has_key?(opts, :host) do
nil
else
path = List.first(positional) || "infra/config.exs"
maybe_build_release_kit_artifacts!(path, opts, [])
HostKit.load!(path, require: Keyword.get_values(opts, :require))
end
end
defp load_plan(opts, project, positional, target_opts) do
case Keyword.get(opts, :plan) do
nil ->
project = project || HostKit.load!(List.first(positional) || "infra/config.exs")
case HostKit.plan(project, plan_opts(opts, target_opts)) do
{:ok, plan} ->
plan
{:error, %HostKit.Diagnostics{} = diagnostics} ->
Mix.raise(HostKit.Diagnostics.Format.format(diagnostics))
{:error, reason} ->
Mix.raise("HostKit plan failed: #{inspect(reason)}")
end
artifact_path ->
target_opts = Options.expand_target_opts(target_opts)
with {:ok, artifact} <- HostKit.Plan.Artifact.load_artifact(artifact_path),
:ok <- validate_artifact_target(artifact, target_opts),
{:ok, plan} <- HostKit.Plan.Artifact.to_plan(artifact) do
plan
else
{:error, reason} ->
Mix.raise("could not load HostKit plan artifact: #{inspect(reason)}")
end
end
end
defp maybe_build_release_kit_artifacts!(path, opts, target_opts) do
if Keyword.get(opts, :dry_run, false) do
:ok
else
do_build_release_kit_artifacts!(path, opts, target_opts)
end
end
defp do_build_release_kit_artifacts!(path, opts, target_opts) do
artifacts =
HostKit.Recipes.OTPRelease.collect_release_kit(path,
require: Keyword.get_values(opts, :require),
services: Options.selected_services(opts)
)
build_opts = apply_opts(opts, target_opts)
Enum.each(artifacts, fn artifact ->
build_release_kit_artifact!(artifact, build_opts, opts)
end)
end
defp build_release_kit_artifact!(artifact, build_opts, cli_opts) do
label = HostKit.Recipes.OTPRelease.release_kit_label(artifact)
unless Keyword.get(cli_opts, :quiet, false) do
Mix.shell().info("▶ #{label} build")
end
try do
HostKit.Recipes.OTPRelease.build_release_kit_artifact!(artifact, build_opts)
unless Keyword.get(cli_opts, :quiet, false) do
Mix.shell().info("✓ #{label} build")
end
rescue
exception ->
unless Keyword.get(cli_opts, :quiet, false) do
Mix.shell().error("✗ #{label} build failed")
end
reraise exception, __STACKTRACE__
end
end
defp validate_artifact_target(%HostKit.Plan.Artifact{target: target}, target_opts)
when is_map(target) do
with :ok <- validate_package_repo(target, target_opts) do
validate_package_manager(target, target_opts)
end
end
defp validate_artifact_target(_artifact, _target_opts), do: :ok
defp validate_package_repo(%{"package_repo" => expected}, target_opts)
when is_binary(expected) do
actual =
case Keyword.get(target_opts, :package_repo) do
repo when is_binary(repo) -> {:ok, repo}
_other -> TargetRepo.detect(target_opts)
end
case actual do
{:ok, ^expected} ->
:ok
{:ok, actual} ->
{:error, {:plan_artifact_target_mismatch, :package_repo, expected, actual}}
{:error, reason} ->
{:error, {:plan_artifact_target_detection_failed, :package_repo, reason}}
end
end
defp validate_package_repo(_target, _target_opts), do: :ok
defp validate_package_manager(%{"package_manager" => expected}, target_opts)
when is_binary(expected) do
case Manager.resolve(target_opts) do
{:ok, manager} ->
actual = to_string(manager)
if actual == expected do
:ok
else
{:error, {:plan_artifact_target_mismatch, :package_manager, expected, actual}}
end
{:error, reason} ->
{:error, {:plan_artifact_target_detection_failed, :package_manager, reason}}
end
end
defp validate_package_manager(_target, _target_opts), do: :ok
defp start_reporter(opts) do
spawn(fn ->
reporter_loop(%{
quiet: Keyword.get(opts, :quiet, false),
verbose: Keyword.get(opts, :verbose, false)
})
end)
end
defp reporter_loop(opts) do
receive do
{HostKit.Apply, %HostKit.Apply.Event{} = event} ->
if print_event?(event, opts), do: IO.puts(HostKit.Apply.Event.format(event))
reporter_loop(opts)
:stop ->
:ok
end
end
defp print_event?(_event, %{verbose: true}), do: true
defp print_event?(%HostKit.Apply.Event{type: :change_skipped}, _opts), do: false
defp print_event?(%HostKit.Apply.Event{type: type}, %{quiet: true}),
do:
type in [
:apply_started,
:apply_finished,
:change_failed,
:readiness_failed,
:service_failed,
:health_check_failed
]
defp print_event?(_event, _opts), do: true
defp print_results(results) do
results
|> Enum.map_join("\n", fn %{change: change, status: status} ->
"#{status} #{HostKit.Plan.Format.format_change(change)}"
end)
|> IO.puts()
end
defp plan_opts(opts, target_opts) do
target_opts
|> Keyword.put(:ignore, Options.ignored_resources(opts))
|> put_present(:services, Options.selected_services(opts))
|> put_present(:package_lock, Keyword.get(opts, :package_lock))
|> Options.put_repology_cache(opts)
end
defp apply_opts(opts, target_opts) do
target_opts
|> Options.expand_target_opts()
|> Keyword.merge(
dry_run: Keyword.get(opts, :dry_run, false),
confirm: Keyword.get(opts, :confirm, false),
sudo: Keyword.get(opts, :sudo, Keyword.get(target_opts, :sudo, false)),
track: Keyword.get(opts, :track, false)
)
|> put_present(:hostkit_runs_root, Keyword.get(opts, :runs_root))
|> put_present(:hostkit_backups_root, Keyword.get(opts, :backups_root))
|> put_present(:up_plan_artifact, Keyword.get(opts, :plan))
end
defp put_present(opts, _key, nil), do: opts
defp put_present(opts, key, value), do: Keyword.put(opts, key, value)
end