Initial version of the on-device API client.

This commit is contained in:
James Harton 2019-10-19 23:11:28 +08:00
commit 833cf1745f
17 changed files with 1140 additions and 0 deletions

1
.dockerignore Normal file
View file

@ -0,0 +1 @@
_build/

4
.formatter.exs Normal file
View file

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
balena_device-*.tar

57
Dockerfile.template Normal file
View file

@ -0,0 +1,57 @@
FROM balenalib/%%BALENA_MACHINE_NAME%%-debian:buster as build
ENV ERLANG_VERSION 22.1
ENV ERLANG_HASH 7b26f64eb6c712968d8477759fc716d64701d41f6325e8a4d0dd9c31de77284a
ENV ELIXIR_VERSION 1.9.1
ENV ELIXIR_HASH 94daa716abbd4493405fb2032514195077ac7bc73dc2999922f13c7d8ea58777
RUN set -x \
&& apt-key adv --keyserver keyserver.ubuntu.com --recv-key 8B48AD6246925553 \
&& apt-key adv --keyserver keyserver.ubuntu.com --recv-key 7638D0442B90D010 \
&& apt-get -q update \
&& apt-get install -y --no-install-recommends autoconf build-essential ca-certificates curl python dpkg-dev g++ gcc git-core libc6-dev libncurses-dev libsctp-dev libssl-dev make mercurial python-dev python-pip unixodbc-dev wget \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN set -x \
&& ERLANG_DOWNLOAD_URL="https://github.com/erlang/otp/archive/OTP-${ERLANG_VERSION}.tar.gz" \
&& ERLANG_ARCHIVE="OTP-$ERLANG_VERSION.tar.gz" \
&& curl -fSL -o $ERLANG_ARCHIVE "$ERLANG_DOWNLOAD_URL" \
&& echo "$ERLANG_HASH $ERLANG_ARCHIVE" | sha256sum -c - \
&& ERL_TOP=/erlang-src \
&& mkdir -vp $ERL_TOP \
&& tar -xzf $ERLANG_ARCHIVE -C $ERL_TOP --strip-components=1 \
&& rm $ERLANG_ARCHIVE \
&& ( \
cd $ERL_TOP \
&& ./otp_build autoconf \
&& gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)" \
&& ./configure --build="$gnuArch" \
&& make \
&& make install \
)
RUN set -x \
&& ELIXIR_DOWNLOAD_URL="https://github.com/elixir-lang/elixir/archive/v${ELIXIR_VERSION}.tar.gz" \
&& curl -fSL -o elixir-src.tar.gz $ELIXIR_DOWNLOAD_URL \
&& echo "$ELIXIR_HASH elixir-src.tar.gz" | sha256sum -c - \
&& mkdir /elixir-src \
&& tar -xzC /elixir-src --strip-components=1 -f elixir-src.tar.gz \
&& rm elixir-src.tar.gz \
&& (cd /elixir-src && make install) \
&& rm -rf /elixir-src
RUN mix local.hex --force
RUN mix local.rebar --force
ENV ERL_CRASH_DUMP_BYTES=0
RUN mkdir /app
WORKDIR /app
COPY mix.exs mix.lock /app/
RUN mix deps.get
RUN mix deps.compile
COPY . /app
CMD mix run --no-halt

21
README.md Normal file
View file

