defmodule Mix.Tasks.HostKit.Audit do @moduledoc """ Audits a HostKit config against the selected target. mix host_kit.audit [options] [config.exs] The audit output starts with a compact host report and then prints the normal plan diff. Use `--format json` for machine-readable output. """ use Mix.Task alias Mix.Tasks.HostKit.Options @shortdoc "Audit declared HostKit resources" @impl true def run(args) do Mix.Task.run("app.start") {opts, positional} = parse!(args) path = List.first(positional) || "infra/config.exs" project = HostKit.load!(path, require: Keyword.get_values(opts, :require)) Options.with_target_opts(opts, project, fn target_opts -> case HostKit.Project.audit(project, audit_opts(opts, target_opts)) do {:ok, plan} -> IO.puts(format_audit(plan, opts)) {:error, reason} -> Mix.raise("HostKit audit failed: #{inspect(reason)}") end end) end defp parse!(args) do 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, format: :string, service: :keep, ignore: :keep, package_lock: :string, repology_cache: :string, repology_cache_ttl: :integer, repology_no_cache: :boolean ] ) end defp audit_opts(opts, target_opts) do target_opts |> Options.expand_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 format_audit(plan, opts) do report = HostKit.Plan.Summary.audit_report(plan) case Keyword.get(opts, :format, "text") do "json" -> %{ report: report, plan: plan |> HostKit.Plan.Artifact.from_plan() |> HostKit.Plan.Artifact.dump() } |> Jason.encode_to_iodata!(pretty: true) "inspect" -> inspect(%{report: report, plan: plan}, pretty: true, limit: :infinity, structs: true) "text" -> [format_report(report), "\n\n", HostKit.Plan.Format.format(plan)] |> IO.iodata_to_binary() format -> Mix.raise("unknown --format #{inspect(format)}, expected text, inspect, or json") end end defp format_report(report) do [ "Audit: ", to_string(report.managed_resources), " managed resources, ", to_string(report.drift), " drift, ", to_string(report.read_errors), " read errors, ", to_string(report.redacted_config_entries), " redacted config entries, ", to_string(report.unchanged), " unchanged", "\nResources: ", Mix.Tasks.HostKit.Output.format_counts(report.resources_by_type), "\nDrift: ", Mix.Tasks.HostKit.Output.format_counts(report.drift_by_type), format_redacted_config(report.redacted_config_paths) ] end defp format_redacted_config([]), do: [] defp format_redacted_config(entries) do [ "\nRedacted config: ", Enum.map_join(entries, "; ", fn entry -> "#{format_resource_id(HostKit.Resource.load(entry.resource_id))}: #{Enum.join(entry.paths, ", ")}" end) ] end defp format_resource_id({type, name}), do: "#{type}.#{name}" defp format_resource_id(resource_id), do: inspect(resource_id) defp put_present(opts, _key, nil), do: opts defp put_present(opts, key, value), do: Keyword.put(opts, key, value) end