Complete coverage of V1 and V2 APIs.

This commit is contained in:
James Harton 2019-10-20 07:24:11 +08:00
parent 833cf1745f
commit 371bb6c012
9 changed files with 202 additions and 53 deletions

View file

@ -53,5 +53,5 @@ COPY mix.exs mix.lock /app/
RUN mix deps.get RUN mix deps.get
RUN mix deps.compile RUN mix deps.compile
COPY . /app COPY . /app/
CMD mix run --no-halt CMD mix run --no-halt

View file

@ -4,9 +4,7 @@ defmodule BalenaDevice.Application do
use Application use Application
def start(_type, _args) do def start(_type, _args) do
children = [ children = []
BalenaDevice.LocalLogStream
]
opts = [strategy: :one_for_one, name: BalenaDevice.Supervisor] opts = [strategy: :one_for_one, name: BalenaDevice.Supervisor]
Supervisor.start_link(children, opts) Supervisor.start_link(children, opts)

View file

@ -1,3 +1,4 @@
defmodule BalenaDevice.Error do defmodule BalenaDevice.Error do
defexception ~w[message]a defexception ~w[message]a
@moduledoc false
end end

View file

@ -2,10 +2,16 @@ defmodule BalenaDevice.HTTP do
use HTTPoison.Base use HTTPoison.Base
alias BalenaDevice.Error alias BalenaDevice.Error
@moduledoc """
Uses `HTTPoison.Base` to construct requests for the on-device supervisor.
"""
@doc false
def process_request_url(url) do def process_request_url(url) do
"#{supervisor_address()}#{url}?apikey=#{supervisor_api_key()}" "#{supervisor_address()}#{url}?apikey=#{supervisor_api_key()}"
end end
@doc false
def process_request_headers(headers) do def process_request_headers(headers) do
headers headers
|> Enum.into(%{}) |> Enum.into(%{})

View file

@ -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

View file

@ -3,68 +3,39 @@ defmodule BalenaDevice.LocalLogStream do
alias BalenaDevice.HTTP alias BalenaDevice.HTTP
@moduledoc """ @moduledoc """
Implements a `GenServer` that when there are subscribers connects to the A simple `GenServer` which handles streaming local device logs from the
supervisor API and retrieves logs and sending them to the subscribers. 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 @impl true
def init(_), do: {:ok, %{subscribers: [], connection: nil}, :hibernate} def init(options) do
receiver = options |> Keyword.get(:receiver)
@impl true {:ok, conn} =
def handle_cast({:subscribe, pid}, %{subscribers: subscribers, connection: nil} = state) do HTTP.get("/v2/local/logs", [],
Process.monitor(pid) stream_to: self(),
{:ok, connection} = start_connection() timeout: :infinity,
{:noreply, %{state | subscribers: [pid | subscribers], connection: connection}} recv_timeout: :infinity
end )
def handle_cast({:subscribe, pid}, %{subscribers: subscribers} = state) do state = %{conn: conn, receiver: receiver}
Process.monitor(pid) {:ok, state}
{:noreply, %{state | subscribers: [pid | subscribers]}}
end end
@impl true @impl true
def handle_info(%HTTPoison.AsyncStatus{}, state), do: {:noreply, state} def handle_info(%HTTPoison.AsyncStatus{}, state), do: {:noreply, state}
def handle_info(%HTTPoison.AsyncHeaders{}, state), do: {:noreply, state} def handle_info(%HTTPoison.AsyncHeaders{}, state), do: {:noreply, state}
def handle_info(%HTTPoison.AsyncChunk{chunk: chunk}, %{subscribers: subscribers} = state) do def handle_info(%HTTPoison.AsyncChunk{chunk: chunk}, %{receiver: receiver} = state) do
chunk = Jason.decode!(chunk) send(receiver, {:local_log, chunk})
Enum.each(subscribers, &send(&1, chunk))
{:noreply, state} {:noreply, state}
end end
def handle_info(%HTTPoison.Error{}, %{subscribers: []} = state) do def handle_info(%HTTPoison.Error{reason: reason}, state), do: {:stop, reason, state}
{:noreply, %{state | connection: nil}}
end
def handle_info(%HTTPoison.Error{}, %{subscribers: subscribers} = state) do def handle_info(%HTTPoison.AsyncEnd{}, state), do: {:stop, :normal, state}
{: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
)
end end

View file

@ -1,4 +1,7 @@
defmodule BalenaDevice.Utils do defmodule BalenaDevice.Utils do
@moduledoc false
@doc false
def underscore_map_keys(value) when is_list(value) do def underscore_map_keys(value) when is_list(value) do
value value
|> Enum.map(&underscore_map_keys(&1)) |> Enum.map(&underscore_map_keys(&1))

View file

