Merge ~adam-collard/maas:clone-websocket-endpoint into maas:master

Proposed by Adam Collard
Status: Merged
Approved by: Adam Collard
Approved revision: 4d2fc897e9520e452e2c0cb1b5595575cffe8e86
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~adam-collard/maas:clone-websocket-endpoint
Merge into: maas:master
Diff against target: 247 lines (+165/-3)
4 files modified
src/maasserver/forms/clone.py (+6/-2)
src/maasserver/node_action.py (+32/-1)
src/maasserver/tests/test_node_action.py (+89/-0)
src/maasserver/websockets/handlers/tests/test_machine.py (+38/-0)
Reviewer Review Type Date Requested Status
Christian Grabowski Approve
MAAS Lander Approve
Review via email: mp+406899@code.launchpad.net

Commit message

Add Clone NodeAction, websocket handler

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

UNIT TESTS
-b clone-websocket-endpoint lp:~adam-collard/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/10644/console
COMMIT: 5119c7cba866440aaa24d9d804cb37dce0349844

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

UNIT TESTS
-b clone-websocket-endpoint lp:~adam-collard/maas/+git/maas into -b master lp:~maas-committers/maas

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

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

UNIT TESTS
-b clone-websocket-endpoint lp:~adam-collard/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/10646/console
COMMIT: 04cdd03d42bf0425e88c564678a226f6d2de5e83

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

UNIT TESTS
-b clone-websocket-endpoint lp:~adam-collard/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/10647/console
COMMIT: 4d2fc897e9520e452e2c0cb1b5595575cffe8e86

review: Needs Fixing
Revision history for this message
Adam Collard (adam-collard) wrote :

jenkins: !test

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

UNIT TESTS
-b clone-websocket-endpoint lp:~adam-collard/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: 4d2fc897e9520e452e2c0cb1b5595575cffe8e86

review: Approve
Revision history for this message
Christian Grabowski (cgrabowski) wrote :

