Merge ~cgrabowski/maas:track_frequency_of_hw_sync_for_specific_machine into maas:master

Proposed by Christian Grabowski
Status: Merged
Approved by: Christian Grabowski
Approved revision: 19f51cf3f6a8824925d5e628645d0f509d272be1
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~cgrabowski/maas:track_frequency_of_hw_sync_for_specific_machine
Merge into: maas:master
Diff against target: 391 lines (+201/-0)
10 files modified
src/maasserver/api/machines.py (+3/-0)
src/maasserver/api/tests/test_enlistment.py (+6/-0)
src/maasserver/api/tests/test_machine.py (+16/-0)
src/maasserver/migrations/maasserver/0267_add_machine_specific_sync_interval_fields.py (+23/-0)
src/maasserver/models/node.py (+19/-0)
src/maasserver/models/tests/test_node.py (+38/-0)
src/maasserver/utils/converters.py (+16/-0)
src/maasserver/utils/tests/test_converters.py (+41/-0)
src/maasserver/websockets/handlers/node.py (+13/-0)
src/maasserver/websockets/handlers/tests/test_machine.py (+26/-0)
Reviewer Review Type Date Requested Status
MAAS Lander Approve
Alexsander de Souza Approve
Review via email: mp+416690@code.launchpad.net

Commit message

ensure hardware sync values exposed via api

ensure sync values exposed via websocket

add model fields to track sync interval on machine basis

To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b track_frequency_of_hw_sync_for_specific_machine lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/12080/console
COMMIT: 96a8dee0db6e7dea739dec939baf0180a7d571ab

review: Needs Fixing
Revision history for this message
Alexsander de Souza (alexsander-souza) wrote :

next_sync could have a value before the first time the update runs, so the user can see something right after he deploys the machine

review: Approve
19f51cf... by Christian Grabowski

