Skip to content

Commit 1db8da2

Browse files
jjcarstenslawik
authored andcommitted
Add a way to extend DeviceChannel functionality via Features
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
1 parent a46a73e commit 1db8da2

File tree

11 files changed

+350
-94
lines changed

11 files changed

+350
-94
lines changed

config/runtime.exs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ unless Enum.member?(["all", "web", "device"], nerves_hub_app) do
99
"""
1010
end
1111

12+
features_enabled? =
13+
case Mix.target() do
14+
:prod ->
15+
System.get_env("FEATURES_ENABLED", "false") == "true"
16+
17+
_ ->
18+
true
19+
end
20+
1221
config :nerves_hub,
1322
app: nerves_hub_app,
1423
deploy_env: System.get_env("DEPLOY_ENV", to_string(config_env())),
@@ -25,9 +34,6 @@ config :nerves_hub,
2534
username: System.get_env("ADMIN_AUTH_USERNAME"),
2635
password: System.get_env("ADMIN_AUTH_PASSWORD")
2736
],
28-
device_health_check_enabled: System.get_env("DEVICE_HEALTH_CHECK_ENABLED", "true") == "true",
29-
device_health_check_interval_minutes:
30-
String.to_integer(System.get_env("DEVICE_HEALTH_CHECK_INTERVAL_MINUTES", "60")),
3137
device_health_days_to_retain:
3238
String.to_integer(System.get_env("HEALTH_CHECK_DAYS_TO_RETAIN", "7")),
3339
device_deployment_change_jitter_seconds:
@@ -37,7 +43,25 @@ config :nerves_hub,
3743
deployment_calculator_interval_seconds:
3844
String.to_integer(System.get_env("DEPLOYMENT_CALCULATOR_INTERVAL_SECONDS", "3600")),
3945
mapbox_access_token: System.get_env("MAPBOX_ACCESS_TOKEN"),
40-
dashboard_enabled: System.get_env("DASHBOARD_ENABLED", "false") == "true"
46+
dashboard_enabled: System.get_env("DASHBOARD_ENABLED", "false") == "true",
47+
# Features are off by default, a safety for now
48+
use_features?: features_enabled?,
49+
# All nice features are on by default, IF you switch features on
50+
features: [
51+
geo: System.get_env("FEATURES_GEO_ENABLED", "true") == "true",
52+
health: System.get_env("FEATURES_HEALTH_ENABLED", "true") == "true"
53+
],
54+
feature_config: [
55+
geo: [
56+
# No interval, fetch geo on device connection by default
57+
interval_minutes:
58+
System.get_env("FEATURES_GEO_INTERVAL_MINUTES", "0") |> String.to_integer()
59+
],
60+
health: [
61+
interval_minutes:
62+
System.get_env("FEATURES_HEALTH_INTERVAL_MINUTES", "60") |> String.to_integer()
63+
]
64+
]
4165

4266
config :nerves_hub, :device_socket_drainer,
4367
batch_size: String.to_integer(System.get_env("DEVICE_SOCKET_DRAINER_BATCH_SIZE", "1000")),

config/test.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ config :nerves_hub, NervesHub.SwooshMailer, adapter: Swoosh.Adapters.Test
8787

8888
config :nerves_hub, NervesHub.RateLimit, limit: 100
8989

90+
config :sentry, environment_name: :test
91+
9092
config :phoenix_test, :endpoint, NervesHubWeb.Endpoint
9193

9294
# Initialize plugs at runtime for faster test compilation

lib/nerves_hub/features.ex

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
defmodule NervesHub.Features do
2+
@moduledoc """
3+
A "feature" is an additional piece of functionality that we add onto the
4+
existing connection between the device and the NervesHub service. They are
5+
designed to be less important than firmware updates and requires both client
6+
to report support and the server to enable support.
7+
8+
This is intended to ensure that:
9+
10+
- The service decides when activity should be taken by the device meaning
11+
the fleet of devices will not inadvertently swarm the service with data.
12+
- The service can turn off features in various ways to ensure that disruptive
13+
features stop being enabled on subsequent connections.
14+
- Use of features should have very little chance to disrupt the flow of a
15+
critical firmware update.
16+
"""
17+
18+
alias NervesHub.Devices.Device
19+
20+
require Logger
21+
22+
@doc """
23+
Whether a device is allowed to use features at all, currently.
24+
25+
This currently consults the static configuration to see if features are
26+
enabled overall. We should later add subsequent checks as features may
27+
be controlled per product or per
28+
"""
29+
@spec device_can_use_features?(Device.t()) :: boolean()
30+
def device_can_use_features?(%Device{} = _device) do
31+
# Launch features feature with a restrictive default, this may change later
32+
Application.get_env(:nerves_hub, :use_features?, false)
33+
end
34+
35+
@doc """
36+
Whether a specific feature is allowed at a specific version for a device.
37+
38+
This currently consults the static configuration to see if features are
39+
enabled overall. We should later add subsequent checks as features may
40+
be controlled per organisation, per product and/or per device.
41+
42+
This starts with that very rough on off switch for all features and
43+
then also some global config for specific features that defaults to off.
44+
"""
45+
@spec enable_feature?(Device.t(), String.t(), String.t()) :: boolean()
46+
def enable_feature?(%Device{} = _device, feature, version) do
47+
Application.get_env(:nerves_hub, :use_features?, false) and
48+
enable_from_config?(feature, version)
49+
end
50+
51+
defp enable_from_config?(feature, version) do
52+
features = Application.get_env(:nerves_hub, :features, [])
53+
54+
case features[feature] do
55+
nil ->
56+
false
57+
58+
# Generically enabled
59+
true ->
60+
true
61+
62+
# Configured
63+
config when is_map(config) or is_list(config) ->
64+
config_version_requirement_matched?(config[:version], version)
65+
66+
bad ->
67+
Logger.error("Invalid config for feature '#{feature}, configured as: #{inspect(bad)}")
68+
false
69+
end
70+
end
71+
72+
# If no version was set in config it is enabled
73+
defp config_version_requirement_matched?(nil, _), do: true
74+
75+
defp config_version_requirement_matched?(requirement, version) do
76+
Version.match?(version, requirement)
77+
end
78+
end

lib/nerves_hub_web/channels/device_channel.ex

Lines changed: 6 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defmodule NervesHubWeb.DeviceChannel do
1414
alias NervesHub.Deployments
1515
alias NervesHub.Devices
1616
alias NervesHub.Devices.Device
17-
alias NervesHub.Devices.Metrics
17+
alias NervesHub.Features
1818
alias NervesHub.Firmwares
1919
alias NervesHub.Repo
2020
alias Phoenix.Socket.Broadcast
@@ -49,13 +49,12 @@ defmodule NervesHubWeb.DeviceChannel do
4949
subscribe("device:#{device.id}")
5050
subscribe(deployment_channel)
5151

52-
if device_health_check_enabled?() do
53-
send(self(), :health_check)
54-
schedule_health_check()
55-
end
56-
5752
send(self(), :device_registation)
5853

54+
if Features.device_can_use_features?(device) do
55+
push(socket, "features:get", %{})
56+
end
57+
5958
socket =
6059
socket
6160
|> assign(:device, device)
@@ -311,12 +310,6 @@ defmodule NervesHubWeb.DeviceChannel do
311310
{:noreply, socket}
312311
end
313312

314-
def handle_info(:health_check, socket) do
315-
push(socket, "check_health", %{})
316-
schedule_health_check()
317-
{:noreply, socket}
318-
end
319-
320313
def handle_info(%Broadcast{event: "connection:heartbeat"}, socket) do
321314
# Expected message that is not used here :)
322315
{:noreply, socket}
@@ -359,16 +352,6 @@ defmodule NervesHubWeb.DeviceChannel do
359352
end
360353
end
361354

362-
def handle_in("location:update", location, %{assigns: %{device: device}} = socket) do
363-
metadata = Map.put(device.connection_metadata, "location", location)
364-
365-
{:ok, device} = Devices.update_device(device, %{connection_metadata: metadata})
366-
367-
device_internal_broadcast!(device, "location:updated", location)
368-
369-
{:reply, :ok, assign(socket, :device, device)}
370-
end
371-
372355
def handle_in("connection_types", %{"values" => types}, %{assigns: %{device: device}} = socket) do
373356
{:ok, device} = Devices.update_device(device, %{"connection_types" => types})
374357
{:noreply, assign(socket, :device, device)}
@@ -407,40 +390,6 @@ defmodule NervesHubWeb.DeviceChannel do
407390
{:noreply, socket}
408391
end
409392

410-
def handle_in("health_check_report", %{"value" => device_status}, socket) do
411-
device_meta =
412-
for {key, val} <- Map.from_struct(socket.assigns.device.firmware_metadata),
413-
into: %{},
414-
do: {to_string(key), to_string(val)}
415-
416-
# Separate metrics from health report to store in metrics table
417-
metrics = device_status["metrics"]
418-
419-
health_report =
420-
device_status
421-
|> Map.delete("metrics")
422-
|> Map.put("metadata", Map.merge(device_status["metadata"], device_meta))
423-
424-
device_health = %{"device_id" => socket.assigns.device.id, "data" => health_report}
425-
426-
with {:health_report, {:ok, _}} <-
427-
{:health_report, Devices.save_device_health(device_health)},
428-
{:metrics_report, {_, _}} <-
429-
{:metrics_report, Metrics.save_metrics(socket.assigns.device.id, metrics)} do
430-
device_internal_broadcast!(socket.assigns.device, "health_check_report", %{})
431-
else
432-
{:health_report, {:error, err}} ->
433-
Logger.warning("Failed to save health check data: #{inspect(err)}")
434-
log_to_sentry(socket.assigns.device, "[DeviceChannel] Failed to save health check data.")
435-
436-
{:metrics_report, {:error, err}} ->
437-
Logger.warning("Failed to save metrics: #{inspect(err)}")
438-
log_to_sentry(socket.assigns.device, "[DeviceChannel] Failed to save metrics.")
439-
end
440-
441-
{:noreply, socket}
442-
end
443-
444393
def handle_in(msg, params, socket) do
445394
# Ignore unhandled messages so that it doesn't crash the link process
446395
# preventing cascading problems.
@@ -476,7 +425,7 @@ defmodule NervesHubWeb.DeviceChannel do
476425
:ok
477426
end
478427

479-
defp log_to_sentry(device, message, extra \\ %{}) do
428+
defp log_to_sentry(device, message, extra) do
480429
Sentry.Context.set_tags_context(%{
481430
device_identifier: device.identifier,
482431
device_id: device.id,
@@ -643,24 +592,6 @@ defmodule NervesHubWeb.DeviceChannel do
643592
socket
644593
end
645594

646-
defp schedule_health_check() do
647-
if device_health_check_enabled?() do
648-
Process.send_after(self(), :health_check, device_health_check_interval())
649-
:ok
650-
else
651-
:ok
652-
end
653-
end
654-
655-
defp device_health_check_enabled?() do
656-
Application.get_env(:nerves_hub, :device_health_check_enabled)
657-
end
658-
659-
defp device_health_check_interval() do
660-
Application.get_env(:nerves_hub, :device_health_check_interval_minutes)
661-
|> :timer.minutes()
662-
end
663-
664595
defp device_deployment_change_jitter_ms() do
665596
jitter = Application.get_env(:nerves_hub, :device_deployment_change_jitter_seconds)
666597

lib/nerves_hub_web/channels/device_socket.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule NervesHubWeb.DeviceSocket do
1212

1313
channel("console", NervesHubWeb.ConsoleChannel)
1414
channel("device", NervesHubWeb.DeviceChannel)
15+
channel("features", NervesHubWeb.FeaturesChannel)
1516

1617
# Default 90 seconds max age for the signature
1718
@default_max_hmac_age 90

0 commit comments

Comments
 (0)