LGTM

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/maasserver/forms/clone.py b/src/maasserver/forms/clone.py
2index 105c19a..9590f25 100644
3--- a/src/maasserver/forms/clone.py
4+++ b/src/maasserver/forms/clone.py
5@@ -115,8 +115,12 @@ class CloneForm(forms.Form):
6 )
7 set_form_error(self, "destinations", error)
8 if not storage and not interfaces:
9- set_form_error(
10- self, "__all__", "Either storage or interfaces must be true."
11+ self.add_error(
12+ "__all__",
13+ ValidationError(
14+ "Either storage or interfaces must be true.",
15+ code="required",
16+ ),
17 )
18 return cleaned_data
19
20diff --git a/src/maasserver/node_action.py b/src/maasserver/node_action.py
21index afee1c6..1f810cb 100644
22--- a/src/maasserver/node_action.py
23+++ b/src/maasserver/node_action.py
24@@ -38,6 +38,7 @@ from maasserver.exceptions import (
25 NodeActionError,
26 StaticIPAddressExhaustion,
27 )
28+from maasserver.forms.clone import CloneForm
29 from maasserver.models import Config, ResourcePool, Tag, Zone
30 from maasserver.node_status import is_failed_status, NON_MONITORED_STATUSES
31 from maasserver.permissions import NodePermission
32@@ -950,7 +951,36 @@ class AddTag(NodeAction):
33 self.node.tags.add(t)
34
35 self.node.save()
36- return
37+
38+
39+class Clone(NodeAction):
40+ """Clone storage/network configuration."""
41+
42+ name = "clone"
43+ display = "Clone"
44+ display_sentence = "cloned"
45+ actionable_statuses = ALL_STATUSES
46+ permission = NodePermission.edit
47+ machine_permission = NodePermission.admin
48+ for_type = {NODE_TYPE.MACHINE}
49+ action_type = NODE_ACTION_TYPE.MISC
50+ audit_description = "Cloning from '%s'."
51+
52+ def get_node_action_audit_description(self, action):
53+ return self.audit_description % action.node.hostname
54+
55+ def _execute(self, destinations=None, storage=False, interfaces=False):
56+ source = self.node
57+ data = {
58+ "source": source,
59+ "destinations": destinations,
60+ "storage": storage,
61+ "interfaces": interfaces,
62+ }
63+ form = CloneForm(self.user, data=data)
64+ if not form.is_valid():
65+ raise NodeActionError(form.errors.as_json())
66+ form.save()
67
68
69 ACTION_CLASSES = (
70@@ -970,6 +1000,7 @@ ACTION_CLASSES = (
71 Lock,
72 Unlock,
73 AddTag,
74+ Clone,
75 SetZone,
76 SetPool,
77 ImportImages,
78diff --git a/src/maasserver/tests/test_node_action.py b/src/maasserver/tests/test_node_action.py
79index 6a8a4b5..a47d046 100644
80--- a/src/maasserver/tests/test_node_action.py
81+++ b/src/maasserver/tests/test_node_action.py
82@@ -1,6 +1,7 @@
83 # Copyright 2012-2020 Canonical Ltd. This software is licensed under the
84 # GNU Affero General Public License version 3 (see the file LICENSE).
85
86+import json
87 import random
88 from unittest.mock import ANY
89
90@@ -32,6 +33,7 @@ from maasserver.node_action import (
91 Acquire,
92 ACTION_CLASSES,
93 AddTag,
94+ Clone,
95 Commission,
96 compile_node_actions,
97 Delete,
98@@ -1772,6 +1774,93 @@ class TestExitRescueModeAction(MAASServerTestCase):
99 self.assertThat(node_stop_rescue_mode, MockCalledOnceWith(user))
100
101
102+class TestCloneAction(MAASServerTestCase):
103+ def test_admin_permission_required(self):
104+ user = factory.make_User()
105+ request = factory.make_fake_request("/")
106+ request.user = user
107+ node = factory.make_Node()
108+ self.assertFalse(Clone(node, user, request).is_permitted())
109+
110+ def test_requires_destinations(self):
111+ user = factory.make_admin()
112+ request = factory.make_fake_request("/")
113+ request.user = user
114+ source = factory.make_Machine()
115+ action = Clone(source, user, request)
116+ exception = self.assertRaises(
117+ NodeActionError, action.execute, storage=True, interfaces=True
118+ )
119+ (error,) = exception.args
120+ self.assertEqual(
121+ json.loads(error)["destinations"],
122+ [{"code": "required", "message": "This field is required."}],
123+ )
124+
125+ def test_requires_storage_or_interfaces(self):
126+ user = factory.make_admin()
127+ request = factory.make_fake_request("/")
128+ request.user = user
129+ source = factory.make_Machine()
130+ destination1 = factory.make_Machine(
131+ status=NODE_STATUS.READY,
132+ with_boot_disk=False,
133+ )
134+ action = Clone(source, user, request)
135+ exception = self.assertRaises(
136+ NodeActionError,
137+ action.execute,
138+ destinations=[destination1.system_id],
139+ )
140+ (error,) = exception.args
141+ self.assertEqual(
142+ json.loads(error)["__all__"],
143+ [
144+ {
145+ "code": "required",
146+ "message": "Either storage or interfaces must be true.",
147+ }
148+ ],
149+ )
150+
151+ def test_clone_action_log(self):
152+ user = factory.make_admin()
153+ request = factory.make_fake_request("/")
154+ request.user = user
155+ source = factory.make_Machine(with_boot_disk=False)
156+ factory.make_PhysicalBlockDevice(
157+ node=source, size=8 * 1024 ** 3, name="sda"
158+ )
159+ factory.make_Interface(node=source, name="eth0")
160+ destination1 = factory.make_Machine(
161+ status=NODE_STATUS.READY,
162+ with_boot_disk=False,
163+ )
164+ factory.make_PhysicalBlockDevice(
165+ node=destination1, size=8 * 1024 ** 3, name="sda"
166+ )
167+ factory.make_Interface(node=destination1, name="eth0")
168+ destination2 = factory.make_Machine(
169+ status=NODE_STATUS.FAILED_TESTING,
170+ with_boot_disk=False,
171+ )
172+ factory.make_PhysicalBlockDevice(
173+ node=destination2, size=8 * 1024 ** 3, name="sda"
174+ )
175+ factory.make_Interface(node=destination2, name="eth0")
176+
177+ action = Clone(source, user, request)
178+ action.execute(
179+ destinations=[destination1.system_id, destination2.system_id],
180+ storage=True,
181+ interfaces=True,
182+ )
183+ audit_event = Event.objects.get(type__level=AUDIT)
184+ self.assertEqual(
185+ audit_event.description, f"Cloning from '{source.hostname}'."
186+ )
187+
188+
189 class TestActionsErrorHandling(MAASServerTestCase):
190 """Tests for error handling in actions.
191
192diff --git a/src/maasserver/websockets/handlers/tests/test_machine.py b/src/maasserver/websockets/handlers/tests/test_machine.py
193index 78da120..391127b 100644
194--- a/src/maasserver/websockets/handlers/tests/test_machine.py
195+++ b/src/maasserver/websockets/handlers/tests/test_machine.py
196@@ -3,6 +3,7 @@
197
198
199 from functools import partial
200+import json
201 import logging
202 from operator import itemgetter
203 import random
204@@ -3636,6 +3637,43 @@ class TestMachineHandler(MAASServerTestCase):
205 node.distro_series, Equals(osystem["releases"][0]["name"])
206 )
207
208+ def test_action_clone_errors_bundled(self):
209+ user = factory.make_admin()
210+ node = factory.make_Node()
211+
212+ handler = MachineHandler(user, {}, None)
213+ exc = self.assertRaises(
214+ NodeActionError,
215+ handler.action,
216+ {
217+ "system_id": node.system_id,
218+ "action": "clone",
219+ "extra": {
220+ "storage": False,
221+ "interfaces": False,
222+ "destinations": [],
223+ },
224+ },
225+ )
226+ (errors,) = exc.args
227+ self.assertEqual(
228+ json.loads(errors),
229+ {
230+ "destinations": [
231+ {
232+ "message": "This field is required.",
233+ "code": "required",
234+ }
235+ ],
236+ "__all__": [
237+ {
238+ "message": "Either storage or interfaces must be true.",
239+ "code": "required",
240+ }
241+ ],
242+ },
243+ )
244+
245 def test_create_physical_creates_interface(self):
246 user = factory.make_admin()
247 node = factory.make_Node(interface=False)

Subscribers

People subscribed via source and target branches