initialize last_sync as time deployment finishes

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b track_frequency_of_hw_sync_for_specific_machine lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/12081/console
COMMIT: 604666498341c0fec27dfcb73fd3fad8ed0189f9

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b track_frequency_of_hw_sync_for_specific_machine lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/12082/console
COMMIT: 8e061c5c65c5b5d26a23018c2c1b2b9f0f51bafb

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b track_frequency_of_hw_sync_for_specific_machine lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/12083/console
COMMIT: fdd7054bdb77e17febca976115a52bd9f1da2d39

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b track_frequency_of_hw_sync_for_specific_machine lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: 19f51cf3f6a8824925d5e628645d0f509d272be1

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/maasserver/api/machines.py b/src/maasserver/api/machines.py
2index 0b23d28..9e51f0b 100644
3--- a/src/maasserver/api/machines.py
4+++ b/src/maasserver/api/machines.py
5@@ -190,6 +190,9 @@ DISPLAYED_MACHINE_FIELDS = (
6 ("numanode_set", DISPLAYED_NUMANODE_FIELDS),
7 "virtualmachine_id",
8 "workload_annotations",
9+ "last_sync",
10+ "sync_interval",
11+ "next_sync",
12 )
13
14 # Limited set of machine fields exposed on the anonymous API.
15diff --git a/src/maasserver/api/tests/test_enlistment.py b/src/maasserver/api/tests/test_enlistment.py
16index 44133ad..cc02116 100644
17--- a/src/maasserver/api/tests/test_enlistment.py
18+++ b/src/maasserver/api/tests/test_enlistment.py
19@@ -757,6 +757,9 @@ class SimpleUserLoggedInEnlistmentAPITest(APITestCase.ForUser):
20 "interface_test_status_name",
21 "virtualmachine_id",
22 "workload_annotations",
23+ "last_sync",
24+ "sync_interval",
25+ "next_sync",
26 },
27 parsed_result.keys(),
28 )
29@@ -978,6 +981,9 @@ class AdminLoggedInEnlistmentAPITest(APITestCase.ForAdmin):
30 "interface_test_status_name",
31 "virtualmachine_id",
32 "workload_annotations",
33+ "last_sync",
34+ "sync_interval",
35+ "next_sync",
36 },
37 parsed_result.keys(),
38 )
39diff --git a/src/maasserver/api/tests/test_machine.py b/src/maasserver/api/tests/test_machine.py
40index 863b640..65eaa1d 100644
41--- a/src/maasserver/api/tests/test_machine.py
42+++ b/src/maasserver/api/tests/test_machine.py
43@@ -2,6 +2,7 @@
44 # GNU Affero General Public License version 3 (see the file LICENSE).
45
46 from base64 import b64encode
47+from datetime import datetime
48 import http.client
49 import logging
50 from random import choice
51@@ -250,6 +251,21 @@ class TestMachineAPI(APITestCase.ForUser):
52 parsed_result["boot_interface"]["mac_address"],
53 )
54
55+ def test_GET_returns_hardware_sync_values(self):
56+ machine = factory.make_Node(enable_hw_sync=True)
57+ machine.last_sync = datetime.now()
58+ machine.save()
59+ response = self.client.get(self.get_machine_uri(machine))
60+ self.assertEqual(http.client.OK, response.status_code)
61+ parsed_result = json_load_bytes(response.content)
62+ self.assertEqual(
63+ machine.last_sync.isoformat()[:-3], parsed_result["last_sync"]
64+ )
65+ self.assertEqual(machine.sync_interval, parsed_result["sync_interval"])
66+ self.assertEqual(
67+ machine.next_sync.isoformat()[:-3], parsed_result["next_sync"]
68+ )
69+
70 def test_GET_refuses_to_access_nonexistent_machine(self):
71 # When fetching a Machine, the api returns a 'Not Found' (404) error
72 # if no machine is found.
73diff --git a/src/maasserver/migrations/maasserver/0267_add_machine_specific_sync_interval_fields.py b/src/maasserver/migrations/maasserver/0267_add_machine_specific_sync_interval_fields.py
74new file mode 100644
75index 0000000..99d3d99
76--- /dev/null
77+++ b/src/maasserver/migrations/maasserver/0267_add_machine_specific_sync_interval_fields.py
78@@ -0,0 +1,23 @@
79+# Generated by Django 2.2.12 on 2022-03-09 03:54
80+
81+from django.db import migrations, models
82+
83+
84+class Migration(migrations.Migration):
85+
86+ dependencies = [
87+ ("maasserver", "0266_nodedevice_unlink_node"),
88+ ]
89+
90+ operations = [
91+ migrations.AddField(
92+ model_name="node",
93+ name="last_sync",
94+ field=models.DateTimeField(blank=True, null=True),
95+ ),
96+ migrations.AddField(
97+ model_name="node",
98+ name="sync_interval",
99+ field=models.IntegerField(blank=True, null=True),
100+ ),
101+ ]
102diff --git a/src/maasserver/models/node.py b/src/maasserver/models/node.py
103index 00bc489..a4761b2 100644
104--- a/src/maasserver/models/node.py
105+++ b/src/maasserver/models/node.py
106@@ -161,6 +161,7 @@ from maasserver.storage_layouts import (
107 VMFS6StorageLayout,
108 VMFS7StorageLayout,
109 )
110+from maasserver.utils.converters import parse_systemd_interval
111 from maasserver.utils.dns import validate_hostname
112 from maasserver.utils.mac import get_vendor_for_mac
113 from maasserver.utils.orm import (
114@@ -1259,7 +1260,10 @@ class Node(CleanSave, TimestampedModel):
115 "NodeConfig", null=True, on_delete=CASCADE, related_name="+"
116 )
117
118+ # hardware updates
119 enable_hw_sync = BooleanField(default=False)
120+ sync_interval = IntegerField(blank=True, null=True)
121+ last_sync = DateTimeField(blank=True, null=True)
122
123 # Note that the ordering of the managers is meaningful. More precisely,
124 # the first manager defined is important: see
125@@ -1360,6 +1364,12 @@ class Node(CleanSave, TimestampedModel):
126 def is_pod(self):
127 return self.get_hosted_pods().exists()
128
129+ @property
130+ def next_sync(self):
131+ if self.last_sync and self.sync_interval:
132+ return self.last_sync + timedelta(seconds=self.sync_interval)
133+ return None
134+
135 def is_commissioning(self):
136 return self.status not in (NODE_STATUS.DEPLOYED, NODE_STATUS.DEPLOYING)
137
138@@ -1741,6 +1751,8 @@ class Node(CleanSave, TimestampedModel):
139 from maasserver.models.event import Event
140
141 self.status = NODE_STATUS.DEPLOYED
142+ if self.enable_hw_sync:
143+ self.last_sync = datetime.now()
144 self.save()
145
146 # Create a status message for DEPLOYED.
147@@ -2004,6 +2016,11 @@ class Node(CleanSave, TimestampedModel):
148 ):
149 kwargs["update_fields"].append("status_expires")
150
151+ if self.enable_hw_sync and self.sync_interval is None:
152+ self.sync_interval = parse_systemd_interval(
153+ Config.objects.get_config("hardware_sync_interval")
154+ )
155+
156 super().save(*args, **kwargs)
157
158 # We let hostname be blank for the initial save, but fix it before the
159@@ -3690,6 +3707,8 @@ class Node(CleanSave, TimestampedModel):
160 self.install_kvm = False
161 self.register_vmhost = False
162 self.enable_hw_sync = False
163+ self.sync_interval = None
164+ self.last_sync = None
165 self.save()
166
167 # Create a status message for RELEASING.
168diff --git a/src/maasserver/models/tests/test_node.py b/src/maasserver/models/tests/test_node.py
169index 6bace21..b461bc6 100644
170--- a/src/maasserver/models/tests/test_node.py
171+++ b/src/maasserver/models/tests/test_node.py
172@@ -2892,6 +2892,35 @@ class TestNode(MAASServerTestCase):
173 node.release()
174 self.assertFalse(node.enable_hw_sync)
175
176+ def test_release_sets_sync_interval_to_None(self):
177+ node = factory.make_Node(
178+ status=NODE_STATUS.ALLOCATED, enable_hw_sync=True
179+ )
180+ self.patch(node, "_stop")
181+ self.patch(node, "_set_status_expires")
182+ self.assertEqual(
183+ node.sync_interval, timedelta(minutes=15).total_seconds()
184+ )
185+ with post_commit_hooks:
186+ node.release()
187+ self.assertIsNone(node.sync_interval)
188+
189+ def test_sync_interval_is_set_when_enable_hw_sync_is_True(self):
190+ node = factory.make_Node(
191+ status=NODE_STATUS.ALLOCATED, enable_hw_sync=True
192+ )
193+ self.assertEqual(
194+ node.sync_interval, timedelta(minutes=15).total_seconds()
195+ )
196+
197+ def test_next_sync_returns_time_after_last_sync(self):
198+ node = factory.make_Node(
199+ status=NODE_STATUS.ALLOCATED, enable_hw_sync=True
200+ )
201+ node.last_sync = datetime.now()
202+ expected_interval = timedelta(minutes=15)
203+ self.assertEqual(node.next_sync, node.last_sync + expected_interval)
204+
205 def test_dynamic_ip_addresses_from_ip_address_table(self):
206 node = factory.make_Node()
207 interfaces = [
208@@ -4867,6 +4896,15 @@ class TestNode(MAASServerTestCase):
209 self.assertEqual(NODE_STATUS.DEPLOYED, reload_object(node).status)
210 self.assertEqual(event.type.name, EVENT_TYPES.DEPLOYED)
211
212+ def test_end_deployment_sets_first_last_sync_value(self):
213+ self.disable_node_query()
214+ node = factory.make_Node(
215+ status=NODE_STATUS.DEPLOYING, enable_hw_sync=True
216+ )
217+ self.assertIsNone(node.last_sync)
218+ node.end_deployment()
219+ self.assertIsNotNone(node.last_sync)
220+
221 def test_start_deployment_changes_state_and_creates_sts_msg(self):
222 node = factory.make_Node_with_Interface_on_Subnet(
223 status=NODE_STATUS.ALLOCATED
224diff --git a/src/maasserver/utils/converters.py b/src/maasserver/utils/converters.py
225index 690fb61..9aaa91b 100644
226--- a/src/maasserver/utils/converters.py
227+++ b/src/maasserver/utils/converters.py
228@@ -4,7 +4,9 @@
229 """Conversion utilities."""
230
231
232+from datetime import timedelta
233 import json
234+import re
235
236 from django.conf import settings
237 from lxml import etree
238@@ -132,3 +134,17 @@ def json_load_bytes(input: bytes, encoding=None):
239 settings.DEFAULT_CHARSET if encoding is None else encoding
240 )
241 )
242+
243+
244+_duration_re = re.compile(
245+ r"((?P<hours>\d+?)(\s?(hour(s?)|hr|h))\s?)?((?P<minutes>\d+?)(\s?(minute(s?)|min|m))\s?)?((?P<seconds>\d+?)(\s?(second(s?)|sec|s))\s?)?"
246+)
247+
248+
249+def parse_systemd_interval(interval):
250+ duration = _duration_re.match(interval)
251+ if not duration:
252+ raise ValueError("value is not a valid interval")
253+ duration = duration.groupdict()
254+ params = {name: int(t) for name, t in duration.items() if t}
255+ return timedelta(**params).total_seconds()
256diff --git a/src/maasserver/utils/tests/test_converters.py b/src/maasserver/utils/tests/test_converters.py
257index 3b24096..960fb57 100644
258--- a/src/maasserver/utils/tests/test_converters.py
259+++ b/src/maasserver/utils/tests/test_converters.py
260@@ -4,11 +4,13 @@
261 """Tests for converters utilities."""
262
263
264+from datetime import timedelta
265 from textwrap import dedent
266
267 from maasserver.utils.converters import (
268 human_readable_bytes,
269 machine_readable_bytes,
270+ parse_systemd_interval,
271 round_size_to_nearest_block,
272 XMLToYAML,
273 )
274@@ -121,3 +123,42 @@ class TestRoundSizeToNearestBlock(MAASTestCase):
275 round_size_to_nearest_block(size, block_size, False),
276 "Shouldn't remove a block from the size.",
277 )
278+
279+
280+class TestParseSystemdInterval(MAASTestCase):
281+ def _assert_parsed(
282+ self, interval: str, hours: int = 0, minutes: int = 0, seconds: int = 0
283+ ):
284+ expected_seconds = timedelta(
285+ hours=hours, minutes=minutes, seconds=seconds
286+ ).total_seconds()
287+ out = parse_systemd_interval(interval)
288+ self.assertEqual(expected_seconds, out)
289+
290+ def test_parses_full_plural_word_durations(self):
291+ self._assert_parsed(
292+ "2 hours 5 minutes 10 seconds", hours=2, minutes=5, seconds=10
293+ )
294+
295+ def test_parses_full_word_durations(self):
296+ self._assert_parsed(
297+ "1 hour 1 minute 1 second", hours=1, minutes=1, seconds=1
298+ )
299+
300+ def test_parses_abbreviated_durations(self):
301+ self._assert_parsed("1hr 15min 5sec", hours=1, minutes=15, seconds=5)
302+
303+ def test_parses_initials_durations(self):
304+ self._assert_parsed("3h 5m 2s", hours=3, minutes=5, seconds=2)
305+
306+ def test_parses_single_full_plural_duration(self):
307+ self._assert_parsed("5 minutes", minutes=5)
308+
309+ def test_parses_single_full_duration(self):
310+ self._assert_parsed("1 hours", hours=1)
311+
312+ def test_parses_single_abbreviated_duration(self):
313+ self._assert_parsed("5min", minutes=5)
314+
315+ def test_parses_single_initials_duration(self):
316+ self._assert_parsed("15s", seconds=15)
317diff --git a/src/maasserver/websockets/handlers/node.py b/src/maasserver/websockets/handlers/node.py
318index 8877140..74bae70 100644
319--- a/src/maasserver/websockets/handlers/node.py
320+++ b/src/maasserver/websockets/handlers/node.py
321@@ -304,6 +304,19 @@ class NodeHandler(TimestampedModelHandler):
322 else:
323 blockdevices = []
324
325+ if obj.enable_hw_sync:
326+ data.update(
327+ {
328+ "last_sync": obj.last_sync,
329+ "sync_interval": obj.sync_interval,
330+ "next_sync": obj.next_sync,
331+ }
332+ )
333+ else:
334+ for k in ("last_sync", "sync_interval", "next_sync"):
335+ if k in data:
336+ del data[k]
337+
338 if obj.node_type != NODE_TYPE.DEVICE:
339 # These values are not defined on a device.
340 data["architecture"] = obj.architecture
341diff --git a/src/maasserver/websockets/handlers/tests/test_machine.py b/src/maasserver/websockets/handlers/tests/test_machine.py
342index fc3af86..f0a04be 100644
343--- a/src/maasserver/websockets/handlers/tests/test_machine.py
344+++ b/src/maasserver/websockets/handlers/tests/test_machine.py
345@@ -2,6 +2,7 @@
346 # GNU Affero General Public License version 3 (see the file LICENSE).
347
348
349+from datetime import datetime, timedelta
350 from functools import partial
351 import json
352 import logging
353@@ -515,6 +516,15 @@ class TestMachineHandler(MAASServerTestCase):
354 node_script_results
355 )
356
357+ if node.enable_hw_sync:
358+ data.update(
359+ {
360+ "last_sync": node.last_sync,
361+ "sync_interval": node.sync_interval,
362+ "next_sync": node.next_sync,
363+ }
364+ )
365+
366 # Clear cache
367 handler._script_results = {}
368
369@@ -2059,6 +2069,22 @@ class TestMachineHandler(MAASServerTestCase):
370 expected = self.dehydrate_node(node, handler)
371 self.assertEqual(observed, expected)
372
373+ def test_get_hardware_sync_fields(self):
374+ user = factory.make_User()
375+ handler = MachineHandler(user, {}, None)
376+ Config.objects.set_config(name="hardware_sync_interval", value="10m")
377+ node = factory.make_Node_with_Interface_on_Subnet(enable_hw_sync=True)
378+ node.last_sync = datetime.now()
379+ node.save()
380+ observed = handler.get({"system_id": node.system_id})
381+ self.assertEqual(observed["last_sync"], node.last_sync)
382+ self.assertEqual(
383+ observed["sync_interval"], timedelta(minutes=10).total_seconds()
384+ )
385+ self.assertEqual(
386+ observed["next_sync"], node.last_sync + timedelta(minutes=10)
387+ )
388+
389 def test_get_driver_for_series(self):
390 user = factory.make_User()
391 handler = MachineHandler(user, {}, None)

Subscribers

People subscribed via source and target branches