Merge ~mpontillo/maas:disallow-pod-release--bug-1782060 into maas:master

Proposed by Mike Pontillo on 2018-09-20
Status: Merged
Approved by: Mike Pontillo on 2018-09-26
Approved revision: ae91151f656d8b9804d2018e859f81b9ea2b00b0
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~mpontillo/maas:disallow-pod-release--bug-1782060
Merge into: maas:master
Diff against target: 614 lines (+284/-20)
10 files modified
src/maasserver/api/machines.py (+19/-0)
src/maasserver/api/pods.py (+1/-9)
src/maasserver/api/rackcontrollers.py (+8/-3)
src/maasserver/api/regioncontrollers.py (+21/-0)
src/maasserver/api/tests/test_machine.py (+41/-1)
src/maasserver/api/tests/test_rackcontroller.py (+45/-1)
src/maasserver/api/tests/test_regioncontroller.py (+65/-1)
src/maasserver/models/bmc.py (+15/-0)
src/maasserver/models/node.py (+53/-5)
src/maasserver/models/tests/test_node.py (+16/-0)
Reviewer Review Type Date Requested Status
Newell Jensen (community) 2018-09-20 Approve on 2018-09-26
Review via email: mp+355397@code.launchpad.net

Commit message

LP: #1782060 - Don't allow nodes that are hosting pods to be deleted.

To post a comment you must log in.
Blake Rouse (blake-rouse) :
Mike Pontillo (mpontillo) wrote :

!test

Newell Jensen (newell-jensen) wrote :

