From f50ff6ab2865fe1b3c5d12e4f17fee6e75b7cd76 Mon Sep 17 00:00:00 2001 From: connor rigby Date: Thu, 1 Feb 2018 09:21:24 -0800 Subject: [PATCH] Finish refactor. --- .../celery_script/ast/node/check_updates.ex | 11 ++- lib/farmbot/system/updates/update_timer.ex | 6 +- lib/farmbot/system/updates/updates.ex | 97 +++++++++++++------ nerves/target/update_handler.ex | 29 +++++- test/farmbot/system/updates/updates_test.exs | 76 +++++++++++++++ 5 files changed, 178 insertions(+), 41 deletions(-) diff --git a/lib/farmbot/celery_script/ast/node/check_updates.ex b/lib/farmbot/celery_script/ast/node/check_updates.ex index 997dad4c..00bfb15a 100644 --- a/lib/farmbot/celery_script/ast/node/check_updates.ex +++ b/lib/farmbot/celery_script/ast/node/check_updates.ex @@ -5,11 +5,12 @@ defmodule Farmbot.CeleryScript.AST.Node.CheckUpdates do def execute(%{package: :farmbot_os}, _, env) do env = mutate_env(env) - # case Farmbot.System.Updates.check_updates(true) do - # :ok -> {:ok, env} - # :no_update -> {:ok, env} - # _ -> {:error, "Failed to check updates", env} - # end + case Farmbot.System.Updates.check_updates(true) do + {:error, reason} -> {:error, reason, env} + nil -> {:ok, env} + url -> + Farmbot.System.Updates.download_and_apply_update(url) + end end def execute(%{package: :arduino_firmware}, _, env) do diff --git a/lib/farmbot/system/updates/update_timer.ex b/lib/farmbot/system/updates/update_timer.ex index e52296ce..e5c75740 100644 --- a/lib/farmbot/system/updates/update_timer.ex +++ b/lib/farmbot/system/updates/update_timer.ex @@ -31,7 +31,11 @@ defmodule Farmbot.System.UpdateTimer do def handle_info(:checkup, state) do osau = Farmbot.System.ConfigStorage.get_config_value(:bool, "settings", "os_auto_update") - # Farmbot.System.Updates.check_updates(osau) + case Farmbot.System.Updates.check_updates(true) do + {:error, err} -> Logger.error 1, "Error checking for updates: #{inspect err}" + nil -> Logger.debug 3, "No updates available as of #{inspect Timex.now()}" + url -> if osau, do: Farmbot.System.Updates.download_and_apply_update(url) + end Process.send_after(self(), :checkup, @twelve_hours) {:noreply, state} end diff --git a/lib/farmbot/system/updates/updates.ex b/lib/farmbot/system/updates/updates.ex index 173abd7e..b1099b4d 100644 --- a/lib/farmbot/system/updates/updates.ex +++ b/lib/farmbot/system/updates/updates.ex @@ -1,14 +1,18 @@ defmodule Farmbot.System.Updates do @moduledoc "Handles over the air updates." + use Supervisor + use Farmbot.Logger + alias Farmbot.System.ConfigStorage @data_path Application.get_env(:farmbot, :data_path) - use Farmbot.Logger + @target Farmbot.Project.target() + @current_version Farmbot.Project.version() + @env Farmbot.Project.env() - @handler Application.get_env(:farmbot, :behaviour)[:update_handler] - @handler || Mix.raise("Please configure update_handler") + @update_handler Application.get_env(:farmbot, :behaviour)[:update_handler] + @update_handler || Mix.raise("Please configure update_handler") - alias Farmbot.System.ConfigStorage @doc "Overwrite os update server field" def override_update_server(url) do @@ -16,11 +20,10 @@ defmodule Farmbot.System.Updates do end defmodule Release do + @moduledoc false defmodule Asset do - defstruct [ - :name, - :browser_download_url - ] + @moduledoc false + defstruct [:name, :browser_download_url] end defstruct [ @@ -34,8 +37,8 @@ defmodule Farmbot.System.Updates do ] end - defmodule CurrentStuff do + @moduledoc false import Farmbot.Project defstruct [ :token, @@ -47,6 +50,7 @@ defmodule Farmbot.System.Updates do :version ] + @doc "Get the current stuff. Fields can be replaced for testing." def get(replace \\ %{}) do os_update_server_overwrite = ConfigStorage.get_config_value(:string, "settings", "os_update_server_overwrite") beta_opt_in? = is_binary(os_update_server_overwrite) || ConfigStorage.get_config_value(:bool, "settings", "beta_opt_in") @@ -65,31 +69,55 @@ defmodule Farmbot.System.Updates do end end + @doc "Downloads and applies an update file." + def download_and_apply_update(dl_url) do + if @update_handler.requires_reboot?() do + Logger.warn 1, "Can't apply update. An update is already staged. Please reboot and try again." + {:error, :reboot_required} + else + fe_constant = "FBOS_OTA" + dl_fun = Farmbot.BotState.download_progress_fun(fe_constant) + # TODO(Connor): I'd like this to have a version number.. + dl_path = Path.join(@data_path, "ota.fw") + case http_adapter().download_file(dl_url, dl_path, dl_fun, "", []) do + {:ok, path} -> apply_firmware(path, true) + {:error, reason} -> {:error, reason} + end + end + end + @doc """ Force check for updates. Does _NOT_ download or apply update. """ - # @spec check_updates(Release.t | nil) :: def check_updates(release \\ nil, current_stuff \\ nil) + # All the HTTP Requests happen here. def check_updates(nil, current_stuff) do + # Get current values. current_stuff_mut = %{ token: token, beta_opt_in: beta_opt_in, os_update_server_overwrite: server_override, env: env, } = current_stuff || CurrentStuff.get() + cond do + # Don't allow non producion envs to check production env updates. env != :prod -> {:error, :wrong_env} + # Don't check if the token is nil. is_nil(token) -> {:error, :no_token} + # Allows the server to be overwrote. is_binary(server_override) -> Logger.debug 3, "Update server override: #{server_override}" get_release_from_url(server_override) + # Beta updates should check twice. beta_opt_in -> Logger.debug 3, "Checking for beta updates." token |> Map.get(:beta_os_update_server) |> get_release_from_url() + # Conditions exhausted. We _must_ be on a production release. true -> Logger.debug 3, "Checking for production updates." token @@ -97,6 +125,9 @@ defmodule Farmbot.System.Updates do |> get_release_from_url() end |> case do + # Beta needs to make two requests: + # check for a later beta update, if no later beta update, + # Check for a later production release. %Release{} = release when beta_opt_in -> do_check_production_release = fn() -> token @@ -108,11 +139,13 @@ defmodule Farmbot.System.Updates do end end check_updates(release, current_stuff_mut) || do_check_production_release.() + # Production release; no beta. Check the release for an asset. %Release{} = release -> check_updates(release, current_stuff_mut) err -> err end end + # Check against the release struct. Not HTTP requests from here out. def check_updates(%Release{} = rel, %CurrentStuff{} = current_stuff) do %{ beta_opt_in: beta_opt_in, @@ -144,6 +177,7 @@ defmodule Farmbot.System.Updates do case Version.compare(current_version, %{release_version | pre: nil}) do c when c in [:lt, :eq] -> Logger.debug 3, "Current version (#{current_version}) is less than or equal to beta release (#{release_version})" + # TODO Check times here i guess? try_find_dl_url_in_asset(rel.assets, release_version, current_stuff) :gt -> Logger.debug 3, "Current version (#{current_version}) is greater than latest beta release (#{release_version})" @@ -161,6 +195,7 @@ defmodule Farmbot.System.Updates do Logger.debug 3, "Current version is equal to release, but commits are not equal." try_find_dl_url_in_asset(rel.assets, release_version, current_stuff) + # Conditions exhausted. No updates available. true -> comparison_str = "version check: current version: #{current_version} #{version_comp} latest release version: #{release_version} \n"<> "commit check: current commit: #{current_commit} latest release commit: #{release_commit}: (equal: #{commits_equal?})" @@ -170,6 +205,7 @@ defmodule Farmbot.System.Updates do end end + @doc "Finds a asset url if it exists, nil if not." def try_find_dl_url_in_asset(assets, version, current_stuff) def try_find_dl_url_in_asset([%Release.Asset{name: name, browser_download_url: bdurl} | rest], release_version, current_stuff) do @@ -189,36 +225,36 @@ defmodule Farmbot.System.Updates do nil end + @doc "HTTP request to fetch a Release." def get_release_from_url(url) when is_binary(url) do Logger.debug 3, "Checking for updates: #{url}" case http_adapter().get(url) do + # This can happen on beta updates, if on an old token + # and a new beta release was published. {:ok, %{status_code: 404}} -> Logger.warn 1, "Got a 404 checking for updates: #{url}. Fetching a new token. Try that again" Farmbot.Bootstrap.AuthTask.force_refresh() {:error, :token_refresh} + + # Decode the HTTP body as a release. {:ok, %{status_code: 200, body: body}} -> pattern = struct(Release, [assets: [struct(Release.Asset)]]) case Poison.decode(body, as: pattern) do {:ok, %Release{} = rel} -> rel _err -> {:error, :bad_release_body} end - {:ok, %{status_code: _code, body: body}} -> - {:error, body} + + # Error situations + {:ok, %{status_code: _code, body: body}} -> {:error, body} err -> err end end - def http_adapter do - # adapter = Application.get_env(:farmbot, :behaviour)[:http_adapter] - # adapter || raise "No http adapter!" - Farmbot.HTTP - end - @doc "Apply an OS (fwup) firmware." def apply_firmware(file_path, reboot) do Logger.busy 1, "Applying #{@target} OS update" before_update() - case @handler.apply_firmware(file_path) do + case @update_handler.apply_firmware(file_path) do :ok -> Logger.success 1, "OS Firmware updated!" if reboot do @@ -231,16 +267,14 @@ defmodule Farmbot.System.Updates do end end - defp before_update do - File.write!(update_file(), @current_version) - end + # Private defp maybe_post_update do case File.read(update_file()) do {:ok, @current_version} -> :ok {:ok, old_version} -> Logger.info 1, "Updating from #{old_version} to #{@current_version}" - @handler.post_update() + @update_handler.post_update() {:error, :enoent} -> Logger.info 1, "Updating to #{@current_version}" {:error, err} -> raise err @@ -248,18 +282,20 @@ defmodule Farmbot.System.Updates do before_update() end - defp update_file do - Path.join(@data_path, "update") - end + defp before_update, do: File.write!(update_file(), @current_version) + + defp update_file, do: Path.join(@data_path, "update") + + defp http_adapter, do: Farmbot.HTTP @doc false def start_link do - :ignore - # Supervisor.start_link(__MODULE__, [], [name: __MODULE__]) + Supervisor.start_link(__MODULE__, [], [name: __MODULE__]) end + @doc false def init([]) do - case @handler.setup(@env) do + case @update_handler.setup(@env) do :ok -> maybe_post_update() children = [ @@ -267,8 +303,7 @@ defmodule Farmbot.System.Updates do ] opts = [strategy: :one_for_one] supervise(children, opts) - {:error, reason} -> - {:stop, reason} + {:error, reason} -> {:stop, reason} end end end diff --git a/nerves/target/update_handler.ex b/nerves/target/update_handler.ex index b38dc9fe..c477a0c8 100644 --- a/nerves/target/update_handler.ex +++ b/nerves/target/update_handler.ex @@ -6,14 +6,35 @@ defmodule Farmbot.Target.UpdateHandler do # Update Handler callbacks - def apply_firmware(file_path) do - Nerves.Firmware.upgrade_and_finalize(file_path) + def apply_firmware(fw_file_path) do + meta_bin + |> String.trim() + |> String.split("\n") + |> Enum.map(&String.split(&1, "=")) + |> Map.new(fn([key, val]) -> + {key, val |> String.trim_leading("\"") |> String.trim_trailing("\"")} + end) + |> log_meta + Nerves.Firmware.upgrade_and_finalize(fw_file_path) end - def before_update do - :ok + defp log_meta(meta_map) do + target = "target: #{meta_map["meta-platform"]}" + product = "product: #{meta_map["meta-product"]}" + version = "version: #{meta_map["meta-version"]}" + create_time = "created: #{meta_map["meta-creation-date"]}" + msg = """ + Applying Firmware: + #{create_time} + #{target} + #{product} + #{version} + """ + Logger.debug 1, msg end + def before_update, do: :ok + def post_update do alias Farmbot.Firmware.UartHandler.Update hw = Farmbot.System.ConfigStorage.get_config_value(:string, "settings", "firmware_hardware") diff --git a/test/farmbot/system/updates/updates_test.exs b/test/farmbot/system/updates/updates_test.exs index c69b7bb2..9c18c453 100644 --- a/test/farmbot/system/updates/updates_test.exs +++ b/test/farmbot/system/updates/updates_test.exs @@ -3,6 +3,12 @@ defmodule Farmbot.System.UpdatesTest do alias Farmbot.System.Updates alias Farmbot.System.Updates.{Release, CurrentStuff} @old_version Farmbot.Project.version |> Version.parse! |> Map.update(:major, nil, &Kernel.-(&1, 1)) |> to_string() + @new_version Farmbot.Project.version |> Version.parse! |> Map.update(:major, nil, &Kernel.+(&1, 1)) |> to_string() + @commit Farmbot.Project.commit + @version Farmbot.Project.version() + @os_update_server "http://fake_os_update_server.com" + @beta_os_update_server "http://beta_os_update_server.com" + @fake_asset_url "http://fake_release_asset.com" describe "CurrentStuff replacement" do test "replaces valid things in the current stuff struct" do @@ -16,16 +22,86 @@ defmodule Farmbot.System.UpdatesTest do end end + @tag :external test "checks for updates for prod rpi3 no beta combo" do # updating from old to new version should work current = CurrentStuff.get(os_update_server_overwrite: nil, beta_opt_in: false, env: :prod, target: :rpi3, version: @old_version) assert Updates.check_updates(nil, current) end + @tag :external test "checks for updates for prod rpi3 with beta combo" do # old version to later beta current = CurrentStuff.get(os_update_server_overwrite: nil, env: :prod, target: :rpi3, version: @old_version, beta_opt_in: true) assert Updates.check_updates(nil, current) end + test "no token gives error" do + current = CurrentStuff.get(token: nil) + assert match?({:error, _}, Updates.check_updates(nil, current)) + end + + test "dev env should not update to prod" do + current = CurrentStuff.get(env: :dev) + assert match?({:error, _}, Updates.check_updates(nil, current)) + end + + + test "updates of the same version should not return a url" do + current = CurrentStuff.get(current_stub()) + release = release_stub() + refute Updates.check_updates(release, current) + end + + test "Draft releases" do + current = CurrentStuff.get(current_stub()) + release = %{release_stub() | draft: true} + refute Updates.check_updates(release, current) + end + + test "Opting into beta won't downgrade from a prod release to a previous beta" do + current = CurrentStuff.get(%{current_stub() | beta_opt_in: true, version: @new_version}) + release = release_stub() + refute Updates.check_updates(release, current) + end + + test "Normal upgrade path: current is less than latest" do + current = CurrentStuff.get(%{current_stub() | version: @old_version}) + release = release_stub() + assert Updates.check_updates(release, current) == @fake_asset_url + end + + test "versions equal, but commits not equal" do + current = CurrentStuff.get(%{current_stub() | commit: String.reverse(@commit)}) + release = release_stub() + assert Updates.check_updates(release, current) == @fake_asset_url + end + + defp current_stub do + %{ + token: %Farmbot.Jwt{ + os_update_server: @os_update_server, + beta_os_update_server: @beta_os_update_server, + }, + beta_opt_in: false, + os_update_server_overwrite: nil, + env: :prod, + commit: @commit, + target: :rpi3, + version: @version + } + end + + defp release_stub do + %Release{ + tag_name: "v#{@version}", + target_commitish: @commit, + name: "Stub Release", + draft: false, + prerelease: false, + body: "This is a stub!", + assets: [%Release.Asset{name: "farmbot-rpi3-#{@version}.fw", browser_download_url: @fake_asset_url}] + } + end + end