297 lines
10 KiB
Elixir
297 lines
10 KiB
Elixir
defimpl FarmbotCore.AssetWorker, for: FarmbotCore.Asset.FarmwareInstallation do
|
|
use GenServer
|
|
require FarmbotCore.Logger
|
|
|
|
alias FarmbotCore.{Asset.Repo, BotState, DepTracker, JSON}
|
|
alias FarmbotCore.Asset.FarmwareInstallation, as: FWI
|
|
alias FarmbotCore.Asset.FarmwareInstallation.Manifest
|
|
|
|
config = Application.get_env(:farmbot_core, __MODULE__)
|
|
@install_dir config[:install_dir] || Mix.raise("Missing Install Dir")
|
|
@error_retry_time_ms config[:error_retry_time_ms] || 25_000
|
|
@back_off_time_ms config[:back_off_time_ms] || 5_000
|
|
@manifest_name "manifest.json"
|
|
|
|
def preload(%FWI{}), do: []
|
|
|
|
def tracks_changes?(%FWI{}), do: false
|
|
|
|
def start_link(fwi, _args) do
|
|
GenServer.start_link(__MODULE__, fwi)
|
|
end
|
|
|
|
def init(fwi) do
|
|
:ok = DepTracker.register_asset(fwi, :init)
|
|
send self(), :timeout
|
|
{:ok, %{fwi: fwi, backoff: 0}}
|
|
end
|
|
|
|
def handle_cast(:update, state) do
|
|
FarmbotCore.Logger.debug(3, "Will attempt Farmware update")
|
|
{:noreply, state, 0}
|
|
end
|
|
|
|
def handle_info(:timeout, %{fwi: %{manifest: nil} = fwi} = state) do
|
|
FarmbotCore.Logger.busy(3, "Installing Farmware... ([source URL](#{fwi.url}))")
|
|
|
|
with {:ok, %{} = manifest} <- get_manifest_json(fwi),
|
|
%{valid?: true} = changeset <- FWI.changeset(fwi, %{manifest: manifest}),
|
|
{:ok, %FWI{} = updated} <- Repo.update(changeset),
|
|
{:ok, zip_binary} <- get_zip(updated),
|
|
:ok <- install_zip(updated, zip_binary),
|
|
:ok <- install_farmware_tools(updated),
|
|
:ok <- write_manifest(updated) do
|
|
:ok = DepTracker.register_asset(fwi, :complete)
|
|
FarmbotCore.Logger.success(1, "Installed Farmware: #{updated.manifest.package}")
|
|
# TODO(Connor) -> No reason to keep this process alive?
|
|
BotState.report_farmware_installed(updated.manifest.package, Manifest.view(updated.manifest))
|
|
{:noreply, %{state | backoff: 0}}
|
|
else
|
|
error ->
|
|
backoff = state.backoff + @back_off_time_ms
|
|
timeout = @error_retry_time_ms + backoff
|
|
Process.send_after(self(), :timeout, timeout)
|
|
error_log(fwi, "Failed to download Farmware manifest. Trying again in #{timeout}ms #{inspect(error)}")
|
|
{:noreply, %{state | backoff: backoff}}
|
|
end
|
|
end
|
|
|
|
# Updating a Farmware is a contentious issue.
|
|
# Examples:
|
|
# * update farmbot os, a previously installed farmware may be incompatible
|
|
# * update farmbot os, a previously installed farmware may be using backwards
|
|
# incompatible APIS in farmware tools (because farmware tools wasn't updated)
|
|
# * update farmware, it uses a more recent version of farmware tools that has a
|
|
# yet to be implemented APIs (due to out of date farmbot os)
|
|
# * update farmware tools, it uses an API that doesn't exist yet (due to
|
|
# not updating farmbot os)
|
|
def handle_info(:timeout, %{fwi: %FWI{} = fwi} = state) do
|
|
with {:ok, %{} = i_manifest} <- load_manifest_json(fwi),
|
|
%{valid?: true} = d_changeset <- FWI.changeset(fwi, %{manifest: i_manifest}),
|
|
%FWI{} = dirty <- Ecto.Changeset.apply_changes(d_changeset),
|
|
{:ok, n_manifest} <- get_manifest_json(fwi),
|
|
%{valid?: true} = n_changeset <- FWI.changeset(fwi, %{manifest: n_manifest}),
|
|
{:ok, %FWI{} = updated} <- Repo.update(n_changeset) do
|
|
maybe_update(%{state | fwi: dirty}, updated)
|
|
else
|
|
# Farmware wasn't found. Reinstall
|
|
{:error, :enoent} ->
|
|
error_log(fwi, "farmware not installed. Maybe uninstalled out of band?")
|
|
updated =
|
|
FWI.changeset(fwi, %{manifest: nil, updated_at: fwi.updated_at})
|
|
|> Repo.update!()
|
|
send self(), :timeout
|
|
{:noreply, %{state | fwi: updated}}
|
|
|
|
error ->
|
|
:ok = DepTracker.register_asset(fwi, :complete)
|
|
BotState.report_farmware_installed(fwi.manifest.package, Manifest.view(fwi.manifest))
|
|
backoff = state.backoff + @back_off_time_ms
|
|
timeout = @error_retry_time_ms + backoff
|
|
Process.send_after(self(), :timeout, timeout)
|
|
error_log(fwi, "failed to check for updates. Trying again in #{timeout}ms #{inspect(error)}")
|
|
{:noreply, %{state | backoff: backoff}}
|
|
end
|
|
end
|
|
|
|
def maybe_update(%{fwi: %FWI{} = installed_fwi} = state, %FWI{} = updated) do
|
|
case Version.compare(installed_fwi.manifest.package_version, updated.manifest.package_version) do
|
|
# Installed is newer than remote.
|
|
:gt ->
|
|
success_log(updated, "up to date.")
|
|
:ok = DepTracker.register_asset(updated, :complete)
|
|
BotState.report_farmware_installed(updated.manifest.package, Manifest.view(updated.manifest))
|
|
|
|
{:noreply, %{state | fwi: updated}}
|
|
|
|
# No difference between installed and remote.
|
|
:eq ->
|
|
success_log(updated, "up to date.")
|
|
:ok = DepTracker.register_asset(updated, :complete)
|
|
BotState.report_farmware_installed(updated.manifest.package, Manifest.view(updated.manifest))
|
|
|
|
{:noreply, %{state | fwi: updated}}
|
|
|
|
# Installed version is older than remote
|
|
:lt ->
|
|
success_log(updated, "update available.")
|
|
|
|
with {:ok, zip_binary} <- get_zip(updated),
|
|
:ok <- install_zip(updated, zip_binary),
|
|
:ok <- install_farmware_tools(updated),
|
|
:ok <- write_manifest(updated) do
|
|
:ok = DepTracker.register_asset(updated, :complete)
|
|
BotState.report_farmware_installed(updated.manifest.package, Manifest.view(updated.manifest))
|
|
{:noreply, %{state | fwi: updated, backoff: 0}}
|
|
|
|
else
|
|
er ->
|
|
backoff = state.backoff + @back_off_time_ms
|
|
timeout = @error_retry_time_ms + backoff
|
|
Process.send_after(self(), :timeout, timeout)
|
|
error_log(updated, "update failed. Trying again in #{timeout}ms #{inspect(er)}")
|
|
{:noreply, %{state | fwi: updated, backoff: backoff}}
|
|
end
|
|
end
|
|
end
|
|
|
|
def get_manifest_json(%FWI{url: "file://" <> path}) do
|
|
FarmbotCore.Logger.debug(1, "Using local directory for Farmware manifest")
|
|
|
|
case File.read(Path.join(Path.expand(path), @manifest_name)) do
|
|
{:ok, data} -> JSON.decode(data)
|
|
err -> err
|
|
end
|
|
end
|
|
|
|
def get_manifest_json(%FWI{url: url}) do
|
|
with {:ok, {{_, 200, _}, _headers, data}} <- get(url) do
|
|
JSON.decode(data)
|
|
end
|
|
end
|
|
|
|
def load_manifest_json(%FWI{manifest: %{}} = fwi) do
|
|
with {:ok, data} <- File.read(Path.join(install_dir(fwi), @manifest_name)) do
|
|
JSON.decode(data)
|
|
end
|
|
end
|
|
|
|
def get_zip(%FWI{manifest: %{zip: url}}), do: get_zip(url)
|
|
|
|
def get_zip("file://" <> path) do
|
|
FarmbotCore.Logger.debug(1, "Using local directory for Farmware zip")
|
|
|
|
with {:ok, files} <- File.ls(path),
|
|
file_list <-
|
|
Enum.map(files, fn filename ->
|
|
{to_charlist(filename), File.read!(Path.join(path, filename))}
|
|
end),
|
|
{:ok, {_path, zip_binary}} <- :zip.create(to_charlist(path), file_list, [:memory]) do
|
|
{:ok, zip_binary}
|
|
end
|
|
end
|
|
|
|
def get_zip(url) when is_binary(url) do
|
|
with {:ok, {{_, 200, _}, _headers, zip_binary}} <- get(url),
|
|
{:ok, zip} <- :zip.zip_open(zip_binary, [:memory]),
|
|
:ok <- :zip.zip_close(zip) do
|
|
{:ok, zip_binary}
|
|
end
|
|
end
|
|
|
|
def install_zip(%FWI{} = fwi, binary) when is_binary(binary) do
|
|
install_zip(install_dir(fwi), binary)
|
|
end
|
|
|
|
def install_zip(dir, binary) do
|
|
with {:ok, _} <- :zip.extract(binary, [{:cwd, dir}]) do
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp write_manifest(%FWI{manifest: manifest} = fwi) do
|
|
json = Manifest.view(manifest) |> JSON.encode!()
|
|
|
|
fwi
|
|
|> install_dir()
|
|
|> Path.join(@manifest_name)
|
|
|> File.write(json)
|
|
end
|
|
|
|
def install_farmware_tools(%FWI{manifest: %{farmware_tools_version_requirement: _version}} = fwi) do
|
|
install_dir = install_dir(fwi)
|
|
File.mkdir_p(Path.join(install_dir, "farmware_tools"))
|
|
|
|
release_url = "https://api.github.com/repos/FarmBot-Labs/farmware-tools/releases/latest"
|
|
# if version == "latest" do
|
|
# "https://api.github.com/repos/FarmBot-Labs/farmware-tools/releases/latest"
|
|
# else
|
|
# "https://api.github.com/repos/FarmBot-Labs/farmware-tools/releases/tags/#{version}"
|
|
# end
|
|
|
|
with {:ok, {_commit, zip_url}} <- get_tools_zip_url(release_url),
|
|
{:ok, zip_binary} <- get_zip(zip_url),
|
|
:ok <- install_zip(install_dir, zip_binary) do
|
|
fun = fn {:zip_file, dir, _info, _, _, _} ->
|
|
[_ | rest] = Path.split(to_string(dir))
|
|
List.first(rest) == "farmware_tools"
|
|
end
|
|
|
|
case :zip.extract(zip_binary, [:memory, file_filter: fun]) do
|
|
{:ok, list} when is_list(list) ->
|
|
Enum.each(list, fn {filename, data} ->
|
|
out_file =
|
|
Path.join([install_dir, "farmware_tools", Path.basename(to_string(filename))])
|
|
|
|
File.write!(out_file, data)
|
|
end)
|
|
|
|
{:error, reason} ->
|
|
raise(reason)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
end
|
|
|
|
def get_tools_zip_url(release_url) do
|
|
case get(release_url) do
|
|
{:ok, {{_, 200, _}, _, msg}} ->
|
|
release = JSON.decode!(msg)
|
|
release_commit = release["target_commitish"]
|
|
{:ok, {release_commit, release["zipball_url"]}}
|
|
|
|
{:ok, {{_, _, _}, _, msg}} ->
|
|
case JSON.decode(msg) do
|
|
{:ok, %{"message" => message}} -> {:error, message}
|
|
_ -> {:error, msg}
|
|
end
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
defp get(url) do
|
|
:httpc.request(:get, {to_charlist(url), httpc_headers()}, [], httpc_options())
|
|
end
|
|
|
|
defp httpc_options, do: [body_format: :binary]
|
|
|
|
case System.get_env("GITHUB_TOKEN") do
|
|
nil ->
|
|
defp httpc_headers, do: [{'user-agent', 'farmbot-farmware-installer'}]
|
|
|
|
token when is_binary(token) ->
|
|
@token token
|
|
require Logger; Logger.info "using github token: #{@token}"
|
|
defp httpc_headers, do: [{'user-agent', 'farmbot-farmware-installer'}, {'Authorization', 'token #{@token}'}]
|
|
end
|
|
|
|
def install_dir(%FWI{} = fwi) do
|
|
install_dir(fwi.manifest)
|
|
end
|
|
|
|
def install_dir(%Manifest{package: package}) do
|
|
dir = Path.join(@install_dir, package)
|
|
File.mkdir_p!(dir)
|
|
dir
|
|
end
|
|
|
|
defp error_log(%FWI{manifest: %{package: package}}, msg) do
|
|
FarmbotCore.Logger.error(3, "Farmware #{package} " <> msg)
|
|
end
|
|
|
|
defp error_log(%FWI{}, msg) do
|
|
FarmbotCore.Logger.error(3, "Farmware " <> msg)
|
|
end
|
|
|
|
defp success_log(%FWI{manifest: %{package: package}}, msg) do
|
|
FarmbotCore.Logger.success(3, "Farmware #{package} " <> msg)
|
|
end
|
|
|
|
defp success_log(%FWI{}, msg) do
|
|
FarmbotCore.Logger.success(3, "Farmware " <> msg)
|
|
end
|
|
end
|