Skip to content

Commit 8c65c89

Browse files
authored
[change] Auto add default/required templates to existing devices #480
Templates marked as default or required are now automatically assigned to all eligible devices in a background task. Closes #480
1 parent 4f0df35 commit 8c65c89

File tree

6 files changed

+275
-13
lines changed

6 files changed

+275
-13
lines changed

docs/user/templates.rst

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Default Templates
7979
:alt: Templates enabled by default
8080

8181
When templates are flagged as **"Enabled by default"**, they will be
82-
automatically assigned to new devices.
82+
automatically assigned to all devices, **this includes existing devices**.
8383

8484
This is a very powerful feature: **once default templates are correctly
8585
configured to implement the use case you need, you will only have to
@@ -95,11 +95,10 @@ default templates without the need of manual intervention from the network
9595
operators.
9696

9797
An organization specific template flagged as default will be automatically
98-
assigned to any new device which will be created in the same organization.
98+
assigned to all current and future devices in the same organization.
9999

100100
A shared default template instead will be automatically assigned to all
101-
the new devices which will be created in the system, regardless of
102-
organization.
101+
existing and new devices in the system, regardless of organization.
103102

104103
.. _required_templates:
105104

openwisp_controller/config/base/template.py

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import logging
23
from collections import OrderedDict
34
from copy import copy
45

@@ -7,14 +8,19 @@
78
from django.utils.translation import gettext_lazy as _
89
from jsonfield import JSONField
910
from netjsonconfig.exceptions import ValidationError as NetjsonconfigValidationError
10-
from swapper import get_model_name
11+
from swapper import get_model_name, load_model
1112
from taggit.managers import TaggableManager
1213

1314
from ...base import ShareableOrgMixinUniqueName
1415
from ..settings import DEFAULT_AUTO_CERT
15-
from ..tasks import update_template_related_config_status
16+
from ..tasks import (
17+
auto_add_template_to_existing_configs,
18+
update_template_related_config_status,
19+
)
1620
from .base import BaseConfig
1721

22+
logger = logging.getLogger(__name__)
23+
1824
TYPE_CHOICES = (("generic", _("Generic")), ("vpn", _("VPN-client")))
1925

2026

