Skip to content

Commit

Permalink
Add a way to extend DeviceChannel functionality via Features
Browse files Browse the repository at this point in the history
Allows for specialized features on device to report data and
interactions safely outside the without affecting the
firmware update mechanism.

- Geo and Health adapted to the new mechanism.
- Global enable/disable via environment variable
- Additional enable/disable for specific Features
  along with config
  • Loading branch information
jjcarstens authored and lawik committed Oct 28, 2024
1 parent a46a73e commit 1db8da2
Show file tree
Hide file tree
Showing 11 changed files with 350 additions and 94 deletions.
32 changes: 28 additions & 4 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ unless Enum.member?(["all", "web", "device"], nerves_hub_app) do
"""
end

features_enabled? =
case Mix.target() do
:prod ->
System.get_env("FEATURES_ENABLED", "false") == "true"

_ ->
true
end

config :nerves_hub,
app: nerves_hub_app,
deploy_env: System.get_env("DEPLOY_ENV", to_string(config_env())),
Expand All @@ -25,9 +34,6 @@ config :nerves_hub,
username: System.get_env("ADMIN_AUTH_USERNAME"),
password: System.get_env("ADMIN_AUTH_PASSWORD")
],
device_health_check_enabled: System.get_env("DEVICE_HEALTH_CHECK_ENABLED", "true") == "true",
device_health_check_interval_minutes:
String.to_integer(System.get_env("DEVICE_HEALTH_CHECK_INTERVAL_MINUTES", "60")),
device_health_days_to_retain:
String.to_integer(System.get_env("HEALTH_CHECK_DAYS_TO_RETAIN", "7")),
device_deployment_change_jitter_seconds:
Expand All @@ -37,7 +43,25 @@ config :nerves_hub,
deployment_calculator_interval_seconds:
String.to_integer(System.get_env("DEPLOYMENT_CALCULATOR_INTERVAL_SECONDS", "3600")),
mapbox_access_token: System.get_env("MAPBOX_ACCESS_TOKEN"),
dashboard_enabled: System.get_env("DASHBOARD_ENABLED", "false") == "true"
dashboard_enabled: System.get_env("DASHBOARD_ENABLED", "false") == "true",
# Features are off by default, a safety for now
use_features?: features_enabled?,
# All nice features are on by default, IF you switch features on
features: [
geo: System.get_env("FEATURES_GEO_ENABLED", "true") == "true",
health: System.get_env("FEATURES_HEALTH_ENABLED", "true") == "true"
],
feature_config: [
geo: [
# No interval, fetch geo on device connection by default
interval_minutes:
System.get_env("FEATURES_GEO_INTERVAL_MINUTES", "0") |> String.to_integer()
],
health: [
interval_minutes:
System.get_env("FEATURES_HEALTH_INTERVAL_MINUTES", "60") |> String.to_integer()
]
]

config :nerves_hub, :device_socket_drainer,
batch_size: String.to_integer(System.get_env("DEVICE_SOCKET_DRAINER_BATCH_SIZE", "1000")),
Expand Down
2 changes: 2 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ config :nerves_hub, NervesHub.SwooshMailer, adapter: Swoosh.Adapters.Test

config :nerves_hub, NervesHub.RateLimit, limit: 100

config :sentry, environment_name: :test

config :phoenix_test, :endpoint, NervesHubWeb.Endpoint

# Initialize plugs at runtime for faster test compilation
Expand Down
78 changes: 78 additions & 0 deletions lib/nerves_hub/features.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
defmodule NervesHub.Features do
@moduledoc """
A "feature" is an additional piece of functionality that we add onto the
existing connection between the device and the NervesHub service. They are
designed to be less important than firmware updates and requires both client
to report support and the server to enable support.
This is intended to ensure that:
- The service decides when activity should be taken by the device meaning
the fleet of devices will not inadvertently swarm the service with data.
- The service can turn off features in various ways to ensure that disruptive
features stop being enabled on subsequent connections.
- Use of features should have very little chance to disrupt the flow of a
critical firmware update.
"""

alias NervesHub.Devices.Device

require Logger

@doc """
Whether a device is allowed to use features at all, currently.
This currently consults the static configuration to see if features are
enabled overall. We should later add subsequent checks as features may
be controlled per product or per
"""
@spec device_can_use_features?(Device.t()) :: boolean()
def device_can_use_features?(%Device{} = _device) do
# Launch features feature with a restrictive default, this may change later
Application.get_env(:nerves_hub, :use_features?, false)
end

@doc """
Whether a specific feature is allowed at a specific version for a device.
This currently consults the static configuration to see if features are
enabled overall. We should later add subsequent checks as features may
be controlled per organisation, per product and/or per device.
This starts with that very rough on off switch for all features and
then also some global config for specific features that defaults to off.
"""
@spec enable_feature?(Device.t(), String.t(), String.t()) :: boolean()
def enable_feature?(%Device{} = _device, feature, version) do
Application.get_env(:nerves_hub, :use_features?, false) and
enable_from_config?(feature, version)
end

defp enable_from_config?(feature, version) do
features = Application.get_env(:nerves_hub, :features, [])

case features[feature] do
nil ->
false

# Generically enabled
true ->
true

# Configured
config when is_map(config) or is_list(config) ->
config_version_requirement_matched?(config[:version], version)

bad ->
Logger.error("Invalid config for feature '#{feature}, configured as: #{inspect(bad)}")
false
end
end

# If no version was set in config it is enabled
defp config_version_requirement_matched?(nil, _), do: true

defp config_version_requirement_matched?(requirement, version) do
Version.match?(version, requirement)
end
end
81 changes: 6 additions & 75 deletions lib/nerves_hub_web/channels/device_channel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule NervesHubWeb.DeviceChannel do
alias NervesHub.Deployments
alias NervesHub.Devices
alias NervesHub.Devices.Device
alias NervesHub.Devices.Metrics
alias NervesHub.Features
alias NervesHub.Firmwares
alias NervesHub.Repo
alias Phoenix.Socket.Broadcast
Expand Down Expand Up @@ -49,13 +49,12 @@ defmodule NervesHubWeb.DeviceChannel do
subscribe("device:#{device.id}")
subscribe(deployment_channel)

if device_health_check_enabled?() do
send(self(), :health_check)
schedule_health_check()
end

send(self(), :device_registation)

if Features.device_can_use_features?(device) do
push(socket, "features:get", %{})
end

socket =
socket
|> assign(:device, device)
Expand Down Expand Up @@ -311,12 +310,6 @@ defmodule NervesHubWeb.DeviceChannel do
{:noreply, socket}
end

def handle_info(:health_check, socket) do
push(socket, "check_health", %{})
schedule_health_check()
{:noreply, socket}
end

def handle_info(%Broadcast{event: "connection:heartbeat"}, socket) do
# Expected message that is not used here :)
{:noreply, socket}
Expand Down Expand Up @@ -359,16 +352,6 @@ defmodule NervesHubWeb.DeviceChannel do
end
end

def handle_in("location:update", location, %{assigns: %{device: device}} = socket) do
metadata = Map.put(device.connection_metadata, "location", location)

{:ok, device} = Devices.update_device(device, %{connection_metadata: metadata})

device_internal_broadcast!(device, "location:updated", location)

{:reply, :ok, assign(socket, :device, device)}
end

def handle_in("connection_types", %{"values" => types}, %{assigns: %{device: device}} = socket) do
{:ok, device} = Devices.update_device(device, %{"connection_types" => types})
{:noreply, assign(socket, :device, device)}
Expand Down Expand Up @@ -407,40 +390,6 @@ defmodule NervesHubWeb.DeviceChannel do
{:noreply, socket}
end

def handle_in("health_check_report", %{"value" => device_status}, socket) do
device_meta =
for {key, val} <- Map.from_struct(socket.assigns.device.firmware_metadata),
into: %{},
do: {to_string(key), to_string(val)}

# Separate metrics from health report to store in metrics table
metrics = device_status["metrics"]

health_report =
device_status
|> Map.delete("metrics")
|> Map.put("metadata", Map.merge(device_status["metadata"], device_meta))

device_health = %{"device_id" => socket.assigns.device.id, "data" => health_report}

with {:health_report, {:ok, _}} <-
{:health_report, Devices.save_device_health(device_health)},
{:metrics_report, {_, _}} <-
{:metrics_report, Metrics.save_metrics(socket.assigns.device.id, metrics)} do
device_internal_broadcast!(socket.assigns.device, "health_check_report", %{})
else
{:health_report, {:error, err}} ->
Logger.warning("Failed to save health check data: #{inspect(err)}")
log_to_sentry(socket.assigns.device, "[DeviceChannel] Failed to save health check data.")

{:metrics_report, {:error, err}} ->
Logger.warning("Failed to save metrics: #{inspect(err)}")
log_to_sentry(socket.assigns.device, "[DeviceChannel] Failed to save metrics.")
end

{:noreply, socket}
end

def handle_in(msg, params, socket) do
# Ignore unhandled messages so that it doesn't crash the link process
# preventing cascading problems.
Expand Down Expand Up @@ -476,7 +425,7 @@ defmodule NervesHubWeb.DeviceChannel do
:ok
end

defp log_to_sentry(device, message, extra \\ %{}) do
defp log_to_sentry(device, message, extra) do
Sentry.Context.set_tags_context(%{
device_identifier: device.identifier,
device_id: device.id,
Expand Down Expand Up @@ -643,24 +592,6 @@ defmodule NervesHubWeb.DeviceChannel do
socket
end

defp schedule_health_check() do
if device_health_check_enabled?() do
Process.send_after(self(), :health_check, device_health_check_interval())
:ok
else
:ok
end
end

defp device_health_check_enabled?() do
Application.get_env(:nerves_hub, :device_health_check_enabled)
end

defp device_health_check_interval() do
Application.get_env(:nerves_hub, :device_health_check_interval_minutes)
|> :timer.minutes()
end

defp device_deployment_change_jitter_ms() do
jitter = Application.get_env(:nerves_hub, :device_deployment_change_jitter_seconds)

Expand Down
1 change: 1 addition & 0 deletions lib/nerves_hub_web/channels/device_socket.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ defmodule NervesHubWeb.DeviceSocket do

channel("console", NervesHubWeb.ConsoleChannel)
channel("device", NervesHubWeb.DeviceChannel)
channel("features", NervesHubWeb.FeaturesChannel)

# Default 90 seconds max age for the signature
@default_max_hmac_age 90
Expand Down
Loading

0 comments on commit 1db8da2

Please sign in to comment.