From 371bb6c012f6c423e7e89da381a2d8832b91ac15 Mon Sep 17 00:00:00 2001 From: James Harton Date: Sun, 20 Oct 2019 07:24:11 +0800 Subject: [PATCH] Complete coverage of V1 and V2 APIs. --- Dockerfile.template | 2 +- lib/balena_device/application.ex | 4 +- lib/balena_device/error.ex | 1 + lib/balena_device/http.ex | 6 +++ lib/balena_device/journald_log_stream.ex | 46 ++++++++++++++++ lib/balena_device/local_log_stream.ex | 67 +++++++----------------- lib/balena_device/utils.ex | 3 ++ lib/balena_device/v1.ex | 67 ++++++++++++++++++++++++ lib/balena_device/v2.ex | 59 ++++++++++++++++++++- 9 files changed, 202 insertions(+), 53 deletions(-) create mode 100644 lib/balena_device/journald_log_stream.ex diff --git a/Dockerfile.template b/Dockerfile.template index d868a81..d04e6d8 100644 --- a/Dockerfile.template +++ b/Dockerfile.template @@ -53,5 +53,5 @@ COPY mix.exs mix.lock /app/ RUN mix deps.get RUN mix deps.compile -COPY . /app +COPY . /app/ CMD mix run --no-halt diff --git a/lib/balena_device/application.ex b/lib/balena_device/application.ex index 104679f..b186e08 100644 --- a/lib/balena_device/application.ex +++ b/lib/balena_device/application.ex @@ -4,9 +4,7 @@ defmodule BalenaDevice.Application do use Application def start(_type, _args) do - children = [ - BalenaDevice.LocalLogStream - ] + children = [] opts = [strategy: :one_for_one, name: BalenaDevice.Supervisor] Supervisor.start_link(children, opts) diff --git a/lib/balena_device/error.ex b/lib/balena_device/error.ex index 184d7c2..54a339d 100644 --- a/lib/balena_device/error.ex +++ b/lib/balena_device/error.ex @@ -1,3 +1,4 @@ defmodule BalenaDevice.Error do defexception ~w[message]a + @moduledoc false end diff --git a/lib/balena_device/http.ex b/lib/balena_device/http.ex index fe0f38c..e467744 100644 --- a/lib/balena_device/http.ex +++ b/lib/balena_device/http.ex @@ -2,10 +2,16 @@ defmodule BalenaDevice.HTTP do use HTTPoison.Base alias BalenaDevice.Error + @moduledoc """ + Uses `HTTPoison.Base` to construct requests for the on-device supervisor. + """ + + @doc false def process_request_url(url) do "#{supervisor_address()}#{url}?apikey=#{supervisor_api_key()}" end + @doc false def process_request_headers(headers) do headers |> Enum.into(%{}) diff --git a/lib/balena_device/journald_log_stream.ex b/lib/balena_device/journald_log_stream.ex new file mode 100644 index 0000000..8690fee --- /dev/null +++ b/lib/balena_device/journald_log_stream.ex @@ -0,0 +1,46 @@ +defmodule BalenaDevice.JournaldLogStream do + use GenServer + alias BalenaDevice.HTTP + + @moduledoc """ + A simple `GenServer` which handles streaming journald logs from the + Supervisor. You should not call functions in this module directly - this + process is started by `BalenaDevice.V2.journald_logs/1`. + """ + + @doc false + def start_link(options), do: GenServer.start_link(__MODULE__, options) + + @impl true + def init(options) do + body = options |> Keyword.get(:body, "") + receiver = options |> Keyword.get(:receiver) + + {:ok, conn} = + HTTP.post("/v2/journal-logs", body, [], + stream_to: self(), + timeout: :infinity, + recv_timeout: :infinity + ) + + state = %{ + conn: conn, + receiver: receiver + } + + {:ok, state} + end + + @impl true + def handle_info(%HTTPoison.AsyncStatus{}, state), do: {:noreply, state} + def handle_info(%HTTPoison.AsyncHeaders{}, state), do: {:noreply, state} + + def handle_info(%HTTPoison.AsyncChunk{chunk: chunk}, %{receiver: receiver} = state) do + send(receiver, {:journald_log, chunk}) + {:noreply, state} + end + + def handle_info(%HTTPoison.Error{reason: reason}, state), do: {:stop, reason, state} + + def handle_info(%HTTPoison.AsyncEnd{}, state), do: {:stop, :normal, state} +end diff --git a/lib/balena_device/local_log_stream.ex b/lib/balena_device/local_log_stream.ex index 3394338..3f8516c 100644 --- a/lib/balena_device/local_log_stream.ex +++ b/lib/balena_device/local_log_stream.ex @@ -3,68 +3,39 @@ defmodule BalenaDevice.LocalLogStream do alias BalenaDevice.HTTP @moduledoc """ - Implements a `GenServer` that when there are subscribers connects to the - supervisor API and retrieves logs and sending them to the subscribers. + A simple `GenServer` which handles streaming local device logs from the + Supervisor. You should not call functions in this module directly - this + process is started by `BalenaDevice.V2.local_log_stream/0`. """ - def start_link(_), do: GenServer.start_link(__MODULE__, [], hibernate_after: 5_000) + @doc false + def start_link(options), do: GenServer.start_link(__MODULE__, options) @impl true - def init(_), do: {:ok, %{subscribers: [], connection: nil}, :hibernate} + def init(options) do + receiver = options |> Keyword.get(:receiver) - @impl true - def handle_cast({:subscribe, pid}, %{subscribers: subscribers, connection: nil} = state) do - Process.monitor(pid) - {:ok, connection} = start_connection() - {:noreply, %{state | subscribers: [pid | subscribers], connection: connection}} - end + {:ok, conn} = + HTTP.get("/v2/local/logs", [], + stream_to: self(), + timeout: :infinity, + recv_timeout: :infinity + ) - def handle_cast({:subscribe, pid}, %{subscribers: subscribers} = state) do - Process.monitor(pid) - {:noreply, %{state | subscribers: [pid | subscribers]}} + state = %{conn: conn, receiver: receiver} + {:ok, state} end @impl true def handle_info(%HTTPoison.AsyncStatus{}, state), do: {:noreply, state} def handle_info(%HTTPoison.AsyncHeaders{}, state), do: {:noreply, state} - def handle_info(%HTTPoison.AsyncChunk{chunk: chunk}, %{subscribers: subscribers} = state) do - chunk = Jason.decode!(chunk) - Enum.each(subscribers, &send(&1, chunk)) + def handle_info(%HTTPoison.AsyncChunk{chunk: chunk}, %{receiver: receiver} = state) do + send(receiver, {:local_log, chunk}) {:noreply, state} end - def handle_info(%HTTPoison.Error{}, %{subscribers: []} = state) do - {:noreply, %{state | connection: nil}} - end + def handle_info(%HTTPoison.Error{reason: reason}, state), do: {:stop, reason, state} - def handle_info(%HTTPoison.Error{}, %{subscribers: subscribers} = state) do - {:ok, connection} = start_connection() - {:noreply, %{state | connection: connection}} - end - - def handle_info(%HTTPoison.AsyncEnd{}, %{subscribers: []} = state) do - {:noreply, %{state | connection: nil}} - end - - def handle_info(%HTTPoison.AsyncEnd{}, %{subscribers: subscribers} = state) do - {:ok, connection} = start_connection() - {:noreply, %{state | connection: connection}} - end - - def handle_info( - {:DOWN, _ref, :process, pid, _reason}, - %{subscribers: subscribers, connection: connection} = state - ) do - subscribers = subscribers |> List.delete(pid) - {:noreply, %{state | subscribers: subscribers}} - end - - defp start_connection, - do: - HTTP.get("/v2/local/logs", [], - stream_to: self(), - timeout: :infinity, - recv_timeout: :infinity - ) + def handle_info(%HTTPoison.AsyncEnd{}, state), do: {:stop, :normal, state} end diff --git a/lib/balena_device/utils.ex b/lib/balena_device/utils.ex index 125540a..f5c98d7 100644 --- a/lib/balena_device/utils.ex +++ b/lib/balena_device/utils.ex @@ -1,4 +1,7 @@ defmodule BalenaDevice.Utils do + @moduledoc false + + @doc false def underscore_map_keys(value) when is_list(value) do value |> Enum.map(&underscore_map_keys(&1)) diff --git a/lib/balena_device/v1.ex b/lib/balena_device/v1.ex index cf28e94..942c620 100644 --- a/lib/balena_device/v1.ex +++ b/lib/balena_device/v1.ex @@ -10,6 +10,11 @@ defmodule BalenaDevice.V1 do @doc """ Request a device identification by blinking the device's LED for 15 seconds. + + ### Example + + iex> V1.blink() + :ok """ @spec blink() :: :ok | {:error, term} def blink do @@ -27,6 +32,11 @@ defmodule BalenaDevice.V1 do @doc """ Trigger an update check on the supervisor. + + ## Example + + iex> V1.update() + :ok """ @spec update() :: :ok | {:error, term} def update, do: update(false) @@ -34,6 +44,11 @@ defmodule BalenaDevice.V1 do @doc """ Trigger an update check on the supervisor. Optionally, forces an update when updates are locked. + + ## Example + + iex> V1.update(true) + :ok """ @spec update(boolean) :: :ok | {:error, term} def update(force) do @@ -52,6 +67,11 @@ defmodule BalenaDevice.V1 do @doc """ Reboots the device. This will first try to stop applications, and fail if there is an update lock. + + ## Example + + iex> V1.reboot() + :ok """ @spec reboot() :: :ok | {:error, term} def reboot, do: reboot(false) @@ -60,6 +80,11 @@ defmodule BalenaDevice.V1 do Reboots the device. This will first try to stop applications, and fail if there is an update lock. An optional "force" parameter in the body overrides the lock when true (and the lock can also be overridden from the dashboard). + + ## Example + + iex> V1.reboot(true) + :ok """ @spec reboot(boolean) :: :ok | {:error, term} def reboot(force) do @@ -78,6 +103,11 @@ defmodule BalenaDevice.V1 do @doc """ *Dangerous*. Shuts down the device. This will first try to stop applications, and fail if there is an update lock. + + ## Example + + iex> V1.shutdown() + :ok """ @spec shutdown() :: :ok | {:error, term} def shutdown, do: shutdown(false) @@ -87,6 +117,11 @@ defmodule BalenaDevice.V1 do and fail if there is an update lock. An optional "force" parameter in the body overrides the lock when true (and the lock can also be overridden from the dashboard). + + ## Example + + iex> V1.shutdown(true) + :ok """ @spec shutdown(boolean) :: :ok | {:error, term} def shutdown(force) do @@ -104,6 +139,11 @@ defmodule BalenaDevice.V1 do @doc """ Clears the user application's /data folder. + + ## Example + + iex> V1.purge(1) + :ok """ @spec purge(BalenaDevice.app_id()) :: :ok | {:error, term} def purge(app_id) when is_app_id(app_id) do @@ -121,6 +161,11 @@ defmodule BalenaDevice.V1 do @doc """ Restarts a user application container + + ## Example + + iex> V1.restart(1) + :ok """ @spec restart(BalenaDevice.app_id()) :: :ok | {:error, term} def restart(app_id) when is_app_id(app_id) do @@ -140,6 +185,11 @@ defmodule BalenaDevice.V1 do Invalidates the current `BALENA_SUPERVISOR_API_KEY` and generates a new one. Responds with the new API key, but *the application will be restarted on the next update cycle* to update the API key environment variable. + + ## Example + + iex> V1.regenerate_api_key() + {:ok, "d7bf0ab2665bb3ddb0da043727974daa46d6cfcb8c92fd068a852969576145"} """ @spec regenerate_api_key() :: {:ok, String.t()} | {:error, term} def regenerate_api_key do @@ -183,6 +233,23 @@ defmodule BalenaDevice.V1 do Other attributes may be added in the future, and some may be missing or null if they haven't been set yet. + + ## Example + + iex> V1.state() + {:ok, + %{ + "api_port" => 48484, + "commit" => "localrelease", + "download_progress" => nil, + "ip_address" => "192.168.56.101", + "os_version" => "balenaOS 2.44.0+rev1", + "status" => "Idle", + "supervisor_version" => "10.3.7", + "update_downloaded" => false, + "update_failed" => false, + "update_pending" => false + }} """ @spec state() :: {:ok, map} | {:error, term} def state do diff --git a/lib/balena_device/v2.ex b/lib/balena_device/v2.ex index cefc0de..01c815a 100644 --- a/lib/balena_device/v2.ex +++ b/lib/balena_device/v2.ex @@ -8,6 +8,16 @@ defmodule BalenaDevice.V2 do documentation](https://www.balena.io/docs/reference/supervisor/supervisor-api). """ + # Options which can be passed to `journald_logs/1`. + @type journald_options :: [journald_option] + # A valid option for `journald_log/1`. + @type journald_option :: + {:all, boolean} + | {:follow, boolean} + | {:count, non_neg_integer} + | {:unit, String.t()} + | {:format, String.t()} + @doc """ Returns a map of applications and their state. """ @@ -376,7 +386,7 @@ defmodule BalenaDevice.V2 do """ @spec local_log_stream() :: {:ok, pid} | {:error, term} def local_log_stream, - do: GenServer.cast(BalenaDevice.LocalLogStream, {:subscribe, self()}) + do: BalenaDevice.LocalLogStream.start_link(receiver: self()) @doc """ Get the last returned device name from the balena API. Note that this differs @@ -448,4 +458,51 @@ defmodule BalenaDevice.V2 do {:error, reason} end end + + @doc """ + journald logs + + Added in supervisor version v10.2.0 + + Retrieve a stream to the journald logs on device. This is equivalent to + running `journalctl --no-pager`. Options supported are: + + * `all: boolean` Show all fields in full, equivalent to `journalctl --all`. + * `follow: boolean` Continuously stream logs as they are generated, equivalent + to `journalctl --follow`. + * `count: integer` Show the most recent count events, equivalent to + `journalctl --line=`. + * `unit: String.t` + Show journal logs from unit only, equivalent to `journalctl --unit=`. + * `format: String.t` Added in supervisor version v10.3.0 The format which will + be streamed from journalctl, formats are described here: + https://www.freedesktop.org/software/systemd/man/journalctl.html#-o + """ + @spec journald_logs(journald_options) :: {:ok, String.t()} | {:ok, pid} | {:error, term} + def journald_logs(options \\ []) when is_list(options) do + options + |> Keyword.take(~w[all follow count unit format]a) + |> Enum.into(%{}) + |> do_journald_logs() + end + + defp do_journald_logs(%{follow: true} = options) do + body = options |> Jason.encode!() + BalenaDevice.JournaldLogStream.start_link(receiver: self(), body: body) + end + + defp do_journald_logs(options) do + body = options |> Jason.encode!() + + case HTTP.post("/v2/journal-logs", body) do + {:ok, %Response{status_code: 200, body: body}} -> + {:ok, body} + + {:ok, %Response{body: body, status_code: status}} -> + {:error, "Status #{status}: #{inspect(body)}"} + + {:error, reason} -> + {:error, reason} + end + end end