Start refactoring update handler. [WIP]

pull/446/head
connor rigby 2018-01-31 12:40:14 -08:00 committed by Connor Rigby
parent 0413855d74
commit 40d1d9ecde
7 changed files with 221 additions and 334 deletions

View File

@ -5,11 +5,11 @@ 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
# :ok -> {:ok, env}
# :no_update -> {:ok, env}
# _ -> {:error, "Failed to check updates", env}
# end
end
def execute(%{package: :arduino_firmware}, _, env) do

View File

@ -21,7 +21,6 @@ defmodule Farmbot.System.Debug do
]
children = [
Plug.Adapters.Cowboy.child_spec(:http, DebugRouter, [], options),
worker(Farmbot.System.Updates.SlackUpdater, []),
]
opts = [strategy: :one_for_one]

View File

@ -1,194 +0,0 @@
defmodule Farmbot.System.Updates.SlackUpdater do
@moduledoc """
Development module for auto flashing fw updates.
"""
@token System.get_env("SLACK_TOKEN")
@target Farmbot.Project.target()
@data_path Application.get_env(:farmbot, :data_path)
use Farmbot.Logger
use GenServer
defmodule RTMSocket do
@moduledoc false
def start_link(url, cb) do
pid = spawn(__MODULE__, :socket_init, [url, cb])
{:ok, pid}
end
def socket_init("wss://" <> url, cb) do
[domain | rest] = String.split(url, "/")
domain
|> Socket.Web.connect!(secure: true, path: "/" <> Enum.join(rest, "/"))
|> socket_loop(cb)
end
def socket_init("ws://" <> url, cb) do
[domain | rest] = String.split(url, "/")
domain
|> Socket.Web.connect!(secure: false, path: "/" <> Enum.join(rest, "/"))
|> socket_loop(cb)
end
defp socket_loop(socket, cb) do
case socket |> Socket.Web.recv!() do
{:text, data} -> data |> Poison.decode!() |> handle_data(socket, cb)
{:ping, _} -> Socket.Web.send!(socket, {:pong, ""})
end
receive do
{:stop, reason} ->
term_msg =
%{
type: "message",
id: 2,
channel: "C58DCU4A3",
text: ":farmbot-genesis: #{node()} Disconnected (#{inspect(reason)})"
}
|> Poison.encode!()
Socket.Web.send!(socket, {:text, term_msg})
Socket.Web.close(socket)
data ->
Socket.Web.send!(socket, {:text, Poison.encode!(data)})
socket_loop(socket, cb)
after
500 -> socket_loop(socket, cb)
end
end
defp handle_data(%{"type" => "hello"}, socket, _) do
Logger.success(3, "Connected to slack!")
msg =
%{
type: "message",
id: 1,
channel: "C58DCU4A3",
text: ":farmbot-genesis: #{node()} Connected!"
}
|> Poison.encode!()
Socket.Web.send!(socket, {:text, msg})
end
defp handle_data(msg, _socket, cb), do: send(cb, {:socket, msg})
end
def upload_file(file, channels \\ "C58DCU4A3") do
GenServer.call(__MODULE__, {:upload_file, file, channels}, :infinity)
end
def start_link() do
GenServer.start_link(__MODULE__, @token, name: __MODULE__)
end
def init(nil) do
Logger.warn(3, "Not setting up slack (No slack token)")
:ignore
end
def init(token) do
url = "https://slack.com/api/rtm.connect"
payload = {:multipart, [{"token", token}]}
headers = [{'User-Agent', 'Farmbot HTTP Adapter'}]
with {:ok, %{status_code: 200, body: body}} <- HTTPoison.post(url, payload, headers),
{:ok, %{"ok" => true} = results} <- Poison.decode(body),
{:ok, url} <- Map.fetch(results, "url"),
{:ok, pid} <- RTMSocket.start_link(url, self()) do
Process.link(pid)
{:ok, %{rtm_socket: pid, token: token, updating: false}}
else
{:error, :invalid, _} ->
init(token)
{:ok, %{status_code: code}} ->
Logger.error(2, "Failed get RTM Auth: #{code}")
:ignore
{:error, reason} ->
Logger.error(2, "Failed to get RTM Auth: #{inspect(reason)}")
:ignore
end
end
def handle_call({:upload_file, file, channels}, _from, state) do
file = to_string(file)
payload =
%{
:file => file,
"token" => state.token,
"channels" => channels,
"title" => file,
"initial_comment" => ""
}
|> Map.to_list()
real_payload = {:multipart, payload}
url = "https://slack.com/api/files.upload"
headers = [{'User-Agent', 'Farmbot HTTP Adapter'}]
case HTTPoison.post(url, real_payload, headers, follow_redirect: true) do
{:ok, %{status_code: code, body: body}} when code > 199 and code < 300 ->
if Poison.decode!(body) |> Map.get("ok", false) do
:ok
else
Logger.error(3, "#{inspect(Poison.decode!(body, pretty: true))}")
end
other ->
Logger.error(3, "#{inspect(other)}")
end
{:reply, :ok, state}
end
def handle_info(
{:socket, %{"file" => %{"url_private_download" => dl_url, "name" => name}}},
state
) do
if Path.extname(name) == ".fw" do
if match?(<<(<<"farmbot-">>), @target, <<"-">>, _rest::binary>>, name) do
Logger.warn(3, "Downloading and applying an image from slack!")
if Farmbot.System.ConfigStorage.get_config_value(:bool, "settings", "os_auto_update") do
dl_fun = Farmbot.BotState.download_progress_fun("FBOS_OTA")
case Farmbot.HTTP.download_file(dl_url, Path.join(@data_path, name), dl_fun, "", [
{'Authorization', 'Bearer #{state.token}'}
]) do
{:ok, path} ->
Farmbot.System.Updates.apply_firmware(path, true)
{:stop, :normal, %{state | updating: true}}
{:error, reason} ->
Logger.error(3, "Failed to download update file: #{inspect(reason)}")
{:noreply, state}
end
else
Logger.warn(3, "Not downloading debug update because auto updates are disabled.")
{:noreply, state}
end
else
Logger.debug(3, "Not downloading #{name} (wrong target)")
{:noreply, state}
end
else
{:noreply, state}
end
end
def handle_info({:socket, _msg}, state), do: {:noreply, state}
def terminate(reason, state) do
if Process.alive?(state.rtm_socket) do
send(state.rtm_socket, {:stop, reason})
end
end
end