@ -0,0 +1,21 @@
# BalenaDevice
**TODO: Add description**
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `balena_device` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:balena_device, "~> 0.1.0"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at [https://hexdocs.pm/balena_device](https://hexdocs.pm/balena_device).

29
lib/balena_device.ex Normal file
View file

@ -0,0 +1,29 @@
defmodule BalenaDevice do
alias BalenaDevice.HTTP
alias HTTPoison.Response
@moduledoc """
Wraps the Balena DBUS and Supervisor HTTP APIs to allow Elixir apps running on
the Balena platform to manipulate their environment.
"""
# An application ID.
@type app_id :: non_neg_integer
@doc """
Ping the device supervisor to ensure that it is alive and well.
"""
@spec ping() :: :ok | {:error, term}
def ping do
case HTTP.get("/ping") do
{:ok, %Response{body: "OK", status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
end

View file

@ -0,0 +1,14 @@
defmodule BalenaDevice.Application do
@moduledoc false
use Application
def start(_type, _args) do
children = [
BalenaDevice.LocalLogStream
]
opts = [strategy: :one_for_one, name: BalenaDevice.Supervisor]
Supervisor.start_link(children, opts)
end
end

View file

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

28
lib/balena_device/http.ex Normal file
View file

@ -0,0 +1,28 @@
defmodule BalenaDevice.HTTP do
use HTTPoison.Base
alias BalenaDevice.Error
def process_request_url(url) do
"#{supervisor_address()}#{url}?apikey=#{supervisor_api_key()}"
end
def process_request_headers(headers) do
headers
|> Enum.into(%{})
|> Map.put_new("content-type", "application/json")
|> Enum.into([])
end
defp supervisor_address, do: get_env_var("BALENA_SUPERVISOR_ADDRESS")
defp supervisor_api_key, do: get_env_var("BALENA_SUPERVISOR_API_KEY")
defp get_env_var(variable) do
case System.get_env(variable) do
var when is_binary(var) ->
var
_ ->
raise Error, message: "No `#{variable}` environment variable set"
end
end
end

View file

@ -0,0 +1,70 @@
defmodule BalenaDevice.LocalLogStream do
use GenServer
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.
"""
def start_link(_), do: GenServer.start_link(__MODULE__, [], hibernate_after: 5_000)
@impl true
def init(_), do: {:ok, %{subscribers: [], connection: nil}, :hibernate}
@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
def handle_cast({:subscribe, pid}, %{subscribers: subscribers} = state) do
Process.monitor(pid)
{:noreply, %{state | subscribers: [pid | subscribers]}}
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))
{:noreply, state}
end
def handle_info(%HTTPoison.Error{}, %{subscribers: []} = state) do
{:noreply, %{state | connection: nil}}
end
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
)
end

View file

@ -0,0 +1,18 @@
defmodule BalenaDevice.Utils do
def underscore_map_keys(value) when is_list(value) do
value
|> Enum.map(&underscore_map_keys(&1))
end
def underscore_map_keys(map) when is_map(map) do
map
|> Enum.reduce(%{}, fn
{key, value}, acc ->
Map.put(acc, Macro.underscore(key), underscore_map_keys(value))
end)
end
def underscore_map_keys(value), do: value
defguard is_app_id(app_id) when is_integer(app_id) and app_id > 0
end

372
lib/balena_device/v1.ex Normal file
View file

@ -0,0 +1,372 @@
defmodule BalenaDevice.V1 do
alias BalenaDevice.HTTP
import BalenaDevice.Utils
alias HTTPoison.Response
@moduledoc """
This module contains the version one API endpoints as described in [the API
documentation](https://www.balena.io/docs/reference/supervisor/supervisor-api).
"""
@doc """
Request a device identification by blinking the device's LED for 15 seconds.
"""
@spec blink() :: :ok | {:error, term}
def blink do
case HTTP.post("/v1/blink", "") do
{:ok, %Response{body: "OK", status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Trigger an update check on the supervisor.
"""
@spec update() :: :ok | {:error, term}
def update, do: update(false)
@doc """
Trigger an update check on the supervisor. Optionally, forces an update when
updates are locked.
"""
@spec update(boolean) :: :ok | {:error, term}
def update(force) do
case HTTP.post("/v1/update", Jason.encode!(%{force: force})) do
{:ok, %Response{status_code: 204}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Reboots the device. This will first try to stop applications, and fail if
there is an update lock.
"""
@spec reboot() :: :ok | {:error, term}
def reboot, do: reboot(false)
@doc """
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).
"""
@spec reboot(boolean) :: :ok | {:error, term}
def reboot(force) do
case HTTP.post("/v1/reboot", Jason.encode!(%{force: force})) do
{:ok, %Response{status_code: 202}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
*Dangerous*. Shuts down the device. This will first try to stop applications,
and fail if there is an update lock.
"""
@spec shutdown() :: :ok | {:error, term}
def shutdown, do: shutdown(false)
@doc """
*Dangerous*. Shuts down 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).
"""
@spec shutdown(boolean) :: :ok | {:error, term}
def shutdown(force) do
case HTTP.post("/v1/shutdown", Jason.encode!(%{force: force})) do
{:ok, %Response{status_code: 202}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Clears the user application's /data folder.
"""
@spec purge(BalenaDevice.app_id()) :: :ok | {:error, term}
def purge(app_id) when is_app_id(app_id) do
case HTTP.post("/v1/purge", Jason.encode!(%{"appId" => app_id})) do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Restarts a user application container
"""
@spec restart(BalenaDevice.app_id()) :: :ok | {:error, term}
def restart(app_id) when is_app_id(app_id) do
case HTTP.post("/v1/restart", Jason.encode!(%{"appId" => app_id})) do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
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.
"""
@spec regenerate_api_key() :: {:ok, String.t()} | {:error, term}
def regenerate_api_key do
case HTTP.post("/v1/regenerate-api-key", "") do
{:ok, %Response{status_code: 200, body: key}} ->
{:ok, key}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Returns the current device state, as reported to the balenaCloud API and with
some extra fields added to allow control over pending/locked updates.
The state is a map that contains some or all of the following:
* `api_port`: Port on which the supervisor is listening.
* `commit`: Hash of the current commit of the application that is running.
* `ip_address`: Space-separated list of IP addresses of the device.
* `status`: Status of the device regarding the app, as a string, i.e.
"Stopping", "Starting", "Downloading", "Installing", "Idle".
* `download_progress`: Amount of the application image that has been
downloaded, expressed as a percentage. If the update has already been
downloaded, this will be null.
* `os_version`: Version of the host OS running on the device.
* `supervisor_version`: Version of the supervisor running on the device.
* `update_pending`: This one is not reported to the balenaCloud API. It's a
boolean that will be true if the supervisor has detected there is a
pending update.
* `update_downloaded`: Not reported to the balenaCloud API either. Boolean
that will be true if a pending update has already been downloaded.
* `update_failed`: Not reported to the balenaCloud API. Boolean that will be
true if the supervisor has tried to apply a pending update but failed
(i.e. if the app was locked, there was a network failure or anything else
went wrong).
Other attributes may be added in the future, and some may be missing or null
if they haven't been set yet.
"""
@spec state() :: {:ok, map} | {:error, term}
def state do
case HTTP.get("/v1/device") do
{:ok, %Response{status_code: 200, body: body}} ->
state =
body
|> Jason.decode!()
{:ok, state}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Temporarily stops a user application container. A reboot or supervisor restart
will cause the container to start again. The container is not removed with
this endpoint.
This is only supported on single-container devices, and will fail on devices
running multiple containers.
Returns an ok tuple with the ID of the stopped container.
"""
@spec stop_app(BalenaDevice.app_id()) :: {:ok, String.t()} | {:error, term}
def stop_app(app_id) when is_app_id(app_id), do: stop_app(app_id, false)
@doc """
Temporarily stops a user application container. A reboot or supervisor restart
will cause the container to start again. The container is not removed with
this endpoint.
This is only supported on single-container devices, and will fail on devices
running multiple containers.
Returns an ok tuple with the ID of the stopped container.
"""
@spec stop_app(BalenaDevice.app_id(), boolean) :: {:ok, String.t()} | {:error, term}
def stop_app(app_id, force) when is_app_id(app_id) when is_boolean(force) do
case HTTP.post("/v1/apps/#{app_id}/stop", Jason.encode!(%{force: force})) do
{:ok, %Response{status_code: 200, body: body}} ->
container_id =
body
|> Jason.decode!()
|> Map.get("containerId")
{:ok, container_id}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Starts a user application container, usually after it has been stopped with
`stop_app/2`.
This is only supported on single-container devices, and will return `:error` on
devices running multiple containers.
When successful, responds with :ok and the id of the started container.
"""
@spec start_app(BalenaDevice.app_id()) :: {:ok, String.t()} | {:error, term}
def start_app(app_id) when is_app_id(app_id) do
case HTTP.post("/v1/apps/#{app_id}/start", "") do
{:ok, %Response{status_code: 200, body: body}} ->
container_id =
body
|> Jason.decode!()
|> Map.get("containerId")
{:ok, container_id}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Returns the application running on the device. The app is a map that
contains the following:
* `app_id`: The id of the app as per the balenaCloud API.
* `commit`: Application commit that is running.
* `image_id`: The docker image of the current application build.
* `container_id`: ID of the docker container of the running app.
* `env`: A key-value store of the app's environment variables.
This is only supported on single-container devices, and will return an error on
devices running multiple containers.
"""
@spec app_state(BalenaDevice.app_id()) :: {:ok, map} | {:error, term}
def app_state(app_id) when is_app_id(app_id) do
case HTTP.get("/v1/apps/#{app_id}") do
{:ok, %Response{status_code: 200, body: body}} ->
state =
body
|> Jason.decode!()
|> underscore_map_keys()
{:ok, state}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Used internally to check whether the supervisor is running correctly,
according to some heuristics that help determine whether the internal
components, application updates and reporting to the balenaCloud API are
functioning.
Responds with an `true` response if the supervisor is healthy, or `false` if
something is not working correctly.
"""
@spec healthy? :: boolean
def healthy? do
case HTTP.get("/v1/healthy") do
{:ok, %Response{status_code: 200}} -> true
_ -> false
end
end
@doc """
This endpoint allows setting some configuration values for the host OS.
Currently it supports proxy and hostname configuration.
For proxy configuration, balenaOS 2.0.7 and higher provides a transparent
proxy redirector (redsocks) that makes all connections be routed to a SOCKS or
HTTP proxy. This endpoint allows user applications to modify these proxy
settings at runtime.
See [PATCH /v1/device/host-config](https://www.balena.io/docs/reference/supervisor/supervisor-api/#patch-v1devicehost-config).
"""
@spec host_config(map) :: :ok | {:error, term}
def host_config(config) do
case HTTP.patch("/v1/device/host-config", Jason.encode!(config)) do
{:ok, %Response{status_code: 200, body: "OK"}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
This endpoint allows reading some configuration values for the host OS,
previously set with `host_config/1`. Currently it supports proxy and hostname
configuration.
"""
@spec host_config :: {:ok, map} | {:error, term}
def host_config do
case HTTP.get("/v1/device/host-config") do
{:ok, %Response{body: body, status_code: 200}} ->
config =
body
|> Jason.decode!()
|> underscore_map_keys()
{:ok, config}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
end

451
lib/balena_device/v2.ex Normal file
View file

@ -0,0 +1,451 @@
defmodule BalenaDevice.V2 do
alias BalenaDevice.HTTP
import BalenaDevice.Utils
alias HTTPoison.Response
@moduledoc """
This module contains the version two API endpoints as described in [the API
documentation](https://www.balena.io/docs/reference/supervisor/supervisor-api).
"""
@doc """
Returns a map of applications and their state.
"""
@spec applications_state :: {:ok, [map]} | {:error, term}
def applications_state do
case HTTP.get("/v2/applications/state") do
{:ok, %Response{body: body, status_code: 200}} ->
state =
body
|> Jason.decode!()
|> underscore_map_keys()
{:ok, state}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Use this endpoint to get the state of a single application, given the app_id.
"""
@spec application_state(BalenaDevice.app_id()) :: {:ok, map} | {:error, term}
def application_state(app_id) when is_app_id(app_id) do
case HTTP.get("/v2/applications/#{app_id}/state") do
{:ok, %Response{status_code: 200, body: body}} ->
state =
body
|> Jason.decode!()
|> underscore_map_keys()
{:ok, state}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
This will return a list of images, containers, the overall download progress
and the status of the state engine.
"""
@spec status :: {:ok, map} | {:error, term}
def status do
case HTTP.get("/v2/state/status") do
{:ok, %Response{body: body, status_code: 200}} ->
status =
body
|> Jason.decode!()
|> underscore_map_keys()
{:ok, status}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Restart an application's service based on it's service name or image id.
"""
@spec restart_service(
BalenaDevice.app_id(),
[{:service_name, String.t()}] | [{:image_id, String.t()}]
) ::
:ok | {:error, term}
def restart_service(app_id, service_name: service_name) when is_app_id(app_id) do
case HTTP.post(
"/v2/applications/#{app_id}/restart-service",
Jason.encode!(%{"serviceName" => service_name})
) do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
def restart_service(app_id, image_id: image_id) when is_app_id(app_id) do
case HTTP.post(
"/v2/applications/#{app_id}/restart-service",
Jason.encode!(%{"imageId" => image_id})
) do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
def restart_service(_app_id, _),
do: {:error, "Must specify either a service name or an image id"}
@doc """
Stop an application's service based on it's service name or image id.
"""
@spec stop_service(
BalenaDevice.app_id(),
[{:service_name, String.t()}] | [{:image_id, String.t()}]
) ::
:ok | {:error, term}
def stop_service(app_id, service_name: service_name) when is_app_id(app_id) do
case HTTP.post(
"/v2/applications/#{app_id}/stop-service",
Jason.encode!(%{"serviceName" => service_name})
) do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
def stop_service(app_id, image_id: image_id) when is_app_id(app_id) do
case HTTP.post(
"/v2/applications/#{app_id}/stop-service",
Jason.encode!(%{"imageId" => image_id})
) do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
def stop_service(_app_id, _),
do: {:error, "Must specify either a service name or an image id"}
@doc """
Start an application's service based on it's service name or image id.
"""
@spec start_service(
BalenaDevice.app_id(),
[{:service_name, String.t()}] | [{:image_id, String.t()}]
) ::
:ok | {:error, term}
def start_service(app_id, service_name: service_name) when is_app_id(app_id) do
case HTTP.post(
"/v2/applications/#{app_id}/start-service",
Jason.encode!(%{"serviceName" => service_name})
) do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
def start_service(app_id, image_id: image_id) when is_app_id(app_id) do
case HTTP.post(
"/v2/applications/#{app_id}/start-service",
Jason.encode!(%{"imageId" => image_id})
) do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
def start_service(_app_id, _),
do: {:error, "Must specify either a service name or an image id"}
@doc """
Restart all services in an application.
"""
@spec restart_services(BalenaDevice.app_id()) :: :ok | {:error, term}
def restart_services(app_id) when is_app_id(app_id) do
case HTTP.post("/v2/applications/#{app_id}/restart", "") do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Use this endpoint to purge all user data for a given application id.
"""
@spec purge(BalenaDevice.app_id()) :: :ok | {:error, term}
def purge(app_id) when is_app_id(app_id) do
case HTTP.post("/v2/applications/#{app_id}/purge", "") do
{:ok, %Response{status_code: 200, body: "OK"}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
This endpoint returns the supervisor version currently running the device api.
"""
@spec supervisor_version() :: {:ok, String.t()} | {:error, term}
def supervisor_version do
case HTTP.get("/v2/version") do
{:ok, %Response{status_code: 200, body: body}} ->
version =
body
|> Jason.decode!()
|> Map.get("version")
{:ok, version}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Use this endpoint to match a service name to a container ID.
"""
@spec container_ids() :: {:ok, map} | {:error, term}
def container_ids() do
case HTTP.get("/v2/containerId") do
{:ok, %Response{status_code: 200, body: body}} ->
services =
body
|> Jason.decode!()
|> Map.get("services")
{:ok, services}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Use this endpoint to match a service name to a container ID.
"""
@spec container_id(String.t()) :: {:ok, String.t()} | {:error, term}
def container_id(service_name) when is_binary(service_name) do
case HTTP.get("/v2/containerId?serviceName=#{service_name}") do
{:ok, %Response{status_code: 200, body: body}} ->
container_id =
body
|> Jason.decode!()
|> Map.get("containerId")
{:ok, container_id}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Get the current target state. Note that if a local mode target state has not
been set then the apps section of the response will always be empty.
Only works on devices which are running in local mode.
"""
@spec local_target_state :: {:ok, map} | {:error, term}
def local_target_state do
case HTTP.get("/v2/local/target-state") do
{:ok, %Response{status_code: 200, body: body}} ->
state =
body
|> Jason.decode!()
|> Map.get("state")
{:ok, state}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Set the current target state.
"""
@spec local_target_state(map) :: :ok | {:error, term}
def local_target_state(config) do
case HTTP.post("/v2/local/target-state", Jason.encode!(config)) do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Get the architecture and device type of the device.
"""
@spec local_device_info :: {:ok, map} | {:error, term}
def local_device_info do
case HTTP.get("/v2/local/device-info") do
{:ok, %Response{status_code: 200, body: body}} ->
info =
body
|> Jason.decode!()
|> Map.get("info")
{:ok, info}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Stream local mode application logs from the device.
This function starts a process which will send logs to the
creating processes mailbox as they arrive.
"""
@spec local_log_stream() :: {:ok, pid} | {:error, term}
def local_log_stream,
do: GenServer.cast(BalenaDevice.LocalLogStream, {:subscribe, self()})
@doc """
Get the last returned device name from the balena API. Note that this differs
from the `BALENA_DEVICE_NAME_AT_INIT` environment variable provided to
containers, as this will not change throughout the runtime of the container,
but the endpoint will always return the latest known device name.
"""
@spec device_name() :: {:ok, String.t()} | {:error, term}
def device_name do
case HTTP.get("/v2/device/name") do
{:ok, %Response{status_code: 200, body: body}} ->
name =
body
|> Jason.decode!()
|> Map.get("deviceName")
{:ok, name}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Retrieve any device tags from the balena API. Note that this endpoint will not
work when the device does not have an available connection to the balena API.
"""
@spec device_tags() :: {:ok, [map]} | {:error, term}
def device_tags do
case HTTP.get("/v2/device/tags") do
{:ok, %Response{status_code: 200, body: body}} ->
tags =
body
|> Jason.decode!()
|> Map.get("tags")
{:ok, tags}
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Cleanup volumes with no references
Added in supervisor version v10.0.0
Starting with balena-supervisor v10.0.0, volumes which have no references are
no longer automatically removed as part of the standard update flow. To
cleanup up any orphaned volumes, use this supervisor endpoint.
"""
@spec cleanup_volumes() :: :ok | {:error, term}
def cleanup_volumes do
case HTTP.get("/v2/cleanup-volumess") do
{:ok, %Response{status_code: 200}} ->
:ok
{:ok, %Response{body: body, status_code: status}} ->
{:error, "Status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
end

30
mix.exs Normal file
View file

@ -0,0 +1,30 @@
defmodule BalenaDevice.MixProject do
use Mix.Project
def project do
[
app: :balena_device,
version: "0.1.0",
elixir: "~> 1.9",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger],
mod: {BalenaDevice.Application, []}
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:httpoison, "~> 1.6"},
{:jason, "~> 1.1"},
{:dbus, "~> 0.7"}
]
end
end

13
mix.lock Normal file
View file

@ -0,0 +1,13 @@
%{
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"dbus": {:hex, :dbus, "0.7.0", "71b7660523be6e222a8a4f9ddcbf54ad95638479bd17654975bb751aadbe81de", [:make, :rebar3], [], "hexpm"},
"hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"httpoison": {:hex, :httpoison, "1.6.1", "2ce5bf6e535cd0ab02e905ba8c276580bab80052c5c549f53ddea52d72e81f33", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
}

View file

@ -0,0 +1,4 @@
defmodule BalenaDeviceTest do
use ExUnit.Case
doctest BalenaDevice
end

1
test/test_helper.exs Normal file
View file

@ -0,0 +1 @@
ExUnit.start()