@@ -61,7 +67,8 @@ class AbstractTemplate(ShareableOrgMixinUniqueName, BaseConfig):
6167
default=False,
6268
db_index=True,
6369
help_text=_(
64-
"whether new configurations will have this template enabled by default"
70+
"whether this template is applied to all current and future devices"
71+
" by default (can be unassigned manually)"
6572
),
6673
)
6774
required = models.BooleanField(
@@ -124,14 +131,31 @@ def pre_save_handler(cls, instance, *args, **kwargs):
124131
# the old configuration may become invalid, raising an exception.
125132
# Setting the checksum to None forces related configurations to update.
126133
current_checksum = None
127-
instance._update_related_config_status = instance.checksum != current_checksum
134+
instance._should_update_related_config_status = (
135+
instance.checksum != current_checksum
136+
)
137+
138+
# Check if template is becoming default or required
139+
if (instance.default and not current.default) or (
140+
instance.required and not current.required
141+
):
142+
instance._should_add_to_existing_configs = True
128143

129144
@classmethod
130145
def post_save_handler(cls, instance, created, *args, **kwargs):
131-
if not created and getattr(instance, "_update_related_config_status", False):
146+
if not created and getattr(
147+
instance, "_should_update_related_config_status", False
148+
):
132149
transaction.on_commit(
133150
lambda: update_template_related_config_status.delay(instance.pk)
134151
)
152+
# Auto-add template to existing configs if it's new or became default/required
153+
if getattr(instance, "_should_add_to_existing_configs", False) or (
154+
created and (instance.default or instance.required)
155+
):
156+
transaction.on_commit(
157+
lambda: auto_add_template_to_existing_configs.delay(str(instance.pk))
158+
)
135159

136160
def _update_related_config_status(self):
137161
# use atomic to ensure any code bound to
@@ -153,6 +177,44 @@ def _update_related_config_status(self):
153177
if config.pk in changing_status:
154178
config._send_config_status_changed_signal()
155179

180+
def _auto_add_to_existing_configs(self):
181+
"""
182+
When a template is ``default`` or ``required``, adds the template
183+
to each relevant ``Config`` object
184+
"""
185+
Config = load_model("config", "Config")
186+
187+
# Only proceed if template is default or required
188+
if not (self.default or self.required):
189+
return
190+
191+
# use atomic to ensure any code bound to
192+
# be executed via transaction.on_commit
193+
# is executed after the whole block
194+
with transaction.atomic():
195+
# Exclude deactivating or deactivated configs
196+
configs = (
197+
Config.objects.select_related("device")
198+
.filter(
199+
backend=self.backend,
200+
)
201+
.exclude(
202+
models.Q(status__in=["deactivating", "deactivated"])
203+
| models.Q(templates__id=self.pk)
204+
)
205+
)
206+
if self.organization_id:
207+
configs = configs.filter(device__organization_id=self.organization_id)
208+
for config in configs.iterator():
209+
try:
210+
config.templates.add(self)
211+
except Exception as e:
212+
# Log error but continue with other configs
213+
logger.exception(
214+
f"Failed to add template {self.pk} to "
215+
f"config {config.pk}: {e}"
216+
)
217+
156218
def clean(self, *args, **kwargs):
157219
"""
158220
* validates org relationship of VPN if present

openwisp_controller/config/migrations/0001_squashed_0002_config_settings_uuid.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,8 @@ class Migration(migrations.Migration):
294294
db_index=True,
295295
default=False,
296296
help_text=(
297-
"whether new configurations will have this "
298-
"template enabled by default"
297+
"whether this template is applied to all current and future"
298+
" devices by default (can be unassigned manually)"
299299
),
300300
verbose_name="enabled by default",
301301
),

openwisp_controller/config/tasks.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,26 @@ def update_template_related_config_status(template_pk):
3737
)
3838

3939

40+
@shared_task(soft_time_limit=7200)
41+
def auto_add_template_to_existing_configs(template_pk):
42+
Template = load_model("config", "Template")
43+
try:
44+
template = Template.objects.get(pk=template_pk)
45+
except ObjectDoesNotExist as e:
46+
logger.warning(
47+
f'auto_add_template_to_existing_configs("{template_pk}") failed: {e}'
48+
)
49+
return
50+
try:
51+
template._auto_add_to_existing_configs()
52+
except SoftTimeLimitExceeded:
53+
logger.error(
54+
"soft time limit hit while executing "
55+
f"_auto_add_to_existing_configs for {template} "
56+
f"(ID: {template_pk})"
57+
)
58+
59+
4060
@shared_task(soft_time_limit=1200)
4161
def create_vpn_dh(vpn_pk):
4262
"""

openwisp_controller/config/tests/test_template.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from .. import settings as app_settings
1616
from ..signals import config_modified, config_status_changed
17+
from ..tasks import auto_add_template_to_existing_configs
1718
from ..tasks import logger as task_logger
1819
from ..tasks import update_template_related_config_status
1920
from .utils import CreateConfigTemplateMixin, TestVpnX509Mixin
@@ -517,6 +518,36 @@ def test_regression_preventing_from_fixing_invalid_conf(self):
517518
template.full_clean()
518519
template.save()
519520

521+
def test_auto_add_to_existing_configs_error_handling(self):
522+
with self.subTest("Template is not default or required"):
523+
template = self._create_template(
524+
name="test-template", required=False, default=False
525+
)
526+
with mock.patch.object(transaction, "atomic") as mocked_atomic:
527+
template._auto_add_to_existing_configs()
528+
mocked_atomic.assert_not_called()
529+
530+
with self.subTest("config.templates.add(template) raises error"):
531+
config = self._create_config(device=self._create_device(name="test-device"))
532+
template.required = True
533+
template.full_clean()
534+
template.save()
535+
with mock.patch.object(
536+
Config.templates.related_manager_cls,
537+
"add",
538+
side_effect=ValueError("Incompatible template"),
539+
), mock.patch("logging.Logger.exception") as mocked_logger:
540+
template._auto_add_to_existing_configs()
541+
mocked_logger.assert_called_once_with(
542+
f"Failed to add template {template.pk} to config {config.pk}:"
543+
" Incompatible template"
544+
)
545+
546+
@mock.patch.object(task_logger, "warning")
547+
def test_auto_add_template_to_existing_configs_task_failure(self, mocked_warning):
548+
auto_add_template_to_existing_configs.delay(uuid.uuid4())
549+
mocked_warning.assert_called_once()
550+
520551

521552
class TestTemplateTransaction(
522553
CreateConfigTemplateMixin,
@@ -749,3 +780,153 @@ def test_task_timeout(self, mocked_update_related_config_status):
749780
template.save()
750781
mocked_error.assert_called_once()
751782
mocked_update_related_config_status.assert_called_once()
783+
784+
def _create_env_for_adding_template_to_existing_config(self):
785+
org1 = self._get_org()
786+
org1_config = self._create_config(organization=org1, status="applied")
787+
org2 = self._create_org(name="org2", slug="org2")
788+
org2_config = self._create_config(organization=org2, status="applied")
789+
org2_deactivating_config = self._create_config(
790+
status="deactivating",
791+
device=self._create_device(
792+
name="test-deactivating",
793+
mac_address="11:22:33:44:55:67",
794+
organization=org2,
795+
),
796+
)
797+
org2_deactivated_config = self._create_config(
798+
status="deactivated",
799+
device=self._create_device(
800+
name="test-deactivated",
801+
mac_address="11:22:33:44:55:68",
802+
organization=org2,
803+
),
804+
)
805+
return (
806+
org1_config,
807+
org2_config,
808+
org2_deactivating_config,
809+
org2_deactivated_config,
810+
org1,
811+
org2,
812+
)
813+
814+
def _assert_template_added_to_existing_config(
815+
self,
816+
template,
817+
org1_config,
818+
org2_config,
819+
org2_deactivating_config,
820+
org2_deactivated_config,
821+
):
822+
org1_config.refresh_from_db()
823+
self.assertEqual(org1_config.status, "modified")
824+
self.assertIn(template, org1_config.templates.all())
825+
org2_config.refresh_from_db()
826+
self.assertEqual(org2_config.status, "modified")
827+
self.assertIn(template, org2_config.templates.all())
828+
829+
org2_deactivating_config.refresh_from_db()
830+
self.assertEqual(org2_deactivating_config.status, "deactivating")
831+
self.assertEqual(org2_deactivating_config.templates.count(), 0)
832+
833+
org2_deactivated_config.refresh_from_db()
834+
self.assertEqual(org2_deactivated_config.status, "deactivated")
835+
self.assertEqual(org2_deactivated_config.templates.count(), 0)
836+
837+
def test_required_template_added_to_existing_config(self):
838+
(
839+
org1_config,
840+
org2_config,
841+
org2_deactivating_config,
842+
org2_deactivated_config,
843+
_,
844+
_,
845+
) = self._create_env_for_adding_template_to_existing_config()
846+
shared_template = self._create_template(
847+
name="shared-template", required=True, default=True
848+
)
849+
self._assert_template_added_to_existing_config(
850+
shared_template,
851+
org1_config,
852+
org2_config,
853+
org2_deactivating_config,
854+
org2_deactivated_config,
855+
)
856+
857+
def test_default_template_added_to_existing_config(self):
858+
(
859+
org1_config,
860+
org2_config,
861+
org2_deactivating_config,
862+
org2_deactivated_config,
863+
_,
864+
_,
865+
) = self._create_env_for_adding_template_to_existing_config()
866+
default_template = self._create_template(name="default-template", default=True)
867+
self._assert_template_added_to_existing_config(
868+
default_template,
869+
org1_config,
870+
org2_config,
871+
org2_deactivating_config,
872+
org2_deactivated_config,
873+
)
874+
875+
def test_update_existing_template_to_be_required(self):
876+
template = self._create_template(
877+
name="existing-template", required=False, default=False
878+
)
879+
config = self._create_config(organization=self._get_org())
880+
self.assertNotIn(template, config.templates.all())
881+
882+
template.required = True
883+
template.full_clean()
884+
template.save()
885+
886+
config.refresh_from_db()
887+
self.assertEqual(config.status, "modified")
888+
self.assertIn(template, config.templates.all())
889+
890+
def test_update_existing_template_to_be_default(self):
891+
template = self._create_template(
892+
name="existing-template", required=False, default=False
893+
)
894+
config = self._create_config(organization=self._get_org())
895+
self.assertNotIn(template, config.templates.all())
896+
897+
template.default = True
898+
template.full_clean()
899+
template.save()
900+
901+
config.refresh_from_db()
902+
self.assertEqual(config.status, "modified")
903+
self.assertIn(template, config.templates.all())
904+
905+
@mock.patch.object(auto_add_template_to_existing_configs, "delay")
906+
def test_auto_add_template_to_existing_configs_not_triggered(self, mocked_task):
907+
"""
908+
Ensure that auto_add_template_to_existing_configs task is not triggered
909+
when a required/default template is updated.
910+
"""
911+
with self.subTest("Updating default template does not trigger task"):
912+
default_template = self._create_template(
913+
name="default-template", default=True
914+
)
915+
mocked_task.assert_called_once_with(str(default_template.pk))
916+
mocked_task.reset_mock()
917+
918+
default_template.config["interfaces"][0]["name"] = "eth1"
919+
default_template.full_clean()
920+
default_template.save()
921+
mocked_task.assert_not_called()
922+
923+
with self.subTest("Updating required template does not trigger task"):
924+
required_template = self._create_template(
925+
name="required-template", required=True
926+
)
927+
mocked_task.assert_called_once_with(str(required_template.pk))
928+
mocked_task.reset_mock()
929+
required_template.config["interfaces"][0]["name"] = "eth1"
930+
required_template.full_clean()
931+
required_template.save()
932+
mocked_task.assert_not_called()

tests/openwisp2/sample_config/migrations/0001_initial.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,8 +527,8 @@ class Migration(migrations.Migration):
527527
db_index=True,
528528
default=False,
529529
help_text=(
530-
"whether new configurations will have this "
531-
"template enabled by default"
530+
"whether this template is applied to all current and future"
531+
" devices by default (can be unassigned manually)"
532532
),
533533
verbose_name="enabled by default",
534534
),

0 commit comments

Comments
 (0)