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.compile
COPY . /app
COPY . /app/
CMD mix run --no-halt

View file

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

View file

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

View file

@ -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(%{})

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

View file

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

View file

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

View file

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