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
diff --git a/src/maasserver/api/machines.py b/src/maasserver/api/machines.py
index 12cbc69..5bbe55d 100644
--- a/src/maasserver/api/machines.py
+++ b/src/maasserver/api/machines.py
@@ -107,6 +107,7 @@ from maasserver.utils.orm import (
107 get_first,107 get_first,
108 reload_object,108 reload_object,
109)109)
110from piston3.utils import rc
110import yaml111import yaml
111112
112# Machine's fields exposed on the API.113# Machine's fields exposed on the API.
@@ -320,6 +321,24 @@ class MachineHandler(NodeHandler, OwnerDataMixin, PowerMixin):
320 model = Machine321 model = Machine
321 fields = DISPLAYED_MACHINE_FIELDS322 fields = DISPLAYED_MACHINE_FIELDS
322323
324 def delete(self, request, system_id):
325 """Delete a specific machine.
326
327 A machine cannot be deleted if it hosts pod virtual machines.
328 Use `force` to override this behavior. Forcing deletion will also
329 remove hosted pods.
330
331 Returns 404 if the node is not found.
332 Returns 403 if the user does not have permission to delete the node.
333 Returns 400 if the machine cannot be deleted.
334 Returns 204 if the node is successfully deleted.
335 """
336 node = self.model.objects.get_node_or_404(
337 system_id=system_id, user=request.user, perm=NODE_PERMISSION.ADMIN)
338 node.as_self().delete(
339 force=get_optional_param(request.GET, 'force', False, StringBool))
340 return rc.DELETED
341
323 @classmethod342 @classmethod
324 def boot_disk(handler, machine):343 def boot_disk(handler, machine):
325 """Return the boot_disk for the machine."""344 """Return the boot_disk for the machine."""
diff --git a/src/maasserver/api/pods.py b/src/maasserver/api/pods.py
index 141c9d9..0c6b28c 100644
--- a/src/maasserver/api/pods.py
+++ b/src/maasserver/api/pods.py
@@ -16,14 +16,12 @@ from maasserver.api.support import (
16 OperationsHandler,16 OperationsHandler,
17)17)
18from maasserver.api.utils import get_mandatory_param18from maasserver.api.utils import get_mandatory_param
19from maasserver.enum import NODE_CREATION_TYPE
20from maasserver.exceptions import MAASAPIValidationError19from maasserver.exceptions import MAASAPIValidationError
21from maasserver.forms.pods import (20from maasserver.forms.pods import (
22 ComposeMachineForm,21 ComposeMachineForm,
23 PodForm,22 PodForm,
24)23)
25from maasserver.models.bmc import Pod24from maasserver.models.bmc import Pod
26from maasserver.models.node import Machine
27from maasserver.utils.django_urls import reverse25from maasserver.utils.django_urls import reverse
28from piston3.utils import rc26from piston3.utils import rc
29from provisioningserver.drivers.pod import Capabilities27from provisioningserver.drivers.pod import Capabilities
@@ -178,13 +176,7 @@ class PodHandler(OperationsHandler):
178 Returns 204 if the pod is successfully deleted.176 Returns 204 if the pod is successfully deleted.
179 """177 """
180 pod = get_object_or_404(Pod, id=id)178 pod = get_object_or_404(Pod, id=id)
181 # Calculate the wait time based on the number of none pre-existing179 pod.delete_and_wait()
182 # machines. We allow maximum of 60 seconds per machine plus 60 seconds
183 # for the pod.
184 num_machines = Machine.objects.filter(bmc=pod)
185 num_machines = num_machines.exclude(
186 creation_type=NODE_CREATION_TYPE.PRE_EXISTING)
187 pod.async_delete().wait((num_machines.count() * 60) + 60)
188 return rc.DELETED180 return rc.DELETED
189181
190 @admin_method182 @admin_method
diff --git a/src/maasserver/api/rackcontrollers.py b/src/maasserver/api/rackcontrollers.py
index d09e8ed..ba59020 100644
--- a/src/maasserver/api/rackcontrollers.py
+++ b/src/maasserver/api/rackcontrollers.py
@@ -6,7 +6,6 @@ __all__ = [
6 'RackControllersHandler',6 'RackControllersHandler',
7 ]7 ]
88
9
10from django.conf import settings9from django.conf import settings
11from django.http import HttpResponse10from django.http import HttpResponse
12from formencode.validators import StringBool11from formencode.validators import StringBool
@@ -93,6 +92,12 @@ class RackControllerHandler(NodeHandler, PowerMixin):
93 a `VLAN` and another rack controller cannot be used to provide DHCP for92 a `VLAN` and another rack controller cannot be used to provide DHCP for
94 said VLAN. Use `force` to override this behavior.93 said VLAN. Use `force` to override this behavior.
9594
95 Using `force` will also allow deleting a rack controller that is
96 hosting pod virtual machines. (The pod will also be deleted.)
97
98 Rack controllers that are also region controllers will be converted
99 to a region controller (and hosted pods will not be affected).
100
96 :param force: Always delete the rack controller even if its the101 :param force: Always delete the rack controller even if its the
97 `primary_rack` on a `VLAN` and another rack controller cannot102 `primary_rack` on a `VLAN` and another rack controller cannot
98 provide DHCP on said VLAN. This will disable DHCP on those VLANs.103 provide DHCP on said VLAN. This will disable DHCP on those VLANs.
@@ -100,11 +105,11 @@ class RackControllerHandler(NodeHandler, PowerMixin):
100105
101 Returns 404 if the node is not found.106 Returns 404 if the node is not found.
102 Returns 403 if the user does not have permission to delete the node.107 Returns 403 if the user does not have permission to delete the node.
108 Returns 400 if the node cannot be deleted.
103 Returns 204 if the node is successfully deleted.109 Returns 204 if the node is successfully deleted.
104 """110 """
105 node = self.model.objects.get_node_or_404(111 node = self.model.objects.get_node_or_404(
106 system_id=system_id, user=request.user,112 system_id=system_id, user=request.user, perm=NODE_PERMISSION.ADMIN)
107 perm=NODE_PERMISSION.ADMIN)
108 node.as_self().delete(113 node.as_self().delete(
109 force=get_optional_param(request.GET, 'force', False, StringBool))114 force=get_optional_param(request.GET, 'force', False, StringBool))
110 return rc.DELETED115 return rc.DELETED
diff --git a/src/maasserver/api/regioncontrollers.py b/src/maasserver/api/regioncontrollers.py
index d366437..7eb0099 100644
--- a/src/maasserver/api/regioncontrollers.py
+++ b/src/maasserver/api/regioncontrollers.py
@@ -6,16 +6,19 @@ __all__ = [
6 'RegionControllersHandler',6 'RegionControllersHandler',
7 ]7 ]
88
9from formencode.validators import StringBool
9from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS10from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS
10from maasserver.api.nodes import (11from maasserver.api.nodes import (
11 NodeHandler,12 NodeHandler,
12 NodesHandler,13 NodesHandler,
13)14)
14from maasserver.api.support import admin_method15from maasserver.api.support import admin_method
16from maasserver.api.utils import get_optional_param
15from maasserver.enum import NODE_PERMISSION17from maasserver.enum import NODE_PERMISSION
16from maasserver.exceptions import MAASAPIValidationError18from maasserver.exceptions import MAASAPIValidationError
17from maasserver.forms import ControllerForm19from maasserver.forms import ControllerForm
18from maasserver.models import RegionController20from maasserver.models import RegionController
21from piston3.utils import rc
1922
20# Region controller's fields exposed on the API.23# Region controller's fields exposed on the API.
21DISPLAYED_REGION_CONTROLLER_FIELDS = (24DISPLAYED_REGION_CONTROLLER_FIELDS = (
@@ -68,6 +71,24 @@ class RegionControllerHandler(NodeHandler):
68 model = RegionController71 model = RegionController
69 fields = DISPLAYED_REGION_CONTROLLER_FIELDS72 fields = DISPLAYED_REGION_CONTROLLER_FIELDS
7073
74 def delete(self, request, system_id):
75 """Delete a specific region controller.
76
77 A region controller cannot be deleted if it hosts pod virtual machines.
78 Use `force` to override this behavior. Forcing deletion will also
79 remove hosted pods.
80
81 Returns 404 if the node is not found.
82 Returns 403 if the user does not have permission to delete the node.
83 Returns 400 if the node cannot be deleted.
84 Returns 204 if the node is successfully deleted.
85 """
86 node = self.model.objects.get_node_or_404(
87 system_id=system_id, user=request.user, perm=NODE_PERMISSION.ADMIN)
88 node.as_self().delete(
89 force=get_optional_param(request.GET, 'force', False, StringBool))
90 return rc.DELETED
91
71 @admin_method92 @admin_method
72 def update(self, request, system_id):93 def update(self, request, system_id):
73 """Update a specific Region controller.94 """Update a specific Region controller.
diff --git a/src/maasserver/api/tests/test_machine.py b/src/maasserver/api/tests/test_machine.py
index 40f2834..d1979d6 100644
--- a/src/maasserver/api/tests/test_machine.py
+++ b/src/maasserver/api/tests/test_machine.py
@@ -8,10 +8,14 @@ __all__ = []
8from base64 import b64encode8from base64 import b64encode
9import http.client9import http.client
10from random import choice10from random import choice
11from unittest.mock import ANY11from unittest.mock import (
12 ANY,
13 call,
14)
1215
13from django.conf import settings16from django.conf import settings
14from django.db import transaction17from django.db import transaction
18from django.utils.http import urlencode
15from maasserver import forms19from maasserver import forms
16from maasserver.api import machines as machines_module20from maasserver.api import machines as machines_module
17from maasserver.enum import (21from maasserver.enum import (
@@ -35,6 +39,7 @@ from maasserver.models import (
35 node as node_module,39 node as node_module,
36 StaticIPAddress,40 StaticIPAddress,
37)41)
42from maasserver.models.bmc import Pod
38from maasserver.models.node import RELEASABLE_STATUSES43from maasserver.models.node import RELEASABLE_STATUSES
39from maasserver.models.signals.testing import SignalsDisabled44from maasserver.models.signals.testing import SignalsDisabled
40from maasserver.storage_layouts import (45from maasserver.storage_layouts import (
@@ -44,6 +49,7 @@ from maasserver.storage_layouts import (
44from maasserver.testing.api import (49from maasserver.testing.api import (
45 APITestCase,50 APITestCase,
46 APITransactionTestCase,51 APITransactionTestCase,
52 explain_unexpected_response,
47)53)
48from maasserver.testing.architecture import make_usable_architecture54from maasserver.testing.architecture import make_usable_architecture
49from maasserver.testing.factory import factory55from maasserver.testing.factory import factory
@@ -63,6 +69,7 @@ from maastesting.matchers import (
63 HasLength,69 HasLength,
64 MockCalledOnce,70 MockCalledOnce,
65 MockCalledOnceWith,71 MockCalledOnceWith,
72 MockCallsMatch,
66 MockNotCalled,73 MockNotCalled,
67)74)
68from metadataserver.enum import SCRIPT_TYPE75from metadataserver.enum import SCRIPT_TYPE
@@ -1641,6 +1648,39 @@ class TestMachineAPI(APITestCase.ForUser):
16411648
1642 self.assertEqual(http.client.NOT_FOUND, response.status_code)1649 self.assertEqual(http.client.NOT_FOUND, response.status_code)
16431650
1651 def test_DELETE_delete_with_force(self):
1652 self.become_admin()
1653 vlan = factory.make_VLAN()
1654 subnet = factory.make_Subnet(vlan=vlan)
1655 machine = factory.make_Machine_with_Interface_on_Subnet(
1656 vlan=vlan, subnet=subnet)
1657 ip = factory.make_StaticIPAddress(interface=machine.boot_interface)
1658 factory.make_Pod(ip_address=ip)
1659 mock_async_delete = self.patch(Pod, "async_delete")
1660 response = self.client.delete(
1661 self.get_machine_uri(machine), QUERY_STRING=urlencode({
1662 'force': 'true'
1663 }, doseq=True))
1664 self.assertEqual(
1665 http.client.NO_CONTENT, response.status_code,
1666 explain_unexpected_response(http.client.NO_CONTENT, response))
1667 self.assertThat(mock_async_delete, MockCallsMatch(call()))
1668
1669 def test_pod_DELETE_delete_without_force(self):
1670 self.become_admin()
1671 vlan = factory.make_VLAN()
1672 subnet = factory.make_Subnet(vlan=vlan)
1673 machine = factory.make_Machine_with_Interface_on_Subnet(
1674 vlan=vlan, subnet=subnet)
1675 ip = factory.make_StaticIPAddress(interface=machine.boot_interface)
1676 factory.make_Pod(ip_address=ip)
1677 mock_async_delete = self.patch(Pod, "async_delete")
1678 response = self.client.delete(self.get_machine_uri(machine))
1679 self.assertEqual(
1680 http.client.BAD_REQUEST, response.status_code,
1681 explain_unexpected_response(http.client.BAD_REQUEST, response))
1682 self.assertThat(mock_async_delete, MockNotCalled())
1683
16441684
1645class TestMachineAPITransactional(APITransactionTestCase.ForUser):1685class TestMachineAPITransactional(APITransactionTestCase.ForUser):
1646 """The following TestMachineAPI tests require APITransactionTestCase."""1686 """The following TestMachineAPI tests require APITransactionTestCase."""
diff --git a/src/maasserver/api/tests/test_rackcontroller.py b/src/maasserver/api/tests/test_rackcontroller.py
index 1d0a485..686fe82 100644
--- a/src/maasserver/api/tests/test_rackcontroller.py
+++ b/src/maasserver/api/tests/test_rackcontroller.py
@@ -4,11 +4,14 @@
4"""Tests for the Rack Controller API."""4"""Tests for the Rack Controller API."""
55
6import http.client6import http.client
7from unittest.mock import call
78
8from django.utils.http import urlencode9from django.utils.http import urlencode
9from maasserver.api import rackcontrollers10from maasserver.api import rackcontrollers
11from maasserver.models.bmc import Pod
10from maasserver.testing.api import (12from maasserver.testing.api import (
11 APITestCase,13 APITestCase,
14 APITransactionTestCase,
12 explain_unexpected_response,15 explain_unexpected_response,
13)16)
14from maasserver.testing.factory import factory17from maasserver.testing.factory import factory
@@ -18,11 +21,12 @@ from maasserver.utils.orm import reload_object
18from maastesting.matchers import (21from maastesting.matchers import (
19 MockCalledOnce,22 MockCalledOnce,
20 MockCalledOnceWith,23 MockCalledOnceWith,
24 MockCallsMatch,
21 MockNotCalled,25 MockNotCalled,
22)26)
2327
2428
25class TestRackControllerAPI(APITestCase.ForUser):29class TestRackControllerAPI(APITransactionTestCase.ForUser):
26 """Tests for /api/2.0/rackcontrollers/<rack>/."""30 """Tests for /api/2.0/rackcontrollers/<rack>/."""
2731
28 def test_handler_path(self):32 def test_handler_path(self):
@@ -107,10 +111,14 @@ class TestRackControllerAPI(APITestCase.ForUser):
107 def test_DELETE_delete_with_force(self):111 def test_DELETE_delete_with_force(self):
108 self.become_admin()112 self.become_admin()
109 vlan = factory.make_VLAN()113 vlan = factory.make_VLAN()
114 factory.make_Subnet(vlan=vlan)
110 rack = factory.make_RackController(vlan=vlan)115 rack = factory.make_RackController(vlan=vlan)
116 ip = factory.make_StaticIPAddress(interface=rack.interface_set.first())
117 factory.make_Pod(ip_address=ip)
111 vlan.dhcp_on = True118 vlan.dhcp_on = True
112 vlan.primary_rack = rack119 vlan.primary_rack = rack
113 vlan.save()120 vlan.save()
121 mock_async_delete = self.patch(Pod, "async_delete")
114 response = self.client.delete(122 response = self.client.delete(
115 self.get_rack_uri(rack), QUERY_STRING=urlencode({123 self.get_rack_uri(rack), QUERY_STRING=urlencode({
116 'force': 'true'124 'force': 'true'
@@ -118,6 +126,42 @@ class TestRackControllerAPI(APITestCase.ForUser):
118 self.assertEqual(126 self.assertEqual(
119 http.client.NO_CONTENT, response.status_code,127 http.client.NO_CONTENT, response.status_code,
120 explain_unexpected_response(http.client.NO_CONTENT, response))128 explain_unexpected_response(http.client.NO_CONTENT, response))
129 self.assertThat(mock_async_delete, MockCallsMatch(call()))
130
131 def test_pod_DELETE_delete_without_force(self):
132 self.become_admin()
133 vlan = factory.make_VLAN()
134 factory.make_Subnet(vlan=vlan)
135 rack = factory.make_RackController(vlan=vlan)
136 ip = factory.make_StaticIPAddress(interface=rack.interface_set.first())
137 factory.make_Pod(ip_address=ip)
138 vlan.dhcp_on = True
139 vlan.primary_rack = rack
140 vlan.save()
141 mock_async_delete = self.patch(Pod, "async_delete")
142 response = self.client.delete(self.get_rack_uri(rack))
143 self.assertEqual(
144 http.client.BAD_REQUEST, response.status_code,
145 explain_unexpected_response(http.client.BAD_REQUEST, response))
146 self.assertThat(mock_async_delete, MockNotCalled())
147
148 def test_DELETE_force_not_required_for_pod_region_rack(self):
149 self.become_admin()
150 vlan = factory.make_VLAN()
151 factory.make_Subnet(vlan=vlan)
152 rack = factory.make_RegionRackController(vlan=vlan)
153 ip = factory.make_StaticIPAddress(
154 interface=rack.interface_set.first())
155 factory.make_Pod(ip_address=ip)
156 mock_async_delete = self.patch(Pod, "async_delete")
157 response = self.client.delete(
158 self.get_rack_uri(rack), QUERY_STRING=urlencode({
159 'force': 'true'
160 }, doseq=True))
161 self.assertEqual(
162 http.client.NO_CONTENT, response.status_code,
163 explain_unexpected_response(http.client.NO_CONTENT, response))
164 self.assertThat(mock_async_delete, MockNotCalled())
121165
122166
123class TestRackControllersAPI(APITestCase.ForUser):167class TestRackControllersAPI(APITestCase.ForUser):
diff --git a/src/maasserver/api/tests/test_regioncontroller.py b/src/maasserver/api/tests/test_regioncontroller.py
index f37d557..19c7993 100644
--- a/src/maasserver/api/tests/test_regioncontroller.py
+++ b/src/maasserver/api/tests/test_regioncontroller.py
@@ -4,12 +4,23 @@
4"""Tests for the Region Controller API."""4"""Tests for the Region Controller API."""
55
6import http.client6import http.client
7from unittest.mock import call
78
8from maasserver.testing.api import APITestCase9from django.utils.http import urlencode
10from maasserver.enum import NODE_TYPE
11from maasserver.models.bmc import Pod
12from maasserver.testing.api import (
13 APITestCase,
14 explain_unexpected_response,
15)
9from maasserver.testing.factory import factory16from maasserver.testing.factory import factory
10from maasserver.utils.converters import json_load_bytes17from maasserver.utils.converters import json_load_bytes
11from maasserver.utils.django_urls import reverse18from maasserver.utils.django_urls import reverse
12from maasserver.utils.orm import reload_object19from maasserver.utils.orm import reload_object
20from maastesting.matchers import (
21 MockCallsMatch,
22 MockNotCalled,
23)
1324
1425
15class TestRegionControllerAPI(APITestCase.ForUser):26class TestRegionControllerAPI(APITestCase.ForUser):
@@ -39,6 +50,59 @@ class TestRegionControllerAPI(APITestCase.ForUser):
39 response = self.client.put(self.get_region_uri(region), {})50 response = self.client.put(self.get_region_uri(region), {})
40 self.assertEqual(http.client.FORBIDDEN, response.status_code)51 self.assertEqual(http.client.FORBIDDEN, response.status_code)
4152
53 def test_DELETE_delete_with_force(self):
54 self.become_admin()
55 vlan = factory.make_VLAN()
56 subnet = factory.make_Subnet(vlan=vlan)
57 region = factory.make_Node_with_Interface_on_Subnet(
58 node_type=NODE_TYPE.REGION_CONTROLLER, subnet=subnet, vlan=vlan)
59 ip = factory.make_StaticIPAddress(
60 interface=region.interface_set.first())
61 factory.make_Pod(ip_address=ip)
62 mock_async_delete = self.patch(Pod, "async_delete")
63 response = self.client.delete(
64 self.get_region_uri(region), QUERY_STRING=urlencode({
65 'force': 'true'
66 }, doseq=True))
67 self.assertEqual(
68 http.client.NO_CONTENT, response.status_code,
69 explain_unexpected_response(http.client.NO_CONTENT, response))
70 self.assertThat(mock_async_delete, MockCallsMatch(call()))
71
72 def test_DELETE_force_not_required_for_pod_region_rack(self):
73 self.become_admin()
74 vlan = factory.make_VLAN()
75 factory.make_Subnet(vlan=vlan)
76 rack = factory.make_RegionRackController(vlan=vlan)
77 ip = factory.make_StaticIPAddress(
78 interface=rack.interface_set.first())
79 factory.make_Pod(ip_address=ip)
80 mock_async_delete = self.patch(Pod, "async_delete")
81 response = self.client.delete(
82 self.get_region_uri(rack), QUERY_STRING=urlencode({
83 'force': 'true'
84 }, doseq=True))
85 self.assertEqual(
86 http.client.NO_CONTENT, response.status_code,
87 explain_unexpected_response(http.client.NO_CONTENT, response))
88 self.assertThat(mock_async_delete, MockNotCalled())
89
90 def test_pod_DELETE_delete_without_force(self):
91 self.become_admin()
92 vlan = factory.make_VLAN()
93 subnet = factory.make_Subnet(vlan=vlan)
94 region = factory.make_Node_with_Interface_on_Subnet(
95 node_type=NODE_TYPE.REGION_CONTROLLER, subnet=subnet, vlan=vlan)
96 ip = factory.make_StaticIPAddress(
97 interface=region.interface_set.first())
98 factory.make_Pod(ip_address=ip)
99 mock_async_delete = self.patch(Pod, "async_delete")
100 response = self.client.delete(self.get_region_uri(region))
101 self.assertEqual(
102 http.client.BAD_REQUEST, response.status_code,
103 explain_unexpected_response(http.client.BAD_REQUEST, response))
104 self.assertThat(mock_async_delete, MockNotCalled())
105
42106
43class TestRegionControllersAPI(APITestCase.ForUser):107class TestRegionControllersAPI(APITestCase.ForUser):
44 """Tests for /api/2.0/regioncontrollers/."""108 """Tests for /api/2.0/regioncontrollers/."""
diff --git a/src/maasserver/models/bmc.py b/src/maasserver/models/bmc.py
index addc187..2b80690 100644
--- a/src/maasserver/models/bmc.py
+++ b/src/maasserver/models/bmc.py
@@ -1282,6 +1282,21 @@ class Pod(BMC):
1282 "Use `async_delete` instead. Deleting a Pod takes "1282 "Use `async_delete` instead. Deleting a Pod takes "
1283 "an asynchronous action.")1283 "an asynchronous action.")
12841284
1285 def delete_and_wait(self):
1286 """Block the current thread while waiting for the pod to be deleted.
1287
1288 This must not be called from a deferToDatabase thread; use the
1289 async_delete() method instead.
1290 """
1291 # Calculate the wait time based on the number of none pre-existing
1292 # machines. We allow maximum of 60 seconds per machine plus 60 seconds
1293 # for the pod.
1294 pod = self.as_pod()
1295 num_machines = Machine.objects.filter(bmc=pod)
1296 num_machines = num_machines.exclude(
1297 creation_type=NODE_CREATION_TYPE.PRE_EXISTING)
1298 pod.async_delete().wait((num_machines.count() * 60) + 60)
1299
1285 @asynchronous1300 @asynchronous
1286 def async_delete(self):1301 def async_delete(self):
1287 """Delete a pod asynchronously.1302 """Delete a pod asynchronously.
diff --git a/src/maasserver/models/node.py b/src/maasserver/models/node.py
index b431d10..9e0b087 100644
--- a/src/maasserver/models/node.py
+++ b/src/maasserver/models/node.py
@@ -235,12 +235,14 @@ from provisioningserver.utils.twisted import (
235 synchronous,235 synchronous,
236 undefined,236 undefined,
237)237)
238from twisted.internet import reactor
238from twisted.internet.defer import (239from twisted.internet.defer import (
239 Deferred,240 Deferred,
240 inlineCallbacks,241 inlineCallbacks,
241 succeed,242 succeed,
242)243)
243from twisted.internet.threads import deferToThread244from twisted.internet.threads import deferToThread
245from twisted.python.threadable import isInIOThread
244246
245247
246log = LegacyLogger()248log = LegacyLogger()
@@ -2362,7 +2364,7 @@ class Node(CleanSave, TimestampedModel):
2362 callOut, maaslog.warning, "%s: Could not stop node to abort "2364 callOut, maaslog.warning, "%s: Could not stop node to abort "
2363 "deployment; it must be stopped manually", hostname)2365 "deployment; it must be stopped manually", hostname)
23642366
2365 def delete(self):2367 def delete(self, *args, **kwargs):
2366 """Delete this node."""2368 """Delete this node."""
2367 bmc = self.bmc2369 bmc = self.bmc
2368 if (self.node_type == NODE_TYPE.MACHINE and2370 if (self.node_type == NODE_TYPE.MACHINE and
@@ -2414,7 +2416,7 @@ class Node(CleanSave, TimestampedModel):
2414 "%s: Deleting my BMC '%s'", self.hostname, self.bmc)2416 "%s: Deleting my BMC '%s'", self.hostname, self.bmc)
2415 self.bmc.delete()2417 self.bmc.delete()
24162418
2417 super(Node, self).delete()2419 super(Node, self).delete(*args, **kwargs)
24182420
2419 def set_random_hostname(self):2421 def set_random_hostname(self):
2420 """Set a random `hostname`."""2422 """Set a random `hostname`."""
@@ -2966,10 +2968,11 @@ class Node(CleanSave, TimestampedModel):
2966 OwnerData.objects.filter(node=self).delete()2968 OwnerData.objects.filter(node=self).delete()
29672969
2968 def release_or_erase(2970 def release_or_erase(
2969 self, user, comment=None,2971 self, user, comment=None, erase=False, secure_erase=None,
2970 erase=False, secure_erase=None, quick_erase=None):2972 quick_erase=None, force=False):
2971 """Either release the node or erase the node then release it, depending2973 """Either release the node or erase the node then release it, depending
2972 on settings and parameters."""2974 on settings and parameters."""
2975 self.maybe_delete_pods(not force)
2973 erase_on_release = Config.objects.get_config(2976 erase_on_release = Config.objects.get_config(
2974 'enable_disk_erasing_on_release')2977 'enable_disk_erasing_on_release')
2975 if erase or erase_on_release:2978 if erase or erase_on_release:
@@ -2979,6 +2982,27 @@ class Node(CleanSave, TimestampedModel):
2979 else:2982 else:
2980 self.release(user, comment)2983 self.release(user, comment)
29812984
2985 def maybe_delete_pods(self, dry_run: bool):
2986 """Check if any pods are associated with this Node.
2987
2988 All pods will be deleted if dry_run=False is passed in.
2989
2990 :param dry_run: If True, raises NodeActionError rather than deleting
2991 pods.
2992 """
2993 hosted_pods = list(
2994 self.get_hosted_pods().values_list('name', flat=True))
2995 if len(hosted_pods) > 0:
2996 if dry_run:
2997 raise ValidationError(
2998 "The following pods must be removed first: %s" % (
2999 ", ".join(hosted_pods)))
3000 for pod in self.get_hosted_pods():
3001 if isInIOThread():
3002 pod.async_delete()
3003 else:
3004 reactor.callFromThread(pod.async_delete)
3005
2982 def set_netboot(self, on=True):3006 def set_netboot(self, on=True):
2983 """Set netboot on or off."""3007 """Set netboot on or off."""
2984 log.debug(3008 log.debug(
@@ -4397,6 +4421,13 @@ class Node(CleanSave, TimestampedModel):
4397 else:4421 else:
4398 return script_result.stdout.decode('utf-8').splitlines()4422 return script_result.stdout.decode('utf-8').splitlines()
43994423
4424 def get_hosted_pods(self) -> QuerySet:
4425 # Circular imports
4426 from maasserver.models import Pod
4427 our_static_ips = StaticIPAddress.objects.filter(
4428 interface__node=self).values_list('ip')
4429 return Pod.objects.filter(ip_address__ip__in=our_static_ips)
4430
44004431
4401# Piston serializes objects based on the object class.4432# Piston serializes objects based on the object class.
4402# Here we define a proxy class so that we can specialize how devices are4433# Here we define a proxy class so that we can specialize how devices are
@@ -4413,6 +4444,17 @@ class Machine(Node):
4413 super(Machine, self).__init__(4444 super(Machine, self).__init__(
4414 node_type=NODE_TYPE.MACHINE, *args, **kwargs)4445 node_type=NODE_TYPE.MACHINE, *args, **kwargs)
44154446
4447 def delete(self, force=False):
4448 """Deletes this Machine.
4449
4450 Before deletion, checks if any hosted pods exist.
4451
4452 Raises ValidationError if the machine is a host for one or more pods,
4453 and `force=True` was not specified.
4454 """
4455 self.maybe_delete_pods(not force)
4456 return super().delete()
4457
44164458
4417class Controller(Node):4459class Controller(Node):
4418 """A node which is either a rack or region controller."""4460 """A node which is either a rack or region controller."""
@@ -5352,6 +5394,11 @@ class RackController(Controller):
53525394
5353 def delete(self, force=False):5395 def delete(self, force=False):
5354 """Delete this rack controller."""5396 """Delete this rack controller."""
5397 # Don't bother with the pod check if this is a region+rack, because
5398 # deleting a region+rack results in a region-only controller.
5399 if self.node_type != NODE_TYPE.REGION_AND_RACK_CONTROLLER:
5400 self.maybe_delete_pods(not force)
5401
5355 # Avoid circular imports5402 # Avoid circular imports
5356 from maasserver.models import RegionRackRPCConnection5403 from maasserver.models import RegionRackRPCConnection
53575404
@@ -5518,8 +5565,9 @@ class RegionController(Controller):
5518 super(RegionController, self).__init__(5565 super(RegionController, self).__init__(
5519 node_type=NODE_TYPE.REGION_CONTROLLER, *args, **kwargs)5566 node_type=NODE_TYPE.REGION_CONTROLLER, *args, **kwargs)
55205567
5521 def delete(self):5568 def delete(self, force=False):
5522 """Delete this region controller."""5569 """Delete this region controller."""
5570 self.maybe_delete_pods(not force)
5523 # Avoid circular dependency.5571 # Avoid circular dependency.
5524 from maasserver.models import RegionControllerProcess5572 from maasserver.models import RegionControllerProcess
5525 connections = RegionControllerProcess.objects.filter(5573 connections = RegionControllerProcess.objects.filter(
diff --git a/src/maasserver/models/tests/test_node.py b/src/maasserver/models/tests/test_node.py
index 5b367ae..e596dd8 100644
--- a/src/maasserver/models/tests/test_node.py
+++ b/src/maasserver/models/tests/test_node.py
@@ -34,6 +34,7 @@ from django.core.exceptions import (
34)34)
35from django.db import transaction35from django.db import transaction
36from django.db.models.deletion import Collector36from django.db.models.deletion import Collector
37from django.db.models.query import QuerySet
37from fixtures import LoggerFixture38from fixtures import LoggerFixture
38from maasserver import (39from maasserver import (
39 bootresources,40 bootresources,
@@ -10844,3 +10845,18 @@ class TestControllerGetDiscoveryState(MAASServerTestCase):
10844 self.assertThat(monitoring_state, Contains('eth2'))10845 self.assertThat(monitoring_state, Contains('eth2'))
10845 self.assertThat(10846 self.assertThat(
10846 monitoring_state['eth1'], Equals(eth1.get_discovery_state()))10847 monitoring_state['eth1'], Equals(eth1.get_discovery_state()))
10848
10849
10850class TestNodeGetHostedPods(MAASServerTestCase):
10851
10852 def test__returns_queryset(self):
10853 node = factory.make_Node()
10854 pods = node.get_hosted_pods()
10855 self.assertThat(pods, IsInstance(QuerySet))
10856
10857 def test__returns_related_pods(self):
10858 node = factory.make_Node_with_Interface_on_Subnet()
10859 ip = factory.make_StaticIPAddress(interface=node.boot_interface)
10860 pod = factory.make_Pod(ip_address=ip)
10861 pods = node.get_hosted_pods()
10862 self.assertThat(pods, Contains(pod))

Subscribers

People subscribed via source and target branches