View File

@ -31,7 +31,7 @@ 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)
# Farmbot.System.Updates.check_updates(osau)
Process.send_after(self(), :checkup, @twelve_hours)
{:noreply, state}
end

View File

@ -3,10 +3,6 @@ defmodule Farmbot.System.Updates do
use Supervisor
@data_path Application.get_env(:farmbot, :data_path)
@current_version Farmbot.Project.version()
@target Farmbot.Project.target()
@current_commit Farmbot.Project.commit()
@env Farmbot.Project.env()
use Farmbot.Logger
@handler Application.get_env(:farmbot, :behaviour)[:update_handler]
@ -16,152 +12,206 @@ defmodule Farmbot.System.Updates do
@doc "Overwrite os update server field"
def override_update_server(url) do
ConfigStorage.update_config_value(:bool, "settings", "beta_opt_in", true)
ConfigStorage.update_config_value(:string, "settings", "os_update_server_overwrite", url)
end
@doc "Force check updates."
def check_updates(reboot) do
if @handler.requires_reboot? do
if reboot do
Logger.info 1, "Farmbot applied an update. Rebooting."
Farmbot.System.reboot("Update reboot required")
else
Logger.info 1, "Farmbot already applied an update. Please reboot."
:ok
end
else
token = ConfigStorage.get_config_value(:string, "authorization", "token")
if token do
case Farmbot.Jwt.decode(token) do
{:ok, %Farmbot.Jwt{os_update_server: normal_update_server, beta_os_update_server: beta_update_server}} ->
override = ConfigStorage.get_config_value(:string, "settings", "os_update_server_overwrite")
if ConfigStorage.get_config_value(:bool, "settings", "beta_opt_in") do
do_check_updates_http(override || beta_update_server, reboot)
else
do_check_updates_http(override || normal_update_server, reboot)
end
_ -> no_token()
end
else
no_token()
end
defmodule Release do
defmodule Asset do
defstruct [
:name,
:browser_download_url
]
end
defstruct [
tag_name: nil,
target_commitish: nil,
name: nil,
draft: false,
prerelease: true,
body: nil,
assets: []
]
end
defmodule CurrentStuff do
import Farmbot.Project
defstruct [
:token,
:beta_opt_in,
:os_update_server_overwrite,
:env,
:commit,
:target,
:version
]
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")
token_bin = ConfigStorage.get_config_value(:string, "authorization", "token")
token = if token_bin, do: Farmbot.Jwt.decode!(token_bin), else: nil
opts = %{
token: token,
beta_opt_in: beta_opt_in?,
os_update_server_overwrite: os_update_server_overwrite,
env: env(),
commit: commit(),
target: target(),
version: version()
} |> Map.merge(Map.new(replace))
struct(__MODULE__, opts)
end
end
defp no_token do
Logger.debug 3, "Not checking for updates. (No token)"
:ok
@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)
def check_updates(nil, current_stuff) do
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
env != :prod -> {:error, :wrong_env}
is_nil(token) -> {:error, :no_token}
is_binary(server_override) ->
Logger.debug 3, "Update server override: #{server_override}"
get_release_from_url(server_override)
beta_opt_in ->
Logger.debug 3, "Checking for beta updates."
token
|> Map.get(:beta_os_update_server)
|> get_release_from_url()
true ->
Logger.debug 3, "Checking for production updates."
token
|> Map.get(:os_update_server)
|> get_release_from_url()
end
|> case do
%Release{} = release when beta_opt_in ->
do_check_production_release = fn() ->
token
|> Map.get(:os_update_server)
|> get_release_from_url()
|> case do
%Release{} = prod_release -> check_updates(prod_release, current_stuff_mut)
err -> err
end
end
check_updates(release, current_stuff_mut) || do_check_production_release.()
%Release{} = release -> check_updates(release, current_stuff_mut)
err -> err
end
end
defp do_check_updates_http(url, reboot) do
Logger.info 3, "Checking: #{url} for updates."
with {:ok, %{body: body, status_code: 200}} <- Farmbot.HTTP.get(url),
{:ok, data} <- Poison.decode(body),
{:ok, prerelease} <- Map.fetch(data, "prerelease"),
{:ok, new_commit} <- Map.fetch(data, "target_commitish"),
{:ok, cl} <- Map.fetch(data, "body"),
{:ok, false} <- Map.fetch(data, "draft"),
{:ok, "v" <> new_version_str} <- Map.fetch(data, "tag_name"),
{:ok, new_version} <- Version.parse(new_version_str),
{:ok, current_version} <- Version.parse(@current_version),
{:ok, fw_url} <- find_fw_url(data, new_version) do
needs_update = if prerelease do
val = new_commit == @current_commit
Logger.info 1, "Checking prerelease commits: current_commit: #{@current_commit} new_commit: #{new_commit} #{val}"
!val
else
case Version.compare(current_version, new_version) do
s when s in [:gt, :eq] ->
Logger.success 2, "Farmbot is up to date."
false
:lt ->
Logger.busy 1, "New Farmbot firmware update: #{new_version}"
true
def check_updates(%Release{} = rel, %CurrentStuff{} = current_stuff) do
%{
beta_opt_in: beta_opt_in,
commit: current_commit,
version: current_version
} = current_stuff
release_version = String.trim(rel.tag_name, "v") |> Version.parse!()
is_beta_release? = "beta" in (release_version.pre || [])
version_comp = Version.compare(current_version, release_version)
release_commit = rel.target_commitish
commits_equal? = current_commit == release_commit
prerelease = rel.prerelease
cond do
# Don't bother if the release is a draft. Not sure how/if this can happen.
rel.draft ->
Logger.warn 1, "Not checking draft release."
nil
# Only check prerelease if
# current_version is less than or equal to release_version
# AND
# the commits are not equal.
prerelease and is_beta_release? and beta_opt_in and !commits_equal? ->
# beta release get marked as greater than non beta release, so we need
# to manually check the versions by removing the pre part.
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})"
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})"
nil
end
end
# if the current version is less than the release version.
!prerelease and version_comp == :lt ->
Logger.debug 3, "Current version is less than release."
try_find_dl_url_in_asset(rel.assets, release_version, current_stuff)
if should_apply_update(@env, prerelease, needs_update) do
Logger.busy 1, "Downloading FarmbotOS over the air update"
IO.puts cl
# Logger.info 1, "Downloading update. Here is the release notes"
# Logger.info 1, cl
do_download_and_apply(fw_url, new_version, reboot)
else
:no_update
end
# If the version isn't different, but the commits are different,
# This happens for beta releases.
!prerelease and version_comp == :eq and !commits_equal? ->
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)
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?})"
Logger.debug 3, "No updates available: \ntarget: #{Farmbot.Project.target()}: \nprerelease: #{prerelease} \n#{comparison_str}"
nil
end
end
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
release_version = to_string(release_version)
current_target = to_string(current_stuff.target)
expected_name = "farmbot-#{current_target}-#{release_version}.fw"
if match?(^expected_name, name) do
bdurl
else
:error ->
msg = "Unexpected release HTTP response or wrong formated `tag_name`"
Logger.error 2, msg
Logger.debug 3, "Incorrect asset name for target: #{current_target}: #{name}"
try_find_dl_url_in_asset(rest, release_version, current_stuff)
end
end
{:error, :no_fw_url} ->
Logger.error 2, "No firmware in update asssets."
def try_find_dl_url_in_asset([], release_version, current_stuff) do
Logger.warn 2, "No update in assets for #{current_stuff.target()} for #{release_version}"
nil
end
{:error, reason} ->
Logger.error 1, "Failed to fetch update data: #{inspect reason}"
{:ok, %{status_code: 400}} ->
Logger.info 2, "Had out of date token. Try that again."
def get_release_from_url(url) when is_binary(url) do
Logger.debug 3, "Checking for updates: #{url}"
case http_adapter().get(url) do
{: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()
{:ok, %{body: body, status_code: code}} ->
reason = case Poison.decode(body) do
{:ok, res} -> res
_ -> body
{:error, :token_refresh}
{: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
Logger.error 1, "OS Update HTTP error: #{code}: #{inspect reason}"
{:ok, %{status_code: _code, body: body}} ->
{:error, body}
err -> err
end
end
defp find_fw_url(%{"assets" => assets}, version) do
expected_name = "farmbot-#{@target}-#{version}.fw"
res = Enum.find_value(assets, fn(asset) ->
case asset do
%{"browser_download_url" => fw_url, "name" => ^expected_name} -> fw_url
_ -> nil
end
end)
if res do
{:ok, res}
else
{:error, :no_fw_url}
end
end
defp should_apply_update(env, prerelease?, needs_update?)
defp should_apply_update(_, _, false), do: false
defp should_apply_update(:prod, true, _) do
if ConfigStorage.get_config_value(:bool, "settings", "beta_opt_in") do
Logger.info 3, "Applying beta update for production firmware"
true
else
Logger.info 3, "Not applying prerelease update for production firmware"
false
end
end
defp should_apply_update(_env, true, _) do
Logger.info 3, "Applying prerelease firmware."
true
end
defp should_apply_update(_, _, true) do
true
end
defp do_download_and_apply(dl_url, new_version, reboot) do
dl_fun = Farmbot.BotState.download_progress_fun("FBOS_OTA")
dl_path = Path.join(@data_path, "#{new_version}.fw")
case Farmbot.HTTP.download_file(dl_url, dl_path, dl_fun, "", []) do
{:ok, path} ->
apply_firmware(path, reboot)
{:error, reason} ->
Logger.error 1, "Failed to download update file: #{inspect reason}"
{:error, reason}
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."
@ -204,7 +254,8 @@ defmodule Farmbot.System.Updates do
@doc false
def start_link do
Supervisor.start_link(__MODULE__, [], [name: __MODULE__])
:ignore
# Supervisor.start_link(__MODULE__, [], [name: __MODULE__])
end
def init([]) do

View File

@ -0,0 +1,31 @@
defmodule Farmbot.System.UpdatesTest do
use ExUnit.Case, async: false
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()
describe "CurrentStuff replacement" do
test "replaces valid things in the current stuff struct" do
r = CurrentStuff.get(env: :almost_prod)
assert r.env == :almost_prod
end
test "Allows and igrnores arbitry data" do
r = CurrentStuff.get(some_key: :some_val)
refute Map.get(r, :some_key)
end
end
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
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
end

View File

@ -2,7 +2,7 @@
unless '--exclude farmbot_api' in :init.get_plain_arguments() do
FarmbotTestSupport.preflight_checks()
end
Farmbot.Logger.Console.set_verbosity_level(0)
# Farmbot.Logger.Console.set_verbosity_level(0)
Ecto.Adapters.SQL.Sandbox.mode(Farmbot.Repo.A, :manual)
Ecto.Adapters.SQL.Sandbox.mode(Farmbot.Repo.B, :manual)