@ -10,6 +10,11 @@ defmodule BalenaDevice.V1 do
@doc """ @doc """
Request a device identification by blinking the device's LED for 15 seconds. Request a device identification by blinking the device's LED for 15 seconds.
### Example
iex> V1.blink()
:ok
""" """
@spec blink() :: :ok | {:error, term} @spec blink() :: :ok | {:error, term}
def blink do def blink do
@ -27,6 +32,11 @@ defmodule BalenaDevice.V1 do
@doc """ @doc """
Trigger an update check on the supervisor. Trigger an update check on the supervisor.
## Example
iex> V1.update()
:ok
""" """
@spec update() :: :ok | {:error, term} @spec update() :: :ok | {:error, term}
def update, do: update(false) def update, do: update(false)
@ -34,6 +44,11 @@ defmodule BalenaDevice.V1 do
@doc """ @doc """
Trigger an update check on the supervisor. Optionally, forces an update when Trigger an update check on the supervisor. Optionally, forces an update when
updates are locked. updates are locked.
## Example
iex> V1.update(true)
:ok
""" """
@spec update(boolean) :: :ok | {:error, term} @spec update(boolean) :: :ok | {:error, term}
def update(force) do def update(force) do
@ -52,6 +67,11 @@ defmodule BalenaDevice.V1 do
@doc """ @doc """
Reboots the device. This will first try to stop applications, and fail if Reboots the device. This will first try to stop applications, and fail if
there is an update lock. there is an update lock.
## Example
iex> V1.reboot()
:ok
""" """
@spec reboot() :: :ok | {:error, term} @spec reboot() :: :ok | {:error, term}
def reboot, do: reboot(false) 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 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 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). 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} @spec reboot(boolean) :: :ok | {:error, term}
def reboot(force) do def reboot(force) do
@ -78,6 +103,11 @@ defmodule BalenaDevice.V1 do
@doc """ @doc """
*Dangerous*. Shuts down the device. This will first try to stop applications, *Dangerous*. Shuts down the device. This will first try to stop applications,
and fail if there is an update lock. and fail if there is an update lock.
## Example
iex> V1.shutdown()
:ok
""" """
@spec shutdown() :: :ok | {:error, term} @spec shutdown() :: :ok | {:error, term}
def shutdown, do: shutdown(false) 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 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 overrides the lock when true (and the lock can also be overridden from the
dashboard). dashboard).
## Example
iex> V1.shutdown(true)
:ok
""" """
@spec shutdown(boolean) :: :ok | {:error, term} @spec shutdown(boolean) :: :ok | {:error, term}
def shutdown(force) do def shutdown(force) do
@ -104,6 +139,11 @@ defmodule BalenaDevice.V1 do
@doc """ @doc """
Clears the user application's /data folder. Clears the user application's /data folder.
## Example
iex> V1.purge(1)
:ok
""" """
@spec purge(BalenaDevice.app_id()) :: :ok | {:error, term} @spec purge(BalenaDevice.app_id()) :: :ok | {:error, term}
def purge(app_id) when is_app_id(app_id) do def purge(app_id) when is_app_id(app_id) do
@ -121,6 +161,11 @@ defmodule BalenaDevice.V1 do
@doc """ @doc """
Restarts a user application container Restarts a user application container
## Example
iex> V1.restart(1)
:ok
""" """
@spec restart(BalenaDevice.app_id()) :: :ok | {:error, term} @spec restart(BalenaDevice.app_id()) :: :ok | {:error, term}
def restart(app_id) when is_app_id(app_id) do 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. 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 Responds with the new API key, but *the application will be restarted on the
next update cycle* to update the API key environment variable. 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} @spec regenerate_api_key() :: {:ok, String.t()} | {:error, term}
def regenerate_api_key do 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 Other attributes may be added in the future, and some may be missing or null
if they haven't been set yet. 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} @spec state() :: {:ok, map} | {:error, term}
def state do def state do

View file

@ -8,6 +8,16 @@ defmodule BalenaDevice.V2 do
documentation](https://www.balena.io/docs/reference/supervisor/supervisor-api). 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 """ @doc """
Returns a map of applications and their state. Returns a map of applications and their state.
""" """
@ -376,7 +386,7 @@ defmodule BalenaDevice.V2 do
""" """
@spec local_log_stream() :: {:ok, pid} | {:error, term} @spec local_log_stream() :: {:ok, pid} | {:error, term}
def local_log_stream, def local_log_stream,
do: GenServer.cast(BalenaDevice.LocalLogStream, {:subscribe, self()}) do: BalenaDevice.LocalLogStream.start_link(receiver: self())
@doc """ @doc """
Get the last returned device name from the balena API. Note that this differs Get the last returned device name from the balena API. Note that this differs
@ -448,4 +458,51 @@ defmodule BalenaDevice.V2 do
{:error, reason} {:error, reason}
end end
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=<count>`.
* `unit: String.t`
Show journal logs from unit only, equivalent to `journalctl --unit=<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 end