Skip to content

Commit fc10369

Browse files
committed
Use token-based ownership lock and assert release path in tests
1 parent 8ff37b8 commit fc10369

File tree

2 files changed

+44
-17
lines changed

2 files changed

+44
-17
lines changed

openwisp_controller/connection/tasks.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import time
3+
import uuid
34

45
import swapper
56
from celery import shared_task
@@ -23,17 +24,27 @@
2324
def _acquire_update_config_lock(device_id):
2425
"""
2526
Attempts to atomically acquire a per-device lock using the Django cache.
26-
Returns True if the lock was acquired, False if another task already holds it.
27+
Returns a unique token string if the lock was acquired, None otherwise.
28+
The token must be passed to _release_update_config_lock to ensure
29+
only the lock owner can release it.
2730
"""
2831
lock_key = _UPDATE_CONFIG_LOCK_KEY.format(device_id=device_id)
32+
token = str(uuid.uuid4())
2933
# cache.add is atomic: returns True only if the key doesn't already exist
30-
return cache.add(lock_key, True, timeout=_UPDATE_CONFIG_LOCK_TIMEOUT)
34+
if cache.add(lock_key, token, timeout=_UPDATE_CONFIG_LOCK_TIMEOUT):
35+
return token
36+
return None
3137

3238

33-
def _release_update_config_lock(device_id):
34-
"""Releases the per-device update_config lock."""
39+
def _release_update_config_lock(device_id, token):
40+
"""
41+
Releases the per-device update_config lock only if the caller
42+
owns it (i.e. the stored token matches).
43+
"""
3544
lock_key = _UPDATE_CONFIG_LOCK_KEY.format(device_id=device_id)
36-
cache.delete(lock_key)
45+
stored_token = cache.get(lock_key)
46+
if stored_token == token:
47+
cache.delete(lock_key)
3748

3849

3950
@shared_task
@@ -56,7 +67,8 @@ def update_config(device_id):
5667
except ObjectDoesNotExist as e:
5768
logger.warning(f'update_config("{device_id}") failed: {e}')
5869
return
59-
if not _acquire_update_config_lock(device_id):
70+
lock_token = _acquire_update_config_lock(device_id)
71+
if not lock_token:
6072
logger.info(
6173
f"update_config for device {device_id} is already in progress, skipping"
6274
)
@@ -70,7 +82,7 @@ def update_config(device_id):
7082
logger.info(f"Updating {device} (pk: {device_id})")
7183
device_conn.update_config()
7284
finally:
73-
_release_update_config_lock(device_id)
85+
_release_update_config_lock(device_id, lock_token)
7486

7587

7688
# task timeout is SSH_COMMAND_TIMEOUT plus a 20% margin

openwisp_controller/connection/tests/test_models.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,7 +1036,7 @@ def test_device_update_config_in_progress(
10361036

10371037
with mock.patch(
10381038
"openwisp_controller.connection.tasks._acquire_update_config_lock",
1039-
return_value=False,
1039+
return_value=None,
10401040
):
10411041
conf.config = {"general": {"timezone": "UTC"}}
10421042
conf.full_clean()
@@ -1057,28 +1057,43 @@ def test_device_update_config_not_in_progress(
10571057

10581058
with mock.patch(
10591059
"openwisp_controller.connection.tasks._acquire_update_config_lock",
1060-
return_value=True,
1060+
return_value="fake-lock-token",
10611061
), mock.patch(
10621062
"openwisp_controller.connection.tasks._release_update_config_lock",
1063-
):
1063+
) as mocked_release:
10641064
conf.config = {"general": {"timezone": "UTC"}}
10651065
conf.full_clean()
10661066
conf.save()
10671067
mocked_get_working_connection.assert_called_once()
10681068
mocked_update_config.assert_called_once()
1069+
mocked_release.assert_called_once()
10691070

10701071
def test_acquire_update_config_lock(self):
10711072
"""Test that the lock can be acquired and prevents duplicate acquisition."""
10721073
device_id = "test-device-id"
1073-
# First acquisition should succeed
1074-
self.assertTrue(_acquire_update_config_lock(device_id))
1074+
# First acquisition should succeed and return a token
1075+
token = _acquire_update_config_lock(device_id)
1076+
self.assertIsNotNone(token)
10751077
# Second acquisition should fail (lock already held)
1076-
self.assertFalse(_acquire_update_config_lock(device_id))
1077-
# After releasing, acquisition should succeed again
1078-
_release_update_config_lock(device_id)
1079-
self.assertTrue(_acquire_update_config_lock(device_id))
1078+
self.assertIsNone(_acquire_update_config_lock(device_id))
1079+
# After releasing with correct token, acquisition should succeed again
1080+
_release_update_config_lock(device_id, token)
1081+
token2 = _acquire_update_config_lock(device_id)
1082+
self.assertIsNotNone(token2)
10801083
# Cleanup
1081-
_release_update_config_lock(device_id)
1084+
_release_update_config_lock(device_id, token2)
1085+
1086+
def test_release_update_config_lock_wrong_token(self):
1087+
"""Only the lock owner can release the lock."""
1088+
device_id = "test-device-id"
1089+
token = _acquire_update_config_lock(device_id)
1090+
self.assertIsNotNone(token)
1091+
# Releasing with wrong token should not delete the lock
1092+
_release_update_config_lock(device_id, "wrong-token")
1093+
# Lock should still be held
1094+
self.assertIsNone(_acquire_update_config_lock(device_id))
1095+
# Releasing with correct token should work
1096+
_release_update_config_lock(device_id, token)
10821097

10831098
@mock.patch(_connect_path)
10841099
def test_schedule_command_called(self, connect_mocked):

0 commit comments

Comments
 (0)