From 65dec0d2062be2edf376dda4850be6033496f9ed Mon Sep 17 00:00:00 2001 From: Eric Oestrich Date: Tue, 27 Jun 2023 11:34:44 -0400 Subject: [PATCH] Merge in NervesHubLinkCommon (#120) Migrate code back into the main library now that the HTTP version is deprecated and doesn't work in NervesHub 2.0 --- .credo.exs | 18 + CHANGELOG.md | 2 +- lib/nerves_hub_link.ex | 4 +- lib/nerves_hub_link/application.ex | 4 +- lib/nerves_hub_link/client.ex | 2 +- lib/nerves_hub_link/downloader.ex | 422 ++++++++++++++++++ .../downloader/retry_config.ex | 69 +++ .../downloader/timeout_calculation.ex | 16 + lib/nerves_hub_link/fwup_config.ex | 86 ++++ .../message/firmware_metadata.ex | 48 ++ lib/nerves_hub_link/message/update_info.ex | 32 ++ lib/nerves_hub_link/socket.ex | 10 +- lib/nerves_hub_link/update_manager.ex | 259 +++++++++++ mix.exs | 7 +- mix.lock | 12 +- test/nerves_hub_link/client/default_test.exs | 2 +- .../downloader/timeout_calculation_test.exs | 15 + test/nerves_hub_link/downloader_test.exs | 182 ++++++++ test/nerves_hub_link/update_manager_test.exs | 124 +++++ test/support/fixtures.ex | 155 +++++++ test/support/fwup_stream_plug.ex | 20 + test/support/http_error_plug.ex | 16 + test/support/idle_timeout_plug.ex | 30 ++ test/support/range_request_plug.ex | 54 +++ test/support/redirect_plug.ex | 25 ++ test/support/uuid.ex | 62 +++ test/support/x_retry_number_plug.ex | 38 ++ 27 files changed, 1699 insertions(+), 15 deletions(-) create mode 100644 .credo.exs create mode 100644 lib/nerves_hub_link/downloader.ex create mode 100644 lib/nerves_hub_link/downloader/retry_config.ex create mode 100644 lib/nerves_hub_link/downloader/timeout_calculation.ex create mode 100644 lib/nerves_hub_link/fwup_config.ex create mode 100644 lib/nerves_hub_link/message/firmware_metadata.ex create mode 100644 lib/nerves_hub_link/message/update_info.ex create mode 100644 lib/nerves_hub_link/update_manager.ex create mode 100644 test/nerves_hub_link/downloader/timeout_calculation_test.exs create mode 100644 test/nerves_hub_link/downloader_test.exs create mode 100644 test/nerves_hub_link/update_manager_test.exs create mode 100644 test/support/fixtures.ex create mode 100644 test/support/fwup_stream_plug.ex create mode 100644 test/support/http_error_plug.ex create mode 100644 test/support/idle_timeout_plug.ex create mode 100644 test/support/range_request_plug.ex create mode 100644 test/support/redirect_plug.ex create mode 100644 test/support/uuid.ex create mode 100644 test/support/x_retry_number_plug.ex diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..42ac8d6 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,18 @@ +# .credo.exs +%{ + configs: [ + %{ + name: "default", + strict: true, + checks: [ + {Credo.Check.Design.TagTODO, false}, + {Credo.Check.Refactor.MapInto, false}, + {Credo.Check.Warning.LazyLogging, false}, + {Credo.Check.Readability.LargeNumbers, only_greater_than: 86400}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, parens: true}, + {Credo.Check.Readability.Specs, tags: []}, + {Credo.Check.Readability.StrictModuleLayout, tags: []} + ] + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 965cbe2..8bb9464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,7 +108,7 @@ This release only bumps the version number. It doesn't have any code changes. * This enforces the update data structure exchanged between device and server and is mostly internal. However, if you implement your own `NervesHubLink.Client` behavior, then you will need to your `NervesHubLink.Client.update_available/1` to - accept a `%NervesHubLinkCommon.Message.UpdateInfo{}` struct as the parameter + accept a `%NervesHubLink.Message.UpdateInfo{}` struct as the parameter instead of a map with string keys which was used until this point. * Enhancements diff --git a/lib/nerves_hub_link.ex b/lib/nerves_hub_link.ex index 8df3244..6286047 100644 --- a/lib/nerves_hub_link.ex +++ b/lib/nerves_hub_link.ex @@ -29,8 +29,8 @@ defmodule NervesHubLink do @doc """ Current status of the update manager """ - @spec status :: NervesHubLinkCommon.UpdateManager.State.status() - defdelegate status(), to: NervesHubLinkCommon.UpdateManager + @spec status :: NervesHubLink.UpdateManager.State.status() + defdelegate status(), to: NervesHubLink.UpdateManager @doc """ Restart the socket and device channel diff --git a/lib/nerves_hub_link/application.ex b/lib/nerves_hub_link/application.ex index 31afe96..9e7a78f 100644 --- a/lib/nerves_hub_link/application.ex +++ b/lib/nerves_hub_link/application.ex @@ -5,8 +5,8 @@ defmodule NervesHubLink.Application do alias NervesHubLink.Configurator alias NervesHubLink.Connection alias NervesHubLink.Socket - alias NervesHubLinkCommon.FwupConfig - alias NervesHubLinkCommon.UpdateManager + alias NervesHubLink.FwupConfig + alias NervesHubLink.UpdateManager def start(_type, _args) do config = Configurator.build() diff --git a/lib/nerves_hub_link/client.ex b/lib/nerves_hub_link/client.ex index 442af3c..956445f 100644 --- a/lib/nerves_hub_link/client.ex +++ b/lib/nerves_hub_link/client.ex @@ -39,7 +39,7 @@ defmodule NervesHubLink.Client do require Logger @typedoc "Update that comes over a socket." - @type update_data :: NervesHubLinkCommon.Message.UpdateInfo.t() + @type update_data :: NervesHubLink.Message.UpdateInfo.t() @typedoc "Supported responses from `update_available/1`" @type update_response :: :apply | :ignore | {:reschedule, pos_integer()} diff --git a/lib/nerves_hub_link/downloader.ex b/lib/nerves_hub_link/downloader.ex new file mode 100644 index 0000000..6d0fda0 --- /dev/null +++ b/lib/nerves_hub_link/downloader.ex @@ -0,0 +1,422 @@ +defmodule NervesHubLink.Downloader do + @moduledoc """ + Handles downloading files via HTTP. + internally caches several interesting properties about + the download such as: + + * the URI of the request + * the total content amounts of bytes of the file being downloaded + * the total amount of bytes downloaded at any given time + + Using this information, it can restart a download using the + [`Range` HTTP header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range). + + This process's **only** focus is obtaining data reliably. It doesn't have any + side effects on the system. + """ + + use GenServer + + alias NervesHubLink.{Downloader, Downloader.RetryConfig, Downloader.TimeoutCalculation} + require Logger + + defstruct uri: nil, + conn: nil, + request_ref: nil, + status: nil, + response_headers: [], + content_length: 0, + downloaded_length: 0, + retry_number: 0, + handler_fun: nil, + retry_args: nil, + max_timeout: nil, + retry_timeout: nil, + worst_case_timeout: nil, + worst_case_timeout_remaining_ms: nil + + @type handler_event :: {:data, binary()} | {:error, any()} | :complete + @type event_handler_fun :: (handler_event -> any()) + @type retry_args :: RetryConfig.t() + + # alias for readability + @typep timer() :: reference() + + @type t :: %Downloader{ + uri: nil | URI.t(), + conn: nil | Mint.HTTP.t(), + request_ref: nil | reference(), + status: nil | Mint.Types.status(), + response_headers: Mint.Types.headers(), + content_length: non_neg_integer(), + downloaded_length: non_neg_integer(), + retry_number: non_neg_integer(), + handler_fun: event_handler_fun, + retry_args: retry_args(), + max_timeout: timer(), + retry_timeout: nil | timer(), + worst_case_timeout: nil | timer(), + worst_case_timeout_remaining_ms: nil | non_neg_integer() + } + + @type initialized_download :: %Downloader{ + uri: URI.t(), + conn: Mint.HTTP.t(), + request_ref: reference(), + status: nil | Mint.Types.status(), + response_headers: Mint.Types.headers(), + content_length: non_neg_integer(), + downloaded_length: non_neg_integer(), + retry_number: non_neg_integer(), + handler_fun: event_handler_fun + } + + # todo, this should be `t`, but with retry_timeout + @type resume_rescheduled :: t() + + @doc """ + Begins downloading a file at `url` handled by `fun`. + + # Example + + iex> pid = self() + #PID<0.110.0> + iex> fun = fn {:data, data} -> File.write("index.html", data) + ...> {:error, error} -> IO.puts("error streaming file: \#{inspect(error)}") + ...> :complete -> send pid, :complete + ...> end + #Function<44.97283095/1 in :erl_eval.expr/5> + iex> NervesHubLink.Downloader.start_download("https://httpbin.com/", fun) + {:ok, #PID<0.111.0>} + iex> flush() + :complete + """ + @spec start_download(String.t() | URI.t(), event_handler_fun()) :: GenServer.on_start() + def start_download(url, fun) when is_function(fun, 1) do + retry_config = + struct(RetryConfig, Application.get_env(:nerves_hub_link_common, :retry_config, [])) + + GenServer.start_link(__MODULE__, [URI.parse(url), fun, retry_config]) + end + + @spec start_download(String.t() | URI.t(), event_handler_fun(), RetryConfig.t()) :: + GenServer.on_start() + def start_download(url, fun, %RetryConfig{} = retry_args) when is_function(fun, 1) do + GenServer.start_link(__MODULE__, [URI.parse(url), fun, retry_args]) + end + + @impl GenServer + def init([%URI{} = uri, fun, %RetryConfig{} = retry_args]) do + timer = Process.send_after(self(), :max_timeout, retry_args.max_timeout) + + state = + reset(%Downloader{ + handler_fun: fun, + retry_args: retry_args, + max_timeout: timer, + uri: uri + }) + + send(self(), :resume) + {:ok, state} + end + + @impl GenServer + # this message is scheduled during init/1 + # it is a extreme condition where regardless of download attempts, + # idle timeouts etc, this entire process has lived for TOO long. + def handle_info(:max_timeout, %Downloader{} = state) do + {:stop, :max_timeout_reached, state} + end + + # this message is scheduled when we receive the `content_length` value + def handle_info(:worst_case_download_speed_timeout, %Downloader{} = state) do + {:stop, :worst_case_download_speed_reached, state} + end + + # this message is delivered after `state.retry_args.idle_timeout` + # milliseconds have occurred. It indicates that many milliseconds have elapsed since + # the last "chunk" from the HTTP server + def handle_info(:timeout, %Downloader{handler_fun: handler} = state) do + _ = handler.({:error, :idle_timeout}) + state = reschedule_resume(state) + {:noreply, state} + end + + # message is scheduled when a resumable event happens. + def handle_info( + :resume, + %Downloader{ + retry_number: retry_number, + retry_args: %RetryConfig{max_disconnects: retry_number} + } = state + ) do + {:stop, :max_disconnects_reached, state} + end + + def handle_info(:resume, %Downloader{handler_fun: handler} = state) do + case resume_download(state.uri, state) do + {:ok, state} -> + {:noreply, state, state.retry_args.idle_timeout} + + error -> + _ = handler.(error) + state = reschedule_resume(state) + {:noreply, state} + end + end + + def handle_info(message, %Downloader{handler_fun: handler} = state) do + case Mint.HTTP.stream(state.conn, message) do + {:ok, conn, responses} -> + handle_responses(responses, %{state | conn: conn}) + + {:error, conn, error, responses} -> + _ = handler.({:error, error}) + handle_responses(responses, reschedule_resume(%{state | conn: conn})) + + :unknown -> + {:stop, :unknown, state} + end + end + + # schedules a message to be delivered based on retry args + @spec reschedule_resume(t()) :: resume_rescheduled() + defp reschedule_resume(%Downloader{retry_number: retry_number} = state) do + # cancel the worst_case_timeout if it was running + worst_case_timeout_remaining_ms = + if state.worst_case_timeout do + Process.cancel_timer(state.worst_case_timeout) || nil + end + + timer = Process.send_after(self(), :resume, state.retry_args.time_between_retries) + + %Downloader{ + state + | retry_timeout: timer, + retry_number: retry_number + 1, + worst_case_timeout_remaining_ms: worst_case_timeout_remaining_ms + } + end + + @spec schedule_worst_case_timer(t()) :: t() + # only calculate worst_case_timeout_remaining_ms is not set + defp schedule_worst_case_timer(%Downloader{worst_case_timeout_remaining_ms: nil} = downloader) do + # decompose here because in the formatter doesn't like all this being in the head + %Downloader{retry_args: retry_config, content_length: content_length} = downloader + %RetryConfig{worst_case_download_speed: speed} = retry_config + ms = TimeoutCalculation.calculate_worst_case_timeout(content_length, speed) + timer = Process.send_after(self(), :worst_case_download_speed_timeout, ms) + %Downloader{downloader | worst_case_timeout: timer} + end + + # worst_case_timeout_remaining_ms gets set if the timer gets canceled by reschedule_resume/1 + # this is done so that the timer doesn't keep counting while not actively downloading data + defp schedule_worst_case_timer(%Downloader{worst_case_timeout_remaining_ms: ms} = downloader) do + timer = Process.send_after(self(), :worst_case_download_speed_timeout, ms) + %Downloader{downloader | worst_case_timeout: timer} + end + + defp handle_responses([response | rest], %Downloader{} = state) do + case handle_response(response, state) do + # this `status != nil` thing seems really weird. Shouldn't be needed. + %Downloader{status: status} = state when status != nil and status >= 400 -> + {:stop, {:http_error, status}, state} + + state -> + handle_responses(rest, state) + end + end + + defp handle_responses( + [], + %Downloader{downloaded_length: downloaded, content_length: downloaded} = state + ) + when downloaded != 0 do + _ = state.handler_fun.(:complete) + {:stop, :normal, state} + end + + defp handle_responses([], %Downloader{} = state) do + {:noreply, state, state.retry_args.idle_timeout} + end + + @doc false + @spec handle_response( + {:status, reference(), non_neg_integer()} | {:headers, reference(), keyword()}, + Downloader.t() + ) :: + Downloader.t() + def handle_response( + {:status, request_ref, status}, + %Downloader{request_ref: request_ref} = state + ) + when status >= 300 and status < 400 do + %Downloader{state | status: status} + end + + # the handle_responses/2 function checks this value again because this function only handles state + def handle_response( + {:status, request_ref, status}, + %Downloader{request_ref: request_ref} = state + ) + when status >= 400 do + # kind of a hack to make the error type uniform + state.handler_fun.({:error, %Mint.HTTPError{reason: {:http_error, status}}}) + %Downloader{state | status: status} + end + + def handle_response( + {:status, request_ref, status}, + %Downloader{request_ref: request_ref} = state + ) + when status >= 200 and status < 300 do + %Downloader{state | status: status} + end + + # handles HTTP redirects. + def handle_response( + {:headers, request_ref, headers}, + %Downloader{request_ref: request_ref, status: status, handler_fun: handler} = state + ) + when status >= 300 and status < 400 do + location = fetch_location(headers) + Logger.info("Redirecting to #{location}") + + state = reset(state) + + case resume_download(location, state) do + {:ok, %Downloader{} = state} -> + state + + error -> + handler.(error) + state + end + end + + # if we already have the content-length header, don't fetch it again. + # range requests will change this value + def handle_response( + {:headers, request_ref, headers}, + %Downloader{request_ref: request_ref, content_length: content_length} = state + ) + when content_length > 0 do + schedule_worst_case_timer(%Downloader{state | response_headers: headers}) + end + + def handle_response( + {:headers, request_ref, headers}, + %Downloader{request_ref: request_ref, content_length: 0} = state + ) do + case fetch_accept_ranges(headers) do + accept_ranges when accept_ranges in ["none", nil] -> + Logger.error("HTTP Server does not support the Range header") + + _ -> + :ok + end + + content_length = fetch_content_length(headers) + + schedule_worst_case_timer(%Downloader{ + state + | response_headers: headers, + content_length: content_length + }) + end + + def handle_response( + {:data, request_ref, data}, + %Downloader{request_ref: request_ref, downloaded_length: downloaded} = state + ) do + _ = state.handler_fun.({:data, data}) + %Downloader{state | downloaded_length: downloaded + byte_size(data)} + end + + def handle_response({:done, request_ref}, %Downloader{request_ref: request_ref} = state) do + state + end + + # ignore other messages when redirecting + def handle_response(_, %Downloader{status: nil} = state) do + state + end + + defp reset(%Downloader{} = state) do + %Downloader{ + state + | retry_number: 0, + downloaded_length: 0, + content_length: 0 + } + end + + @spec resume_download(URI.t(), t()) :: + {:ok, initialized_download()} + | {:error, Mint.Types.error()} + | {:error, Mint.HTTP.t(), Mint.Types.error()} + defp resume_download( + %URI{scheme: scheme, host: host, port: port, path: path, query: query} = uri, + %Downloader{} = state + ) + when scheme in ["https", "http"] do + request_headers = + [{"content-type", "application/octet-stream"}] + |> add_range_header(state) + |> add_retry_number_header(state) + |> add_user_agent_header(state) + + # mint doesn't accept the query as the http body, so it must be encoded + # like this. There may be a better way to do this.. + path = if query, do: "#{path}?#{query}", else: path + + Logger.info("Resuming download attempt number #{state.retry_number} #{uri}") + + with {:ok, conn} <- Mint.HTTP.connect(String.to_existing_atom(scheme), host, port), + {:ok, conn, request_ref} <- Mint.HTTP.request(conn, "GET", path, request_headers, nil) do + {:ok, + %Downloader{ + state + | uri: uri, + conn: conn, + request_ref: request_ref, + status: nil, + response_headers: [] + }} + end + end + + @spec fetch_content_length(Mint.Types.headers()) :: 0 | pos_integer() + defp fetch_content_length(headers) + defp fetch_content_length([{"content-length", value} | _]), do: String.to_integer(value) + defp fetch_content_length([_ | rest]), do: fetch_content_length(rest) + defp fetch_content_length([]), do: 0 + + @spec fetch_location(Mint.Types.headers()) :: nil | URI.t() + defp fetch_location(headers) + defp fetch_location([{"location", uri} | _]), do: URI.parse(uri) + defp fetch_location([_ | rest]), do: fetch_location(rest) + defp fetch_location([]), do: nil + + defp fetch_accept_ranges(headers) + defp fetch_accept_ranges([{"accept-ranges", value} | _]), do: value + defp fetch_accept_ranges([_ | rest]), do: fetch_accept_ranges(rest) + defp fetch_accept_ranges([]), do: nil + + @spec add_range_header(Mint.Types.headers(), t()) :: Mint.Types.headers() + defp add_range_header(headers, state) + + defp add_range_header(headers, %Downloader{content_length: 0}), do: headers + + defp add_range_header(headers, %Downloader{downloaded_length: r, content_length: total}) + when total > 0, + do: [{"Range", "bytes=#{r}-#{total}"} | headers] + + @spec add_retry_number_header(Mint.Types.headers(), t()) :: Mint.Types.headers() + defp add_retry_number_header(headers, %Downloader{retry_number: retry_number}), + do: [{"X-Retry-Number", "#{retry_number}"} | headers] + + defp add_user_agent_header(headers, _), + do: [{"User-Agent", "NHL/#{Application.spec(:nerves_hub_link_common)[:vsn]}"} | headers] +end diff --git a/lib/nerves_hub_link/downloader/retry_config.ex b/lib/nerves_hub_link/downloader/retry_config.ex new file mode 100644 index 0000000..854ce17 --- /dev/null +++ b/lib/nerves_hub_link/downloader/retry_config.ex @@ -0,0 +1,69 @@ +defmodule NervesHubLink.Downloader.RetryConfig do + @moduledoc """ + Configuration structure for how the Downloader server will + handle disconnects, errors, timeouts etc + """ + + defstruct [ + # stop trying after this many disconnects + max_disconnects: 10, + + # attempt a retry after this time + # if no data comes in after this amount of time, disconnect and retry + idle_timeout: 60_000, + + # if the total time since this server has started reaches this time, + # stop trying, give up, disconnect, etc + # started right when the gen_server starts + # default is 24 hours as that is how long NervesHub AWS urls are signed for + max_timeout: 86_400_000, + + # don't bother retrying until this time has passed + time_between_retries: 15_000, + + # worst case average download speed in bits/second + # This is used to calculate a "sensible" timeout that is shorter than `max_timeout`. + # LTE Cat M1 modems sometimes top out at 32 kbps (30 kbps for some slack) + worst_case_download_speed: 30_000 + ] + + @typedoc """ + maximum number of disconnects. After this limit is reached + the download will be stopped and will no longer be retried + """ + @type max_disconnects :: non_neg_integer() + + @typedoc """ + time in milliseconds between chunks of data received + that once elapsed will trigger a retry. This event counts + towards the `max_disconnects` counter + """ + @type idle_timeout :: non_neg_integer() + + @typedoc """ + maximum time in milliseconds that a download can exist for. + after this amount of time has elapsed, the download is canceled + and the download process will crash + """ + @type max_timeout :: non_neg_integer() + + @typedoc """ + time in milliseconds to wait before attempting to retry a download + """ + @type time_between_retries :: non_neg_integer() + + @typedoc """ + worst case download speed specified in bytes per second. This is + used to calculate the "worst case" download timeout. it is meant to + fail faster than waiting for `max_timeout` to elapse + """ + @type worst_case_download_speed :: non_neg_integer() + + @type t :: %__MODULE__{ + max_disconnects: max_disconnects(), + idle_timeout: idle_timeout(), + max_timeout: max_timeout(), + time_between_retries: time_between_retries(), + worst_case_download_speed: worst_case_download_speed() + } +end diff --git a/lib/nerves_hub_link/downloader/timeout_calculation.ex b/lib/nerves_hub_link/downloader/timeout_calculation.ex new file mode 100644 index 0000000..35b939f --- /dev/null +++ b/lib/nerves_hub_link/downloader/timeout_calculation.ex @@ -0,0 +1,16 @@ +defmodule NervesHubLink.Downloader.TimeoutCalculation do + @moduledoc """ + Pure functions for dealing with timeouts + """ + + @type number_of_bytes :: non_neg_integer() + @type bits_per_second :: non_neg_integer() + + @doc "Calculates the worst_case_timeout value based on content_length header and worst case network speed" + @spec calculate_worst_case_timeout(number_of_bytes, bits_per_second) :: non_neg_integer() + def calculate_worst_case_timeout(content_length, speed) do + # need to extract milliseconds based on a speed in seconds and number of bits + # set a max of 1 minute in case the data is smaller than the conceivably fastest speed + round(content_length * 8 / speed * 1000) |> max(60_000) + end +end diff --git a/lib/nerves_hub_link/fwup_config.ex b/lib/nerves_hub_link/fwup_config.ex new file mode 100644 index 0000000..cdd8784 --- /dev/null +++ b/lib/nerves_hub_link/fwup_config.ex @@ -0,0 +1,86 @@ +defmodule NervesHubLink.FwupConfig do + @moduledoc """ + Config structure responsible for handling callbacks from FWUP, + applying a fwupdate, and storing fwup task configuration + """ + alias NervesHubLink.Message.UpdateInfo + + defstruct fwup_public_keys: [], + fwup_devpath: "/dev/mmcblk0", + fwup_env: [], + fwup_task: "upgrade", + handle_fwup_message: nil, + update_available: nil + + @typedoc """ + `handle_fwup_message` will be called with this data + """ + @type fwup_message :: + {:progress, non_neg_integer()} + | {:warning, non_neg_integer(), String.t()} + | {:error, non_neg_integer(), String.t()} + | {:ok, non_neg_integer(), String.t()} + + @typedoc """ + Callback that will be called during the lifecycle of a fwupdate being applied + """ + @type handle_fwup_message_fun() :: (fwup_message -> any) + + @typedoc """ + Called when an update has been dispatched via `NervesHubLink.UpdateManager.apply_update/2` + """ + @type update_available_fun() :: + (UpdateInfo.t() -> :ignore | {:reschedule, timeout()} | :apply) + + @type t :: %__MODULE__{ + fwup_public_keys: [String.t()], + fwup_devpath: Path.t(), + fwup_task: String.t(), + fwup_env: [{String.t(), String.t()}], + handle_fwup_message: handle_fwup_message_fun, + update_available: update_available_fun + } + + @doc "Raises an ArgumentError on invalid arguments" + @spec validate!(t()) :: t() + def validate!(%__MODULE__{} = args) do + args + |> validate_fwup_public_keys!() + |> validate_fwup_devpath!() + |> validate_fwup_env!() + |> validate_handle_fwup_message!() + |> validate_update_available!() + end + + defp validate_fwup_public_keys!(%__MODULE__{fwup_public_keys: list} = args) when is_list(list), + do: args + + defp validate_fwup_public_keys!(%__MODULE__{}), + do: raise(ArgumentError, message: "invalid arg: fwup_public_keys") + + defp validate_fwup_devpath!(%__MODULE__{fwup_devpath: devpath} = args) when is_binary(devpath), + do: args + + defp validate_fwup_devpath!(%__MODULE__{}), + do: raise(ArgumentError, message: "invalid arg: fwup_devpath") + + defp validate_handle_fwup_message!(%__MODULE__{handle_fwup_message: handle_fwup_message} = args) + when is_function(handle_fwup_message, 1), + do: args + + defp validate_handle_fwup_message!(%__MODULE__{}), + do: raise(ArgumentError, message: "handle_fwup_message function signature incorrect") + + defp validate_update_available!(%__MODULE__{update_available: update_available} = args) + when is_function(update_available, 1), + do: args + + defp validate_update_available!(%__MODULE__{}), + do: raise(ArgumentError, message: "update_available function signature incorrect") + + defp validate_fwup_env!(%__MODULE__{fwup_env: list} = args) when is_list(list), + do: args + + defp validate_fwup_env!(%__MODULE__{}), + do: raise(ArgumentError, message: "invalid arg: fwup_env") +end diff --git a/lib/nerves_hub_link/message/firmware_metadata.ex b/lib/nerves_hub_link/message/firmware_metadata.ex new file mode 100644 index 0000000..aadb3a6 --- /dev/null +++ b/lib/nerves_hub_link/message/firmware_metadata.ex @@ -0,0 +1,48 @@ +defmodule NervesHubLink.Message.FirmwareMetadata do + @moduledoc """ + Structure containing metadata about a firmware. + """ + + defstruct [ + :architecture, + :author, + :description, + :fwup_version, + :misc, + :platform, + :product, + :uuid, + :vcs_identifier, + :version + ] + + @type t() :: %__MODULE__{ + architecture: String.t(), + author: String.t() | nil, + description: String.t() | nil, + fwup_version: Version.build() | nil, + misc: String.t() | nil, + platform: String.t(), + product: String.t(), + uuid: binary(), + vcs_identifier: String.t() | nil, + version: Version.build() + } + + @spec parse(map()) :: {:ok, t()} + def parse(params) do + {:ok, + %__MODULE__{ + architecture: params["architecture"], + author: params["author"], + description: params["description"], + fwup_version: params["fwup_version"], + misc: params["misc"], + platform: params["platform"], + product: params["product"], + uuid: params["uuid"], + vcs_identifier: params["vcs_identifier"], + version: params["version"] + }} + end +end diff --git a/lib/nerves_hub_link/message/update_info.ex b/lib/nerves_hub_link/message/update_info.ex new file mode 100644 index 0000000..85f9426 --- /dev/null +++ b/lib/nerves_hub_link/message/update_info.ex @@ -0,0 +1,32 @@ +defmodule NervesHubLink.Message.UpdateInfo do + @moduledoc false + + alias NervesHubLink.Message.FirmwareMetadata + + defstruct [:firmware_url, :firmware_meta] + + @typedoc """ + Payload that gets dispatched down to devices upon an update + + `firmware_url` and `firmware_meta` are only available + when `update_available` is true. + """ + @type t() :: %__MODULE__{ + firmware_url: URI.t(), + firmware_meta: FirmwareMetadata.t() + } + + @doc "Parse an update message from NervesHub" + @spec parse(map()) :: {:ok, t()} | {:error, :invalid_params} + def parse(%{"firmware_meta" => %{} = meta, "firmware_url" => url}) do + with {:ok, firmware_meta} <- FirmwareMetadata.parse(meta) do + {:ok, + %__MODULE__{ + firmware_url: URI.parse(url), + firmware_meta: firmware_meta + }} + end + end + + def parse(_), do: {:error, :invalid_params} +end diff --git a/lib/nerves_hub_link/socket.ex b/lib/nerves_hub_link/socket.ex index c4c387a..417aace 100644 --- a/lib/nerves_hub_link/socket.ex +++ b/lib/nerves_hub_link/socket.ex @@ -5,7 +5,7 @@ defmodule NervesHubLink.Socket do require Logger alias NervesHubLink.Client - alias NervesHubLinkCommon.UpdateManager + alias NervesHubLink.UpdateManager defmodule State do @type t :: %__MODULE__{ @@ -159,8 +159,8 @@ defmodule NervesHubLink.Socket do end def handle_message(@device_topic, "update", update, socket) do - case NervesHubLinkCommon.Message.UpdateInfo.parse(update) do - {:ok, %NervesHubLinkCommon.Message.UpdateInfo{} = info} -> + case NervesHubLink.Message.UpdateInfo.parse(update) do + {:ok, %NervesHubLink.Message.UpdateInfo{} = info} -> _ = UpdateManager.apply_update(info) {:ok, socket} @@ -267,8 +267,8 @@ defmodule NervesHubLink.Socket do end defp handle_join_reply(%{"firmware_url" => url} = update) when is_binary(url) do - case NervesHubLinkCommon.Message.UpdateInfo.parse(update) do - {:ok, %NervesHubLinkCommon.Message.UpdateInfo{} = info} -> + case NervesHubLink.Message.UpdateInfo.parse(update) do + {:ok, %NervesHubLink.Message.UpdateInfo{} = info} -> UpdateManager.apply_update(info) error -> diff --git a/lib/nerves_hub_link/update_manager.ex b/lib/nerves_hub_link/update_manager.ex new file mode 100644 index 0000000..3e47f57 --- /dev/null +++ b/lib/nerves_hub_link/update_manager.ex @@ -0,0 +1,259 @@ +defmodule NervesHubLink.UpdateManager do + @moduledoc """ + GenServer responsible for brokering messages between: + * an external controlling process + * FWUP + * HTTP + + Should be started in a supervision tree + """ + use GenServer + + alias NervesHubLink.{Downloader, FwupConfig} + alias NervesHubLink.Message.UpdateInfo + + require Logger + + defmodule State do + @moduledoc """ + Structure for the state of the `UpdateManager` server. + Contains types that describe status and different states the + `UpdateManager` can be in + """ + + @type status :: + :idle + | {:fwup_error, String.t()} + | :update_rescheduled + | {:updating, integer()} + + @type t :: %__MODULE__{ + status: status(), + update_reschedule_timer: nil | :timer.tref(), + download: nil | GenServer.server(), + fwup: nil | GenServer.server(), + fwup_config: FwupConfig.t(), + update_info: nil | UpdateInfo.t() + } + + defstruct status: :idle, + update_reschedule_timer: nil, + fwup: nil, + download: nil, + fwup_config: nil, + update_info: nil + end + + @doc """ + Must be called when an update payload is dispatched from + NervesHub. the map must contain a `"firmware_url"` key. + """ + @spec apply_update(GenServer.server(), UpdateInfo.t()) :: State.status() + def apply_update(manager \\ __MODULE__, %UpdateInfo{} = update_info) do + GenServer.call(manager, {:apply_update, update_info}) + end + + @doc """ + Returns the current status of the update manager + """ + @spec status(GenServer.server()) :: State.status() + def status(manager \\ __MODULE__) do + GenServer.call(manager, :status) + end + + @doc """ + Returns the UUID of the currently downloading firmware, or nil. + """ + @spec currently_downloading_uuid(GenServer.server()) :: uuid :: String.t() | nil + def currently_downloading_uuid(manager \\ __MODULE__) do + GenServer.call(manager, :currently_downloading_uuid) + end + + @doc """ + Add a FWUP Public key + """ + @spec add_fwup_public_key(GenServer.server(), String.t()) :: :ok + def add_fwup_public_key(manager \\ __MODULE__, pubkey) do + GenServer.call(manager, {:fwup_public_key, :add, pubkey}) + end + + @doc """ + Remove a FWUP public key + """ + @spec remove_fwup_public_key(GenServer.server(), String.t()) :: :ok + def remove_fwup_public_key(manager \\ __MODULE__, pubkey) do + GenServer.call(manager, {:fwup_public_key, :remove, pubkey}) + end + + @doc false + @spec child_spec(FwupConfig.t()) :: Supervisor.child_spec() + def child_spec(%FwupConfig{} = args) do + %{ + start: {__MODULE__, :start_link, [args, [name: __MODULE__]]}, + id: __MODULE__ + } + end + + @doc false + @spec start_link(FwupConfig.t(), GenServer.options()) :: GenServer.on_start() + def start_link(%FwupConfig{} = args, opts \\ []) do + GenServer.start_link(__MODULE__, args, opts) + end + + @impl GenServer + def init(%FwupConfig{} = fwup_config) do + fwup_config = FwupConfig.validate!(fwup_config) + {:ok, %State{fwup_config: fwup_config}} + end + + @impl GenServer + def handle_call({:apply_update, %UpdateInfo{} = update}, _from, %State{} = state) do + state = maybe_update_firmware(update, state) + {:reply, state.status, state} + end + + def handle_call(:currently_downloading_uuid, _from, %State{update_info: nil} = state) do + {:reply, nil, state} + end + + def handle_call(:currently_downloading_uuid, _from, %State{} = state) do + {:reply, state.update_info.firmware_meta.uuid, state} + end + + def handle_call(:status, _from, %State{} = state) do + {:reply, state.status, state} + end + + def handle_call({:fwup_public_key, action, pubkey}, _from, %State{} = state) do + pubkey = String.trim(pubkey) + keys = state.fwup_config.fwup_public_keys + + updated = + case action do + :add -> [pubkey | keys] + :remove -> for i <- keys, i != pubkey, do: i + end + + state = put_in(state.fwup_config.fwup_public_keys, Enum.uniq(updated)) + {:reply, :ok, state} + end + + @impl GenServer + def handle_info({:update_reschedule, response}, state) do + {:noreply, maybe_update_firmware(response, %State{state | update_reschedule_timer: nil})} + end + + # messages from FWUP + def handle_info({:fwup, message}, state) do + _ = state.fwup_config.handle_fwup_message.(message) + + case message do + {:ok, 0, _message} -> + Logger.info("[NervesHubLink] FWUP Finished") + {:noreply, %State{state | fwup: nil, update_info: nil, status: :idle}} + + {:progress, percent} -> + {:noreply, %State{state | status: {:updating, percent}}} + + {:error, _, message} -> + {:noreply, %State{state | status: {:fwup_error, message}}} + + _ -> + {:noreply, state} + end + end + + # messages from Downloader + def handle_info({:download, :complete}, state) do + Logger.info("[NervesHubLink] Firmware Download complete") + {:noreply, %State{state | download: nil}} + end + + def handle_info({:download, {:error, reason}}, state) do + Logger.error("[NervesHubLink] Nonfatal HTTP download error: #{inspect(reason)}") + {:noreply, state} + end + + # Data from the downloader is sent to fwup + def handle_info({:download, {:data, data}}, state) do + _ = Fwup.Stream.send_chunk(state.fwup, data) + {:noreply, state} + end + + @spec maybe_update_firmware(UpdateInfo.t(), State.t()) :: State.t() + defp maybe_update_firmware( + %UpdateInfo{} = _update_info, + %State{status: {:updating, _percent}} = state + ) do + # Received an update message from NervesHub, but we're already in progress. + # It could be because the deployment/device was edited making a duplicate + # update message or a new deployment was created. Either way, lets not + # interrupt FWUP and let the task finish. After update and reboot, the + # device will check-in and get an update message if it was actually new and + # required + state + end + + defp maybe_update_firmware(%UpdateInfo{} = update_info, %State{} = state) do + # Cancel an existing timer if it exists. + # This prevents rescheduled updates` + # from compounding. + state = maybe_cancel_timer(state) + + # possibly offload update decision to an external module. + # This will allow application developers + # to control exactly when an update is applied. + # note: update_available is a behaviour function + case state.fwup_config.update_available.(update_info) do + :apply -> + start_fwup_stream(update_info, state) + + :ignore -> + state + + {:reschedule, ms} -> + timer = Process.send_after(self(), {:update_reschedule, update_info}, ms) + Logger.info("[NervesHubLink] rescheduling firmware update in #{ms} milliseconds") + %{state | status: :update_rescheduled, update_reschedule_timer: timer} + end + end + + defp maybe_update_firmware(_, state), do: state + + defp maybe_cancel_timer(%{update_reschedule_timer: nil} = state), do: state + + defp maybe_cancel_timer(%{update_reschedule_timer: timer} = state) do + _ = Process.cancel_timer(timer) + + %{state | update_reschedule_timer: nil} + end + + @spec start_fwup_stream(UpdateInfo.t(), State.t()) :: State.t() + defp start_fwup_stream(%UpdateInfo{} = update_info, state) do + pid = self() + fun = &send(pid, {:download, &1}) + {:ok, download} = Downloader.start_download(update_info.firmware_url, fun) + + {:ok, fwup} = + Fwup.stream(pid, fwup_args(state.fwup_config), fwup_env: state.fwup_config.fwup_env) + + Logger.info("[NervesHubLink] Downloading firmware: #{update_info.firmware_url}") + + %State{ + state + | status: {:updating, 0}, + download: download, + fwup: fwup, + update_info: update_info + } + end + + @spec fwup_args(FwupConfig.t()) :: [String.t()] + defp fwup_args(%FwupConfig{} = config) do + args = ["--apply", "--no-unmount", "-d", config.fwup_devpath, "--task", config.fwup_task] + + Enum.reduce(config.fwup_public_keys, args, fn public_key, args -> + args ++ ["--public-key", public_key] + end) + end +end diff --git a/mix.exs b/mix.exs index dd66ddf..603f394 100644 --- a/mix.exs +++ b/mix.exs @@ -90,17 +90,20 @@ defmodule NervesHubLink.MixProject do defp deps do [ + {:castore, "~> 0.1.0"}, + {:credo, "~> 1.2", only: :test, runtime: false}, {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, - {:ex_doc, "~> 0.18", only: :docs, runtime: false}, {:excoveralls, "~> 0.10", only: :test}, + {:ex_doc, "~> 0.18", only: :docs, runtime: false}, {:extty, "~> 0.2"}, {:fwup, "~> 1.0"}, {:hackney, "~> 1.10"}, {:jason, "~> 1.0"}, + {:mint, "~> 1.2"}, {:mox, "~> 1.0.0", only: :test}, {:nerves_key, "~> 1.0 or ~> 0.5", optional: true}, {:nerves_runtime, "~> 0.8"}, - {:nerves_hub_link_common, "~> 0.4"}, + {:plug_cowboy, "~> 2.0", only: :test}, {:slipstream, "~> 1.0 or ~> 0.8"}, {:x509, "~> 0.5"} ] diff --git a/mix.lock b/mix.lock index 6372e57..fe83158 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,13 @@ %{ "atecc508a": {:hex, :atecc508a, "1.1.0", "8bcbc5e46e04cd48d5cf2c219559e65b4333fd85cbb86bc659f8993312014a69", [:mix], [{:circuits_i2c, "~> 0.2 or ~> 1.0", [hex: :circuits_i2c, repo: "hexpm", optional: false]}, {:x509, "~> 0.5.1 or ~> 0.6", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "ebfdacc6b40e8051ac550e7df45b8d5f1f78fdc39d771d2d8209e16ad02196ac"}, + "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "castore": {:hex, :castore, "0.1.20", "62a0126cbb7cb3e259257827b9190f88316eb7aa3fdac01fd6f2dfd64e7f46e9", [:mix], [], "hexpm", "a020b7650529c986c454a4035b6b13a328e288466986307bea3aadb4c95ac98a"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, "circuits_i2c": {:hex, :circuits_i2c, "1.1.0", "a6ac3a9ae4f7f7f92a5242652008e1646da22adef0587d230a621501f935ba46", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "39fee480cebc6fbd90abb4b82c15cf4a95e45ea741369886bd903ce9ab6eb60c"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, "elixir_make": {:hex, :elixir_make, "0.7.2", "e83548b0500e654d1a595f1134af4862a2e92ec3282ec4c2a17641e9aa45ee73", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "05fb44abf9582381c2eb1b73d485a55288c581071de0ee3ee1084ee69d6a8e5f"}, @@ -10,6 +15,7 @@ "ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"}, "excoveralls": {:hex, :excoveralls, "0.15.1", "83c8cf7973dd9d1d853dce37a2fb98aaf29b564bf7d01866e409abf59dac2c0e", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f8416bd90c0082d56a2178cf46c837595a06575f70a5624f164a1ffe37de07e7"}, "extty": {:hex, :extty, "0.2.1", "4da6d78d41f0a9ff9980d82968476b4277ac0afa227d2ed91af0ffcbac2f451b", [:mix], [], "hexpm", "26b2e495c14501d4ae24c7dffba199f6abf0a0f69dcfd07db62f421952442f05"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "fwup": {:hex, :fwup, "1.1.0", "6c9d5f06a38263855dcf34f5a5b4da46c2ca42f6e3f873b8f932d095ce010765", [:mix], [], "hexpm", "3ff61d932a42785efc3324ecab6cf9bd3de75dacfca16d9e659a7173ef9f15d6"}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, @@ -19,11 +25,11 @@ "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.2", "0933a4c82f2376e35569b2255cdce94f2e3f993c0d5b04c360460cb8beda7154", [:mix], [{:mint, ">= 1.4.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "067c5e15439be060f2ab57c468ee4ab29e39cb20b498ed990cb94f62db0efc3a"}, "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, - "nerves_hub_link_common": {:hex, :nerves_hub_link_common, "0.4.1", "efa35947e2c7145c4152300e0124e7fa9efc72179080dd3faaa4eb57a191669e", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:fwup, "~> 1.0", [hex: :fwup, repo: "hexpm", optional: false]}, {:mint, "~> 1.2", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "0aabbd63bd7e7ca035f0c812b58815a2b8571d5401ee51e3c2783934b3c666f9"}, "nerves_key": {:hex, :nerves_key, "1.1.0", "bb32a77e769e049607b546566bb676e51f9c71a393adce9d71d3b92280a64916", [:mix], [{:atecc508a, "~> 0.3.0 or ~> 1.1", [hex: :atecc508a, repo: "hexpm", optional: false]}, {:nerves_key_pkcs11, "~> 0.2 or ~> 1.0", [hex: :nerves_key_pkcs11, repo: "hexpm", optional: false]}], "hexpm", "ba19f49bf9f3d253f3e93d084bb17ffd825476a0517c585e08e6c46ee28cf270"}, "nerves_key_pkcs11": {:hex, :nerves_key_pkcs11, "1.1.1", "e1edf0e8fbe514e792a2ef2b8234fc37cf733b4cc079954ce1e4163525681d30", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "80fa08978fb9627fb53b37681e3c8701582bac6bdba0b8e812d5bec074e44f9a"}, "nerves_logging": {:hex, :nerves_logging, "0.2.0", "4099b860f41a0171ff49fbc1e86ee0ce4576c24c1cf318a0fd0bf227355e8c12", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "07cfb9fe9d21b908da51b81a1ced858288c68519204aca7aa2c1fcd531d5e059"}, @@ -32,7 +38,11 @@ "nimble_options": {:hex, :nimble_options, "0.5.2", "42703307b924880f8c08d97719da7472673391905f528259915782bb346e0a1b", [:mix], [], "hexpm", "4da7f904b915fd71db549bcdc25f8d56f378ef7ae07dc1d372cbe72ba950dce0"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "property_table": {:hex, :property_table, "0.2.2", "79c6879e74f510d8a5990d574e9da85fb8a8e7e14c5ad6367fdc8f757ec58ba2", [:mix], [], "hexpm", "984870a52d531413dc36c194e0b438a1d70781a222fae588bb965d460cb112ff"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "slipstream": {:hex, :slipstream, "1.0.1", "0b2f6990178a0f1eddec11a3318ca12c4ef3939f753565858256c7abd73266e5", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mint_web_socket, "~> 0.2 or ~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3b7654de36654a9ff1a92becbe9081ae9689d5883ff304803b3193a3c0aba913"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, diff --git a/test/nerves_hub_link/client/default_test.exs b/test/nerves_hub_link/client/default_test.exs index f9a0a36..a52d053 100644 --- a/test/nerves_hub_link/client/default_test.exs +++ b/test/nerves_hub_link/client/default_test.exs @@ -1,7 +1,7 @@ defmodule NervesHubLink.Client.DefaultTest do use ExUnit.Case, async: true alias NervesHubLink.Client.Default - alias NervesHubLinkCommon.Message.{FirmwareMetadata, UpdateInfo} + alias NervesHubLink.Message.{FirmwareMetadata, UpdateInfo} doctest Default diff --git a/test/nerves_hub_link/downloader/timeout_calculation_test.exs b/test/nerves_hub_link/downloader/timeout_calculation_test.exs new file mode 100644 index 0000000..e7bc6e4 --- /dev/null +++ b/test/nerves_hub_link/downloader/timeout_calculation_test.exs @@ -0,0 +1,15 @@ +defmodule NervesHubLink.Downloader.TimeoutCalculationTest do + use ExUnit.Case + alias NervesHubLink.Downloader.TimeoutCalculation + + test "calculate_worst_case_timeout" do + # 20 mb @ 30 b/sec is about 1.5 hours + assert TimeoutCalculation.calculate_worst_case_timeout(20_971_520, 30_000) == 5_592_405 + end + + test "calculate_worst_case_timeout minimum value" do + # small data now matter how slow finishes quickly + # ensure there's a minimum timeout of 60000 ms + assert TimeoutCalculation.calculate_worst_case_timeout(1, 30_000) == 60000 + end +end diff --git a/test/nerves_hub_link/downloader_test.exs b/test/nerves_hub_link/downloader_test.exs new file mode 100644 index 0000000..52dd318 --- /dev/null +++ b/test/nerves_hub_link/downloader_test.exs @@ -0,0 +1,182 @@ +defmodule NervesHubLink.DownloaderTest do + use ExUnit.Case + + alias NervesHubLink.Support.{ + HTTPErrorPlug, + IdleTimeoutPlug, + RangeRequestPlug, + RedirectPlug, + XRetryNumberPlug + } + + alias NervesHubLink.{Downloader, Downloader.RetryConfig} + + @short_retry_args %RetryConfig{ + max_disconnects: 10, + idle_timeout: 60_000, + max_timeout: 3_600_000, + time_between_retries: 10, + worst_case_download_speed: 30_000 + } + + @failure_url "http://localhost/this_should_fail" + + test "max_disconnects" do + test_pid = self() + handler_fun = &send(test_pid, &1) + + retry_args = %RetryConfig{ + max_disconnects: 2, + time_between_retries: 1 + } + + Process.flag(:trap_exit, true) + {:ok, download} = Downloader.start_download(@failure_url, handler_fun, retry_args) + # should receive this one twice + assert_receive {:error, %Mint.TransportError{reason: :econnrefused}}, 1000 + assert_receive {:error, %Mint.TransportError{reason: :econnrefused}} + # then exit + assert_receive {:EXIT, ^download, :max_disconnects_reached} + end + + test "max_timeout" do + test_pid = self() + handler_fun = &send(test_pid, &1) + + retry_args = %RetryConfig{ + max_timeout: 10 + } + + Process.flag(:trap_exit, true) + {:ok, download} = Downloader.start_download(@failure_url, handler_fun, retry_args) + assert_receive {:error, %Mint.TransportError{reason: :econnrefused}}, 1000 + assert_receive {:EXIT, ^download, :max_timeout_reached} + end + + describe "idle timeout" do + setup do + port = 5004 + + {:ok, plug} = + start_supervised( + {Plug.Cowboy, scheme: :http, plug: IdleTimeoutPlug, options: [port: port]} + ) + + {:ok, [plug: plug, url: "http://localhost:#{port}/test"]} + end + + test "idle_timeout causes retry", %{url: url} do + test_pid = self() + handler_fun = &send(test_pid, &1) + + retry_args = %RetryConfig{ + idle_timeout: 100, + time_between_retries: 10 + } + + {:ok, _download} = Downloader.start_download(url, handler_fun, retry_args) + assert_receive {:error, :idle_timeout}, 1000 + assert_receive {:data, "content"} + assert_receive :complete + end + end + + describe "http error" do + setup do + port = 5003 + + {:ok, plug} = + start_supervised({Plug.Cowboy, scheme: :http, plug: HTTPErrorPlug, options: [port: port]}) + + {:ok, [plug: plug, url: "http://localhost:#{port}/test"]} + end + + test "exits when an HTTP error occurs", %{url: url} do + test_pid = self() + handler_fun = &send(test_pid, &1) + Process.flag(:trap_exit, true) + {:ok, download} = Downloader.start_download(url, handler_fun, @short_retry_args) + assert_receive {:error, %Mint.HTTPError{reason: {:http_error, 416}}}, 1000 + assert_receive {:EXIT, ^download, {:http_error, 416}} + end + end + + describe "range" do + setup do + port = 5002 + + {:ok, plug} = + start_supervised( + {Plug.Cowboy, scheme: :http, plug: RangeRequestPlug, options: [port: port]} + ) + + {:ok, [plug: plug, url: "http://localhost:#{port}/test"]} + end + + test "calculates range request header", %{url: url} do + test_pid = self() + handler_fun = &send(test_pid, &1) + {:ok, _} = Downloader.start_download(url, handler_fun, @short_retry_args) + + assert_receive {:data, "h"}, 1000 + assert_receive {:error, _} + + refute_receive {:error, _} + assert_receive {:data, "ello, world"} + end + end + + describe "redirect" do + setup do + port = 5001 + + {:ok, plug} = + start_supervised({Plug.Cowboy, scheme: :http, plug: RedirectPlug, options: [port: port]}) + + {:ok, [plug: plug, url: "http://localhost:#{port}/redirect"]} + end + + test "follows redirects", %{url: url} do + test_pid = self() + handler_fun = &send(test_pid, &1) + {:ok, _download} = Downloader.start_download(url, handler_fun) + refute_receive {:error, _} + assert_receive {:data, "redirected"} + end + end + + describe "xretry" do + setup do + port = 5000 + + {:ok, plug} = + start_supervised( + {Plug.Cowboy, scheme: :http, plug: XRetryNumberPlug, options: [port: port]} + ) + + {:ok, [plug: plug, url: "http://localhost:#{port}/test"]} + end + + test "simple download resume", %{url: url} do + test_pid = self() + handler_fun = &send(test_pid, &1) + expected_data_part_1 = :binary.copy(<<0>>, 2048) + expected_data_part_2 = :binary.copy(<<1>>, 2048) + + # download the first part of the data. + # the plug will terminate the connection after 2048 bytes are sent. + # the handler_fun will send the data to this test's mailbox. + {:ok, _download} = Downloader.start_download(url, handler_fun, @short_retry_args) + assert_receive {:data, ^expected_data_part_1}, 1000 + + # download will be resumed after the error + assert_receive {:error, _} + + # second part should now be delivered + assert_receive {:data, ^expected_data_part_2} + + # the request should complete successfully this time + assert_receive :complete + end + end +end diff --git a/test/nerves_hub_link/update_manager_test.exs b/test/nerves_hub_link/update_manager_test.exs new file mode 100644 index 0000000..cb4c36c --- /dev/null +++ b/test/nerves_hub_link/update_manager_test.exs @@ -0,0 +1,124 @@ +defmodule NervesHubLink.UpdateManagerTest do + use ExUnit.Case + alias NervesHubLink.{FwupConfig, UpdateManager} + alias NervesHubLink.Message.{FirmwareMetadata, UpdateInfo} + alias NervesHubLink.Support.FWUPStreamPlug + + describe "fwup stream" do + setup do + port = 5000 + devpath = "/tmp/fwup_output" + + update_payload = %UpdateInfo{ + firmware_url: "http://localhost:#{port}/test.fw", + firmware_meta: %FirmwareMetadata{} + } + + {:ok, plug} = + start_supervised( + {Plug.Cowboy, scheme: :http, plug: FWUPStreamPlug, options: [port: port]} + ) + + File.rm(devpath) + + {:ok, [plug: plug, update_payload: update_payload, devpath: "/tmp/fwup_output"]} + end + + test "apply", %{update_payload: update_payload, devpath: devpath} do + fwup_config = %{default_config() | fwup_devpath: devpath} + + {:ok, manager} = UpdateManager.start_link(fwup_config) + assert UpdateManager.apply_update(manager, update_payload) == {:updating, 0} + + assert_receive {:fwup, {:progress, 0}} + assert_receive {:fwup, {:progress, 100}} + assert_receive {:fwup, {:ok, 0, ""}} + end + + test "reschedule", %{update_payload: update_payload, devpath: devpath} do + test_pid = self() + + update_available_fun = fn _ -> + case Process.get(:reschedule) do + nil -> + send(test_pid, :rescheduled) + Process.put(:reschedule, true) + {:reschedule, 50} + + _ -> + :apply + end + end + + fwup_config = %{ + default_config() + | fwup_devpath: devpath, + update_available: update_available_fun + } + + {:ok, manager} = UpdateManager.start_link(fwup_config) + assert UpdateManager.apply_update(manager, update_payload) == :update_rescheduled + assert_received :rescheduled + refute_received {:fwup, _} + + assert_receive {:fwup, {:progress, 0}}, 250 + assert_receive {:fwup, {:progress, 100}} + assert_receive {:fwup, {:ok, 0, ""}} + end + + test "apply with fwup environment", %{update_payload: update_payload, devpath: devpath} do + fwup_config = %{ + default_config() + | fwup_devpath: devpath, + fwup_task: "secret_upgrade", + fwup_env: [ + {"SUPER_SECRET", "1234567890123456789012345678901234567890123456789012345678901234"} + ] + } + + # If setting SUPER_SECRET in the environment doesn't happen, then test fails + # due to fwup getting a bad aes key. + {:ok, manager} = UpdateManager.start_link(fwup_config) + assert UpdateManager.apply_update(manager, update_payload) == {:updating, 0} + + assert_receive {:fwup, {:progress, 0}} + assert_receive {:fwup, {:progress, 100}} + assert_receive {:fwup, {:ok, 0, ""}} + end + end + + test "add fwup public key" do + config = default_config() + {:ok, manager} = start_supervised({UpdateManager, config}) + + assert config.fwup_public_keys == :sys.get_state(manager).fwup_config.fwup_public_keys + + UpdateManager.add_fwup_public_key(manager, "test") + keys = :sys.get_state(manager).fwup_config.fwup_public_keys + refute config.fwup_public_keys == keys + assert keys == ["test"] + end + + test "remove fwup public key" do + config = %{default_config() | fwup_public_keys: ["test"]} + {:ok, manager} = start_supervised({UpdateManager, config}) + + assert config.fwup_public_keys == :sys.get_state(manager).fwup_config.fwup_public_keys + + UpdateManager.remove_fwup_public_key(manager, "test") + keys = :sys.get_state(manager).fwup_config.fwup_public_keys + refute config.fwup_public_keys == keys + assert keys == [] + end + + defp default_config() do + test_pid = self() + fwup_fun = &send(test_pid, {:fwup, &1}) + update_available_fun = fn _ -> :apply end + + %FwupConfig{ + handle_fwup_message: fwup_fun, + update_available: update_available_fun + } + end +end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex new file mode 100644 index 0000000..d2a14c5 --- /dev/null +++ b/test/support/fixtures.ex @@ -0,0 +1,155 @@ +# Copy Paste of NervesHubCore.Support.Fwup +# As found https://github.com/nerves-hub/nerves_hub_web/blob/37290d5e21c1a082ca7dccfd07227cf296b4f45d/test/support/fwup.ex +defmodule Fwup.TestSupport.Fixtures do + @moduledoc """ + This module is intended to help with testing and development + by allowing for "easy" creation of firmware signing keys, and + signed/unsigned/corrupted firmware files. + + It is a thin wrapper around `fwup`, and it persists the files in + `System.tmp_dir()`. + + The files are given the names that are passed to the respective functions, so + make sure you pass unique names to avoid collisions if necessary. This module + takes little effort to avoid collisions on its own. + """ + + defmodule MetaParams do + @moduledoc false + defstruct product: "nerves-hub", + description: "D", + version: "1.0.0", + platform: "platform", + architecture: "x86_64", + author: "me" + end + + @doc """ + Generate a public/private key pair for firmware signing. The `key_name` + argument can be used to lookup the public key via `get_public_key/1` or to + specify the private key to be used for signing a firmware image via + `sign_firmware/3` and `create_signed_firmware/4` + """ + @spec gen_key_pair(String.t()) :: :ok + def(gen_key_pair(key_name)) do + key_path_no_extension = Path.join([System.tmp_dir(), key_name]) + + for ext <- ~w(.priv .pub) do + _ = File.rm(key_path_no_extension <> ext) + end + + {_, 0} = System.cmd("fwup", ["-g", "-o", key_path_no_extension], stderr_to_stdout: true) + :ok + end + + @doc """ + Get a public key which has been generated via `gen_key_pair/1`. + """ + @spec get_public_key(String.t()) :: String.t() + def get_public_key(key_name) do + File.read!(Path.join([System.tmp_dir(), key_name <> ".pub"])) + end + + @doc """ + Create an unsigned firmware image, and return the path to that image. + """ + @spec create_firmware(String.t(), map()) :: {:ok, String.t()} + def create_firmware(firmware_name, meta_params \\ %{}) do + conf_path = make_conf(struct(MetaParams, meta_params)) + out_path = Path.join([System.tmp_dir(), firmware_name <> ".fw"]) + _ = File.rm(out_path) + + {_, 0} = + System.cmd("fwup", [ + "-c", + "-f", + conf_path, + "-o", + out_path + ]) + + {:ok, out_path} + end + + @doc """ + Sign a firmware image, and return the path to that image. The `firmware_name` + argument must match the name of a firmware created with `create_firmware/2`. + """ + @spec sign_firmware(String.t(), String.t(), String.t()) :: {:ok, String.t()} + def sign_firmware(key_name, firmware_name, output_name) do + dir = System.tmp_dir() + output_path = Path.join([dir, output_name <> ".fw"]) + + {_, 0} = + System.cmd( + "fwup", + [ + "-S", + "-s", + Path.join([dir, key_name <> ".priv"]), + "-i", + Path.join([dir, firmware_name <> ".fw"]), + "-o", + output_path + ], + stderr_to_stdout: true + ) + + {:ok, output_path} + end + + @doc """ + Create a signed firmware image, and return the path to that image. + """ + @spec create_signed_firmware(String.t(), String.t(), String.t(), map()) :: {:ok, String.t()} + def create_signed_firmware(key_name, firmware_name, output_name, meta_params \\ %{}) do + {:ok, _} = create_firmware(firmware_name, meta_params) + sign_firmware(key_name, firmware_name, output_name) + end + + @doc """ + Corrupt an existing firmware image. + """ + @spec corrupt_firmware_file(String.t(), String.t()) :: {:ok, String.t()} + def corrupt_firmware_file(input_path, output_name \\ "corrupt") do + output_path = Path.join([System.tmp_dir(), output_name <> ".fw"]) + :ok = File.cp!(input_path, output_path) + + {_, 0} = + System.cmd("dd", ["if=/dev/urandom", "of=" <> output_path, "bs=512", "count=1"], + stderr_to_stdout: true + ) + + {:ok, output_path} + end + + defp make_conf(%MetaParams{} = meta_params) do + path = Path.join([System.tmp_dir(), "#{Ecto.UUID.generate()}.conf"]) + File.write!(path, build_conf_contents(meta_params)) + + path + end + + defp build_conf_contents(%MetaParams{} = meta_params) do + """ + meta-product = "#{meta_params.product}" + meta-description = "#{meta_params.description} " + meta-version = "#{meta_params.version}" + meta-platform = "#{meta_params.platform}" + meta-architecture = "#{meta_params.architecture}" + meta-author = "#{meta_params.author}" + + file-resource hello.txt { + contents = "Hello, world!" + } + + task upgrade { + on-resource hello.txt { raw_write(0) } + } + + task secret_upgrade { + on-resource hello.txt { raw_write(0, "cipher=aes-cbc-plain", "secret=\\${SUPER_SECRET}") } + } + """ + end +end diff --git a/test/support/fwup_stream_plug.ex b/test/support/fwup_stream_plug.ex new file mode 100644 index 0000000..36ecd90 --- /dev/null +++ b/test/support/fwup_stream_plug.ex @@ -0,0 +1,20 @@ +defmodule NervesHubLink.Support.FWUPStreamPlug do + @moduledoc false + + @behaviour Plug + + import Plug.Conn + + alias Fwup.TestSupport.Fixtures + + @impl Plug + def init(options), do: options + + @impl Plug + def call(conn, _opts) do + {:ok, path} = Fixtures.create_firmware("test") + + conn + |> send_file(200, path) + end +end diff --git a/test/support/http_error_plug.ex b/test/support/http_error_plug.ex new file mode 100644 index 0000000..b479a6c --- /dev/null +++ b/test/support/http_error_plug.ex @@ -0,0 +1,16 @@ +defmodule NervesHubLink.Support.HTTPErrorPlug do + @moduledoc false + + @behaviour Plug + + import Plug.Conn + + @impl Plug + def init(options), do: options + + @impl Plug + def call(conn, _opts) do + conn + |> send_resp(416, "Range Not Satisfiable") + end +end diff --git a/test/support/idle_timeout_plug.ex b/test/support/idle_timeout_plug.ex new file mode 100644 index 0000000..dc81fb9 --- /dev/null +++ b/test/support/idle_timeout_plug.ex @@ -0,0 +1,30 @@ +defmodule NervesHubLink.Support.IdleTimeoutPlug do + @moduledoc false + + @behaviour Plug + + import Plug.Conn + + @impl Plug + def init(options), do: options + + @impl Plug + def call(conn, _opts) do + retry_number = find_x_retry_number_header(conn.req_headers) + + # gets incremented every retry, so doing it on 0 should only + # sleep on the first connect + # (something something stateless http....) + if retry_number == 0 do + Process.sleep(500) + end + + send_resp(conn, 200, "content") + end + + defp find_x_retry_number_header([{"x-retry-number", retry_number} | _]), + do: String.to_integer(retry_number) + + defp find_x_retry_number_header([_ | rest]), do: find_x_retry_number_header(rest) + defp find_x_retry_number_header([]), do: raise("Could not find x-retry-number header") +end diff --git a/test/support/range_request_plug.ex b/test/support/range_request_plug.ex new file mode 100644 index 0000000..bf1ed9f --- /dev/null +++ b/test/support/range_request_plug.ex @@ -0,0 +1,54 @@ +defmodule NervesHubLink.Support.RangeRequestPlug do + @moduledoc """ + Sends chunked response according to the value of the range-request header + """ + + @behaviour Plug + + import Plug.Conn + + @impl Plug + def init(options), do: options + + @impl Plug + def call(%Plug.Conn{} = conn, _opts) do + {start, finish} = fetch_range_header(conn.req_headers) + payload = "hello, world" + resp = fetch_range(payload, start, finish) + + conn = + conn + |> put_resp_header("accept-ranges", "bytes") + |> put_resp_header("content-length", to_string(byte_size(payload))) + |> put_resp_header( + "content-range", + "bytes #{start}-#{finish}/#{to_string(byte_size(payload))}" + ) + |> send_chunked(206) + + {:ok, conn} = chunk(conn, resp) + halt(conn) + end + + defp fetch_range(payload, start, finish) do + {_, tail} = String.split_at(payload, start) + fetch_range_until(tail, <<>>, start, finish) + end + + defp fetch_range_until(_, acc, finish, finish) do + acc + end + + defp fetch_range_until(<>, acc, i, finish) do + fetch_range_until(rest, acc <> c, i + 1, finish) + end + + defp fetch_range_header([]), do: {0, 1} + + defp fetch_range_header([{"range", "bytes=" <> range} | _rest]) do + [start, finish] = String.split(range, "-", parts: 2) + {String.to_integer(start), String.to_integer(finish)} + end + + defp fetch_range_header([_ | rest]), do: fetch_range_header(rest) +end diff --git a/test/support/redirect_plug.ex b/test/support/redirect_plug.ex new file mode 100644 index 0000000..0a9b108 --- /dev/null +++ b/test/support/redirect_plug.ex @@ -0,0 +1,25 @@ +defmodule NervesHubLink.Support.RedirectPlug do + @moduledoc false + + @behaviour Plug + + import Plug.Conn + + @impl Plug + def init(options), do: options + + @impl Plug + def call(%Plug.Conn{request_path: "/redirect"} = conn, _opts) do + redirect = "http://localhost:5001/redirected" + + conn + |> put_resp_header("accept-ranges", "bytes") + |> put_resp_header("location", redirect) + |> send_resp(302, redirect) + end + + def call(%Plug.Conn{request_path: "/redirected"} = conn, _opts) do + conn + |> send_resp(200, "redirected") + end +end diff --git a/test/support/uuid.ex b/test/support/uuid.ex new file mode 100644 index 0000000..2039be6 --- /dev/null +++ b/test/support/uuid.ex @@ -0,0 +1,62 @@ +# https://github.com/elixir-ecto/ecto/blob/v2.2.11/lib/ecto/uuid.ex +defmodule Ecto.UUID do + @moduledoc """ + An Ecto type for UUIDs strings. + """ + + @doc """ + Generates a version 4 (random) UUID. + """ + @spec generate() :: <<_::288>> + def generate() do + {:ok, uuid} = encode(bingenerate()) + uuid + end + + @doc """ + Generates a version 4 (random) UUID in the binary format. + """ + @spec bingenerate() :: <<_::128>> + def bingenerate() do + <> = :crypto.strong_rand_bytes(16) + <> + end + + # Callback invoked by autogenerate fields. + @doc false + @spec autogenerate() :: <<_::288>> + def autogenerate(), do: generate() + + defp encode( + <> + ) do + <> + catch + :error -> :error + else + encoded -> {:ok, encoded} + end + + @compile {:inline, e: 1} + + defp e(0), do: ?0 + defp e(1), do: ?1 + defp e(2), do: ?2 + defp e(3), do: ?3 + defp e(4), do: ?4 + defp e(5), do: ?5 + defp e(6), do: ?6 + defp e(7), do: ?7 + defp e(8), do: ?8 + defp e(9), do: ?9 + defp e(10), do: ?a + defp e(11), do: ?b + defp e(12), do: ?c + defp e(13), do: ?d + defp e(14), do: ?e + defp e(15), do: ?f +end diff --git a/test/support/x_retry_number_plug.ex b/test/support/x_retry_number_plug.ex new file mode 100644 index 0000000..2f56b89 --- /dev/null +++ b/test/support/x_retry_number_plug.ex @@ -0,0 +1,38 @@ +defmodule NervesHubLink.Support.XRetryNumberPlug do + @moduledoc """ + Plug sends data in chunks, halting halfway thru to be resumed by a client + + the payload sent is the value of the http header `X-Retry-Number` copied + 2048 times. + """ + + @behaviour Plug + + import Plug.Conn + + @impl Plug + def init(options), do: options + + @impl Plug + def call(conn, _opts) do + retry_number = find_x_retry_number_header(conn.req_headers) + + conn + |> put_resp_header("accept-ranges", "bytes") + |> put_resp_header("content-length", "4096") + |> send_chunked(200) + |> do_stream(retry_number) + end + + defp do_stream(conn, retry_number) do + {:ok, conn} = chunk(conn, :binary.copy(<>, 2048)) + halt(conn) + end + + # i have no idea why get_req_header/2 doesn't work here. + defp find_x_retry_number_header([{"x-retry-number", retry_number} | _]), + do: String.to_integer(retry_number) + + defp find_x_retry_number_header([_ | rest]), do: find_x_retry_number_header(rest) + defp find_x_retry_number_header([]), do: raise("Could not find x-retry-number header") +end