Looks good. Nicely done.

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 12cbc69..5bbe55d 100644
3--- a/src/maasserver/api/machines.py
4+++ b/src/maasserver/api/machines.py
5@@ -107,6 +107,7 @@ from maasserver.utils.orm import (
6 get_first,
7 reload_object,
8 )
9+from piston3.utils import rc
10 import yaml
11
12 # Machine's fields exposed on the API.
13@@ -320,6 +321,24 @@ class MachineHandler(NodeHandler, OwnerDataMixin, PowerMixin):
14 model = Machine
15 fields = DISPLAYED_MACHINE_FIELDS
16
17+ def delete(self, request, system_id):
18+ """Delete a specific machine.
19+
20+ A machine cannot be deleted if it hosts pod virtual machines.
21+ Use `force` to override this behavior. Forcing deletion will also
22+ remove hosted pods.
23+
24+ Returns 404 if the node is not found.
25+ Returns 403 if the user does not have permission to delete the node.
26+ Returns 400 if the machine cannot be deleted.
27+ Returns 204 if the node is successfully deleted.
28+ """
29+ node = self.model.objects.get_node_or_404(
30+ system_id=system_id, user=request.user, perm=NODE_PERMISSION.ADMIN)
31+ node.as_self().delete(
32+ force=get_optional_param(request.GET, 'force', False, StringBool))
33+ return rc.DELETED
34+
35 @classmethod
36 def boot_disk(handler, machine):
37 """Return the boot_disk for the machine."""
38diff --git a/src/maasserver/api/pods.py b/src/maasserver/api/pods.py
39index 141c9d9..0c6b28c 100644
40--- a/src/maasserver/api/pods.py
41+++ b/src/maasserver/api/pods.py
42@@ -16,14 +16,12 @@ from maasserver.api.support import (
43 OperationsHandler,
44 )
45 from maasserver.api.utils import get_mandatory_param
46-from maasserver.enum import NODE_CREATION_TYPE
47 from maasserver.exceptions import MAASAPIValidationError
48 from maasserver.forms.pods import (
49 ComposeMachineForm,
50 PodForm,
51 )
52 from maasserver.models.bmc import Pod
53-from maasserver.models.node import Machine
54 from maasserver.utils.django_urls import reverse
55 from piston3.utils import rc
56 from provisioningserver.drivers.pod import Capabilities
57@@ -178,13 +176,7 @@ class PodHandler(OperationsHandler):
58 Returns 204 if the pod is successfully deleted.
59 """
60 pod = get_object_or_404(Pod, id=id)
61- # Calculate the wait time based on the number of none pre-existing
62- # machines. We allow maximum of 60 seconds per machine plus 60 seconds
63- # for the pod.
64- num_machines = Machine.objects.filter(bmc=pod)
65- num_machines = num_machines.exclude(
66- creation_type=NODE_CREATION_TYPE.PRE_EXISTING)
67- pod.async_delete().wait((num_machines.count() * 60) + 60)
68+ pod.delete_and_wait()
69 return rc.DELETED
70
71 @admin_method
72diff --git a/src/maasserver/api/rackcontrollers.py b/src/maasserver/api/rackcontrollers.py
73index d09e8ed..ba59020 100644
74--- a/src/maasserver/api/rackcontrollers.py
75+++ b/src/maasserver/api/rackcontrollers.py
76@@ -6,7 +6,6 @@ __all__ = [
77 'RackControllersHandler',
78 ]
79
80-
81 from django.conf import settings
82 from django.http import HttpResponse
83 from formencode.validators import StringBool
84@@ -93,6 +92,12 @@ class RackControllerHandler(NodeHandler, PowerMixin):
85 a `VLAN` and another rack controller cannot be used to provide DHCP for
86 said VLAN. Use `force` to override this behavior.
87
88+ Using `force` will also allow deleting a rack controller that is
89+ hosting pod virtual machines. (The pod will also be deleted.)
90+
91+ Rack controllers that are also region controllers will be converted
92+ to a region controller (and hosted pods will not be affected).
93+
94 :param force: Always delete the rack controller even if its the
95 `primary_rack` on a `VLAN` and another rack controller cannot
96 provide DHCP on said VLAN. This will disable DHCP on those VLANs.
97@@ -100,11 +105,11 @@ class RackControllerHandler(NodeHandler, PowerMixin):
98
99 Returns 404 if the node is not found.
100 Returns 403 if the user does not have permission to delete the node.
101+ Returns 400 if the node cannot be deleted.
102 Returns 204 if the node is successfully deleted.
103 """
104 node = self.model.objects.get_node_or_404(
105- system_id=system_id, user=request.user,
106- perm=NODE_PERMISSION.ADMIN)
107+ system_id=system_id, user=request.user, perm=NODE_PERMISSION.ADMIN)
108 node.as_self().delete(
109 force=get_optional_param(request.GET, 'force', False, StringBool))
110 return rc.DELETED
111diff --git a/src/maasserver/api/regioncontrollers.py b/src/maasserver/api/regioncontrollers.py
112index d366437..7eb0099 100644
113--- a/src/maasserver/api/regioncontrollers.py
114+++ b/src/maasserver/api/regioncontrollers.py
115@@ -6,16 +6,19 @@ __all__ = [
116 'RegionControllersHandler',
117 ]
118
119+from formencode.validators import StringBool
120 from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS
121 from maasserver.api.nodes import (
122 NodeHandler,
123 NodesHandler,
124 )
125 from maasserver.api.support import admin_method
126+from maasserver.api.utils import get_optional_param
127 from maasserver.enum import NODE_PERMISSION
128 from maasserver.exceptions import MAASAPIValidationError
129 from maasserver.forms import ControllerForm
130 from maasserver.models import RegionController
131+from piston3.utils import rc
132
133 # Region controller's fields exposed on the API.
134 DISPLAYED_REGION_CONTROLLER_FIELDS = (
135@@ -68,6 +71,24 @@ class RegionControllerHandler(NodeHandler):
136 model = RegionController
137 fields = DISPLAYED_REGION_CONTROLLER_FIELDS
138
139+ def delete(self, request, system_id):
140+ """Delete a specific region controller.
141+
142+ A region controller cannot be deleted if it hosts pod virtual machines.
143+ Use `force` to override this behavior. Forcing deletion will also
144+ remove hosted pods.
145+
146+ Returns 404 if the node is not found.
147+ Returns 403 if the user does not have permission to delete the node.
148+ Returns 400 if the node cannot be deleted.
149+ Returns 204 if the node is successfully deleted.
150+ """
151+ node = self.model.objects.get_node_or_404(
152+ system_id=system_id, user=request.user, perm=NODE_PERMISSION.ADMIN)
153+ node.as_self().delete(
154+ force=get_optional_param(request.GET, 'force', False, StringBool))
155+ return rc.DELETED
156+
157 @admin_method
158 def update(self, request, system_id):
159 """Update a specific Region controller.
160diff --git a/src/maasserver/api/tests/test_machine.py b/src/maasserver/api/tests/test_machine.py
161index 40f2834..d1979d6 100644
162--- a/src/maasserver/api/tests/test_machine.py
163+++ b/src/maasserver/api/tests/test_machine.py
164@@ -8,10 +8,14 @@ __all__ = []
165 from base64 import b64encode
166 import http.client
167 from random import choice
168-from unittest.mock import ANY
169+from unittest.mock import (
170+ ANY,
171+ call,
172+)
173
174 from django.conf import settings
175 from django.db import transaction
176+from django.utils.http import urlencode
177 from maasserver import forms
178 from maasserver.api import machines as machines_module
179 from maasserver.enum import (
180@@ -35,6 +39,7 @@ from maasserver.models import (
181 node as node_module,
182 StaticIPAddress,
183 )
184+from maasserver.models.bmc import Pod
185 from maasserver.models.node import RELEASABLE_STATUSES
186 from maasserver.models.signals.testing import SignalsDisabled
187 from maasserver.storage_layouts import (
188@@ -44,6 +49,7 @@ from maasserver.storage_layouts import (
189 from maasserver.testing.api import (
190 APITestCase,
191 APITransactionTestCase,
192+ explain_unexpected_response,
193 )
194 from maasserver.testing.architecture import make_usable_architecture
195 from maasserver.testing.factory import factory
196@@ -63,6 +69,7 @@ from maastesting.matchers import (
197 HasLength,
198 MockCalledOnce,
199 MockCalledOnceWith,
200+ MockCallsMatch,
201 MockNotCalled,
202 )
203 from metadataserver.enum import SCRIPT_TYPE
204@@ -1641,6 +1648,39 @@ class TestMachineAPI(APITestCase.ForUser):
205
206 self.assertEqual(http.client.NOT_FOUND, response.status_code)
207
208+ def test_DELETE_delete_with_force(self):
209+ self.become_admin()
210+ vlan = factory.make_VLAN()
211+ subnet = factory.make_Subnet(vlan=vlan)
212+ machine = factory.make_Machine_with_Interface_on_Subnet(
213+ vlan=vlan, subnet=subnet)
214+ ip = factory.make_StaticIPAddress(interface=machine.boot_interface)
215+ factory.make_Pod(ip_address=ip)
216+ mock_async_delete = self.patch(Pod, "async_delete")
217+ response = self.client.delete(
218+ self.get_machine_uri(machine), QUERY_STRING=urlencode({
219+ 'force': 'true'
220+ }, doseq=True))
221+ self.assertEqual(
222+ http.client.NO_CONTENT, response.status_code,
223+ explain_unexpected_response(http.client.NO_CONTENT, response))
224+ self.assertThat(mock_async_delete, MockCallsMatch(call()))
225+
226+ def test_pod_DELETE_delete_without_force(self):
227+ self.become_admin()
228+ vlan = factory.make_VLAN()
229+ subnet = factory.make_Subnet(vlan=vlan)
230+ machine = factory.make_Machine_with_Interface_on_Subnet(
231+ vlan=vlan, subnet=subnet)
232+ ip = factory.make_StaticIPAddress(interface=machine.boot_interface)
233+ factory.make_Pod(ip_address=ip)
234+ mock_async_delete = self.patch(Pod, "async_delete")
235+ response = self.client.delete(self.get_machine_uri(machine))
236+ self.assertEqual(
237+ http.client.BAD_REQUEST, response.status_code,
238+ explain_unexpected_response(http.client.BAD_REQUEST, response))
239+ self.assertThat(mock_async_delete, MockNotCalled())
240+
241
242 class TestMachineAPITransactional(APITransactionTestCase.ForUser):
243 """The following TestMachineAPI tests require APITransactionTestCase."""
244diff --git a/src/maasserver/api/tests/test_rackcontroller.py b/src/maasserver/api/tests/test_rackcontroller.py
245index 1d0a485..686fe82 100644
246--- a/src/maasserver/api/tests/test_rackcontroller.py
247+++ b/src/maasserver/api/tests/test_rackcontroller.py
248@@ -4,11 +4,14 @@
249 """Tests for the Rack Controller API."""
250
251 import http.client
252+from unittest.mock import call
253
254 from django.utils.http import urlencode
255 from maasserver.api import rackcontrollers
256+from maasserver.models.bmc import Pod
257 from maasserver.testing.api import (
258 APITestCase,
259+ APITransactionTestCase,
260 explain_unexpected_response,
261 )
262 from maasserver.testing.factory import factory
263@@ -18,11 +21,12 @@ from maasserver.utils.orm import reload_object
264 from maastesting.matchers import (
265 MockCalledOnce,
266 MockCalledOnceWith,
267+ MockCallsMatch,
268 MockNotCalled,
269 )
270
271
272-class TestRackControllerAPI(APITestCase.ForUser):
273+class TestRackControllerAPI(APITransactionTestCase.ForUser):
274 """Tests for /api/2.0/rackcontrollers/<rack>/."""
275
276 def test_handler_path(self):
277@@ -107,10 +111,14 @@ class TestRackControllerAPI(APITestCase.ForUser):
278 def test_DELETE_delete_with_force(self):
279 self.become_admin()
280 vlan = factory.make_VLAN()
281+ factory.make_Subnet(vlan=vlan)
282 rack = factory.make_RackController(vlan=vlan)
283+ ip = factory.make_StaticIPAddress(interface=rack.interface_set.first())
284+ factory.make_Pod(ip_address=ip)
285 vlan.dhcp_on = True
286 vlan.primary_rack = rack
287 vlan.save()
288+ mock_async_delete = self.patch(Pod, "async_delete")
289 response = self.client.delete(
290 self.get_rack_uri(rack), QUERY_STRING=urlencode({
291 'force': 'true'
292@@ -118,6 +126,42 @@ class TestRackControllerAPI(APITestCase.ForUser):
293 self.assertEqual(
294 http.client.NO_CONTENT, response.status_code,
295 explain_unexpected_response(http.client.NO_CONTENT, response))
296+ self.assertThat(mock_async_delete, MockCallsMatch(call()))
297+
298+ def test_pod_DELETE_delete_without_force(self):
299+ self.become_admin()
300+ vlan = factory.make_VLAN()
301+ factory.make_Subnet(vlan=vlan)
302+ rack = factory.make_RackController(vlan=vlan)
303+ ip = factory.make_StaticIPAddress(interface=rack.interface_set.first())
304+ factory.make_Pod(ip_address=ip)
305+ vlan.dhcp_on = True
306+ vlan.primary_rack = rack
307+ vlan.save()
308+ mock_async_delete = self.patch(Pod, "async_delete")
309+ response = self.client.delete(self.get_rack_uri(rack))
310+ self.assertEqual(
311+ http.client.BAD_REQUEST, response.status_code,
312+ explain_unexpected_response(http.client.BAD_REQUEST, response))
313+ self.assertThat(mock_async_delete, MockNotCalled())
314+
315+ def test_DELETE_force_not_required_for_pod_region_rack(self):
316+ self.become_admin()
317+ vlan = factory.make_VLAN()
318+ factory.make_Subnet(vlan=vlan)
319+ rack = factory.make_RegionRackController(vlan=vlan)
320+ ip = factory.make_StaticIPAddress(
321+ interface=rack.interface_set.first())
322+ factory.make_Pod(ip_address=ip)
323+ mock_async_delete = self.patch(Pod, "async_delete")
324+ response = self.client.delete(
325+ self.get_rack_uri(rack), QUERY_STRING=urlencode({
326+ 'force': 'true'
327+ }, doseq=True))
328+ self.assertEqual(
329+ http.client.NO_CONTENT, response.status_code,
330+ explain_unexpected_response(http.client.NO_CONTENT, response))
331+ self.assertThat(mock_async_delete, MockNotCalled())
332
333
334 class TestRackControllersAPI(APITestCase.ForUser):
335diff --git a/src/maasserver/api/tests/test_regioncontroller.py b/src/maasserver/api/tests/test_regioncontroller.py
336index f37d557..19c7993 100644
337--- a/src/maasserver/api/tests/test_regioncontroller.py
338+++ b/src/maasserver/api/tests/test_regioncontroller.py
339@@ -4,12 +4,23 @@
340 """Tests for the Region Controller API."""
341
342 import http.client
343+from unittest.mock import call
344
345-from maasserver.testing.api import APITestCase
346+from django.utils.http import urlencode
347+from maasserver.enum import NODE_TYPE
348+from maasserver.models.bmc import Pod
349+from maasserver.testing.api import (
350+ APITestCase,
351+ explain_unexpected_response,
352+)
353 from maasserver.testing.factory import factory
354 from maasserver.utils.converters import json_load_bytes
355 from maasserver.utils.django_urls import reverse
356 from maasserver.utils.orm import reload_object
357+from maastesting.matchers import (
358+ MockCallsMatch,
359+ MockNotCalled,
360+)
361
362
363 class TestRegionControllerAPI(APITestCase.ForUser):
364@@ -39,6 +50,59 @@ class TestRegionControllerAPI(APITestCase.ForUser):
365 response = self.client.put(self.get_region_uri(region), {})
366 self.assertEqual(http.client.FORBIDDEN, response.status_code)
367
368+ def test_DELETE_delete_with_force(self):
369+ self.become_admin()
370+ vlan = factory.make_VLAN()
371+ subnet = factory.make_Subnet(vlan=vlan)
372+ region = factory.make_Node_with_Interface_on_Subnet(
373+ node_type=NODE_TYPE.REGION_CONTROLLER, subnet=subnet, vlan=vlan)
374+ ip = factory.make_StaticIPAddress(
375+ interface=region.interface_set.first())
376+ factory.make_Pod(ip_address=ip)
377+ mock_async_delete = self.patch(Pod, "async_delete")
378+ response = self.client.delete(
379+ self.get_region_uri(region), QUERY_STRING=urlencode({
380+ 'force': 'true'
381+ }, doseq=True))
382+ self.assertEqual(
383+ http.client.NO_CONTENT, response.status_code,
384+ explain_unexpected_response(http.client.NO_CONTENT, response))
385+ self.assertThat(mock_async_delete, MockCallsMatch(call()))
386+
387+ def test_DELETE_force_not_required_for_pod_region_rack(self):
388+ self.become_admin()
389+ vlan = factory.make_VLAN()
390+ factory.make_Subnet(vlan=vlan)
391+ rack = factory.make_RegionRackController(vlan=vlan)
392+ ip = factory.make_StaticIPAddress(
393+ interface=rack.interface_set.first())
394+ factory.make_Pod(ip_address=ip)
395+ mock_async_delete = self.patch(Pod, "async_delete")
396+ response = self.client.delete(
397+ self.get_region_uri(rack), QUERY_STRING=urlencode({
398+ 'force': 'true'
399+ }, doseq=True))
400+ self.assertEqual(
401+ http.client.NO_CONTENT, response.status_code,
402+ explain_unexpected_response(http.client.NO_CONTENT, response))
403+ self.assertThat(mock_async_delete, MockNotCalled())
404+
405+ def test_pod_DELETE_delete_without_force(self):
406+ self.become_admin()
407+ vlan = factory.make_VLAN()
408+ subnet = factory.make_Subnet(vlan=vlan)
409+ region = factory.make_Node_with_Interface_on_Subnet(
410+ node_type=NODE_TYPE.REGION_CONTROLLER, subnet=subnet, vlan=vlan)
411+ ip = factory.make_StaticIPAddress(
412+ interface=region.interface_set.first())
413+ factory.make_Pod(ip_address=ip)
414+ mock_async_delete = self.patch(Pod, "async_delete")
415+ response = self.client.delete(self.get_region_uri(region))
416+ self.assertEqual(
417+ http.client.BAD_REQUEST, response.status_code,
418+ explain_unexpected_response(http.client.BAD_REQUEST, response))
419+ self.assertThat(mock_async_delete, MockNotCalled())
420+
421
422 class TestRegionControllersAPI(APITestCase.ForUser):
423 """Tests for /api/2.0/regioncontrollers/."""
424diff --git a/src/maasserver/models/bmc.py b/src/maasserver/models/bmc.py
425index addc187..2b80690 100644
426--- a/src/maasserver/models/bmc.py
427+++ b/src/maasserver/models/bmc.py
428@@ -1282,6 +1282,21 @@ class Pod(BMC):
429 "Use `async_delete` instead. Deleting a Pod takes "
430 "an asynchronous action.")
431
432+ def delete_and_wait(self):
433+ """Block the current thread while waiting for the pod to be deleted.
434+
435+ This must not be called from a deferToDatabase thread; use the
436+ async_delete() method instead.
437+ """
438+ # Calculate the wait time based on the number of none pre-existing
439+ # machines. We allow maximum of 60 seconds per machine plus 60 seconds
440+ # for the pod.
441+ pod = self.as_pod()
442+ num_machines = Machine.objects.filter(bmc=pod)
443+ num_machines = num_machines.exclude(
444+ creation_type=NODE_CREATION_TYPE.PRE_EXISTING)
445+ pod.async_delete().wait((num_machines.count() * 60) + 60)
446+
447 @asynchronous
448 def async_delete(self):
449 """Delete a pod asynchronously.
450diff --git a/src/maasserver/models/node.py b/src/maasserver/models/node.py
451index b431d10..9e0b087 100644
452--- a/src/maasserver/models/node.py
453+++ b/src/maasserver/models/node.py
454@@ -235,12 +235,14 @@ from provisioningserver.utils.twisted import (
455 synchronous,
456 undefined,
457 )
458+from twisted.internet import reactor
459 from twisted.internet.defer import (
460 Deferred,
461 inlineCallbacks,
462 succeed,
463 )
464 from twisted.internet.threads import deferToThread
465+from twisted.python.threadable import isInIOThread
466
467
468 log = LegacyLogger()
469@@ -2362,7 +2364,7 @@ class Node(CleanSave, TimestampedModel):
470 callOut, maaslog.warning, "%s: Could not stop node to abort "
471 "deployment; it must be stopped manually", hostname)
472
473- def delete(self):
474+ def delete(self, *args, **kwargs):
475 """Delete this node."""
476 bmc = self.bmc
477 if (self.node_type == NODE_TYPE.MACHINE and
478@@ -2414,7 +2416,7 @@ class Node(CleanSave, TimestampedModel):
479 "%s: Deleting my BMC '%s'", self.hostname, self.bmc)
480 self.bmc.delete()
481
482- super(Node, self).delete()
483+ super(Node, self).delete(*args, **kwargs)
484
485 def set_random_hostname(self):
486 """Set a random `hostname`."""
487@@ -2966,10 +2968,11 @@ class Node(CleanSave, TimestampedModel):
488 OwnerData.objects.filter(node=self).delete()
489
490 def release_or_erase(
491- self, user, comment=None,
492- erase=False, secure_erase=None, quick_erase=None):
493+ self, user, comment=None, erase=False, secure_erase=None,
494+ quick_erase=None, force=False):
495 """Either release the node or erase the node then release it, depending
496 on settings and parameters."""
497+ self.maybe_delete_pods(not force)
498 erase_on_release = Config.objects.get_config(
499 'enable_disk_erasing_on_release')
500 if erase or erase_on_release:
501@@ -2979,6 +2982,27 @@ class Node(CleanSave, TimestampedModel):
502 else:
503 self.release(user, comment)
504
505+ def maybe_delete_pods(self, dry_run: bool):
506+ """Check if any pods are associated with this Node.
507+
508+ All pods will be deleted if dry_run=False is passed in.
509+
510+ :param dry_run: If True, raises NodeActionError rather than deleting
511+ pods.
512+ """
513+ hosted_pods = list(
514+ self.get_hosted_pods().values_list('name', flat=True))
515+ if len(hosted_pods) > 0:
516+ if dry_run:
517+ raise ValidationError(
518+ "The following pods must be removed first: %s" % (
519+ ", ".join(hosted_pods)))
520+ for pod in self.get_hosted_pods():
521+ if isInIOThread():
522+ pod.async_delete()
523+ else:
524+ reactor.callFromThread(pod.async_delete)
525+
526 def set_netboot(self, on=True):
527 """Set netboot on or off."""
528 log.debug(
529@@ -4397,6 +4421,13 @@ class Node(CleanSave, TimestampedModel):
530 else:
531 return script_result.stdout.decode('utf-8').splitlines()
532
533+ def get_hosted_pods(self) -> QuerySet:
534+ # Circular imports
535+ from maasserver.models import Pod
536+ our_static_ips = StaticIPAddress.objects.filter(
537+ interface__node=self).values_list('ip')
538+ return Pod.objects.filter(ip_address__ip__in=our_static_ips)
539+
540
541 # Piston serializes objects based on the object class.
542 # Here we define a proxy class so that we can specialize how devices are
543@@ -4413,6 +4444,17 @@ class Machine(Node):
544 super(Machine, self).__init__(
545 node_type=NODE_TYPE.MACHINE, *args, **kwargs)
546
547+ def delete(self, force=False):
548+ """Deletes this Machine.
549+
550+ Before deletion, checks if any hosted pods exist.
551+
552+ Raises ValidationError if the machine is a host for one or more pods,
553+ and `force=True` was not specified.
554+ """
555+ self.maybe_delete_pods(not force)
556+ return super().delete()
557+
558
559 class Controller(Node):
560 """A node which is either a rack or region controller."""
561@@ -5352,6 +5394,11 @@ class RackController(Controller):
562
563 def delete(self, force=False):
564 """Delete this rack controller."""
565+ # Don't bother with the pod check if this is a region+rack, because
566+ # deleting a region+rack results in a region-only controller.
567+ if self.node_type != NODE_TYPE.REGION_AND_RACK_CONTROLLER:
568+ self.maybe_delete_pods(not force)
569+
570 # Avoid circular imports
571 from maasserver.models import RegionRackRPCConnection
572
573@@ -5518,8 +5565,9 @@ class RegionController(Controller):
574 super(RegionController, self).__init__(
575 node_type=NODE_TYPE.REGION_CONTROLLER, *args, **kwargs)
576
577- def delete(self):
578+ def delete(self, force=False):
579 """Delete this region controller."""
580+ self.maybe_delete_pods(not force)
581 # Avoid circular dependency.
582 from maasserver.models import RegionControllerProcess
583 connections = RegionControllerProcess.objects.filter(
584diff --git a/src/maasserver/models/tests/test_node.py b/src/maasserver/models/tests/test_node.py
585index 5b367ae..e596dd8 100644
586--- a/src/maasserver/models/tests/test_node.py
587+++ b/src/maasserver/models/tests/test_node.py
588@@ -34,6 +34,7 @@ from django.core.exceptions import (
589 )
590 from django.db import transaction
591 from django.db.models.deletion import Collector
592+from django.db.models.query import QuerySet
593 from fixtures import LoggerFixture
594 from maasserver import (
595 bootresources,
596@@ -10844,3 +10845,18 @@ class TestControllerGetDiscoveryState(MAASServerTestCase):
597 self.assertThat(monitoring_state, Contains('eth2'))
598 self.assertThat(
599 monitoring_state['eth1'], Equals(eth1.get_discovery_state()))
600+
601+
602+class TestNodeGetHostedPods(MAASServerTestCase):
603+
604+ def test__returns_queryset(self):
605+ node = factory.make_Node()
606+ pods = node.get_hosted_pods()
607+ self.assertThat(pods, IsInstance(QuerySet))
608+
609+ def test__returns_related_pods(self):
610+ node = factory.make_Node_with_Interface_on_Subnet()
611+ ip = factory.make_StaticIPAddress(interface=node.boot_interface)
612+ pod = factory.make_Pod(ip_address=ip)
613+ pods = node.get_hosted_pods()
614+ self.assertThat(pods, Contains(pod))

Subscribers

People subscribed via source and target branches