Merge ~blake-rouse/maas:fix-1724677 into maas:master

Proposed by Blake Rouse
Status: Merged
Approved by: Blake Rouse
Approved revision: 46e07bfddc1eeee720cfe4ea7cd96cb3ae35b76a
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~blake-rouse/maas:fix-1724677
Merge into: maas:master
Diff against target: 303 lines (+264/-1)
2 files modified
src/provisioningserver/rackdservices/tests/test_tftp.py (+223/-0)
src/provisioningserver/rackdservices/tftp.py (+41/-1)
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Review via email: mp+332727@code.launchpad.net

Commit message

Always use the same RPC client for the TFTP remote client request.

This ensures that when a TFTP request is made from a client the same RPC connection will be used for the life of that client. This prevents concurrent updates from occurring across regiond processes since the same regiond reactor will be used for the life of that client.

To post a comment you must log in.
Revision history for this message
Blake Rouse (blake-rouse) wrote :

I have test this in the CI and it passed:

http://162.213.35.104:8080/job/maas-git-manual/75/console

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Looks good!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/provisioningserver/rackdservices/tests/test_tftp.py b/src/provisioningserver/rackdservices/tests/test_tftp.py
2index fe9df09..925f98c 100644
3--- a/src/provisioningserver/rackdservices/tests/test_tftp.py
4+++ b/src/provisioningserver/rackdservices/tests/test_tftp.py
5@@ -385,6 +385,229 @@ class TestTFTPBackend(MAASTestCase):
6 )
7
8 @inlineCallbacks
9+ def test_get_boot_method_reader_uses_same_client(self):
10+ # Fake configuration parameters, as discovered from the file path.
11+ fake_params = {"mac": factory.make_mac_address("-")}
12+ # Fake kernel configuration parameters, as returned from the RPC call.
13+ fake_kernel_params = make_kernel_parameters()
14+ fake_params = fake_kernel_params._asdict()
15+
16+ # Stub the output of list_boot_images so the label is set in the
17+ # kernel parameters.
18+ boot_image = {
19+ "osystem": fake_params["osystem"],
20+ "release": fake_params["release"],
21+ "architecture": fake_params["arch"],
22+ "subarchitecture": fake_params["subarch"],
23+ "purpose": fake_params["purpose"],
24+ "supported_subarches": "",
25+ "label": fake_params["label"],
26+ }
27+ self.patch(tftp_module, "list_boot_images").return_value = [boot_image]
28+ del fake_params["label"]
29+
30+ # Stub RPC call to return the fake configuration parameters.
31+ clients = []
32+ for _ in range(10):
33+ client = Mock()
34+ client.localIdent = factory.make_name("system_id")
35+ client.side_effect = lambda *args, **kwargs: (
36+ succeed(dict(fake_params)))
37+ clients.append(client)
38+ client_service = Mock()
39+ client_service.getClientNow.side_effect = [
40+ succeed(client)
41+ for client in clients
42+ ]
43+ client_service.getAllClients.return_value = clients
44+
45+ # get_boot_method_reader() takes a dict() of parameters and returns an
46+ # `IReader` of a PXE configuration, rendered by
47+ # `PXEBootMethod.get_reader`.
48+ backend = TFTPBackend(
49+ self.make_dir(), client_service)
50+
51+ # Stub get_reader to return the render parameters.
52+ method = PXEBootMethod()
53+ fake_render_result = factory.make_name("render").encode("utf-8")
54+ render_patch = self.patch(method, "get_reader")
55+ render_patch.return_value = BytesReader(fake_render_result)
56+
57+ # Get the reader once.
58+ remote_ip = factory.make_ipv4_address()
59+ params_with_ip = dict(fake_params)
60+ params_with_ip['remote_ip'] = remote_ip
61+ reader = yield backend.get_boot_method_reader(method, params_with_ip)
62+ self.addCleanup(reader.finish)
63+
64+ # Get the reader twice.
65+ params_with_ip = dict(fake_params)
66+ params_with_ip['remote_ip'] = remote_ip
67+ reader = yield backend.get_boot_method_reader(method, params_with_ip)
68+ self.addCleanup(reader.finish)
69+
70+ # Only one client is saved.
71+ self.assertEquals(clients[0], backend.client_to_remote[remote_ip])
72+
73+ # Only the first client should have been called twice, and all the
74+ # other clients should not have been called.
75+ self.assertEquals(2, clients[0].call_count)
76+ for idx in range(1, 10):
77+ self.assertThat(clients[idx], MockNotCalled())
78+
79+ @inlineCallbacks
80+ def test_get_boot_method_reader_uses_different_clients(self):
81+ # Fake configuration parameters, as discovered from the file path.
82+ fake_params = {"mac": factory.make_mac_address("-")}
83+ # Fake kernel configuration parameters, as returned from the RPC call.
84+ fake_kernel_params = make_kernel_parameters()
85+ fake_params = fake_kernel_params._asdict()
86+
87+ # Stub the output of list_boot_images so the label is set in the
88+ # kernel parameters.
89+ boot_image = {
90+ "osystem": fake_params["osystem"],
91+ "release": fake_params["release"],
92+ "architecture": fake_params["arch"],
93+ "subarchitecture": fake_params["subarch"],
94+ "purpose": fake_params["purpose"],
95+ "supported_subarches": "",
96+ "label": fake_params["label"],
97+ }
98+ self.patch(tftp_module, "list_boot_images").return_value = [boot_image]
99+ del fake_params["label"]
100+
101+ # Stub RPC call to return the fake configuration parameters.
102+ clients = []
103+ for _ in range(10):
104+ client = Mock()
105+ client.localIdent = factory.make_name("system_id")
106+ client.side_effect = lambda *args, **kwargs: (
107+ succeed(dict(fake_params)))
108+ clients.append(client)
109+ client_service = Mock()
110+ client_service.getClientNow.side_effect = [
111+ succeed(client)
112+ for client in clients
113+ ]
114+ client_service.getAllClients.return_value = clients
115+
116+ # get_boot_method_reader() takes a dict() of parameters and returns an
117+ # `IReader` of a PXE configuration, rendered by
118+ # `PXEBootMethod.get_reader`.
119+ backend = TFTPBackend(
120+ self.make_dir(), client_service)
121+
122+ # Stub get_reader to return the render parameters.
123+ method = PXEBootMethod()
124+ fake_render_result = factory.make_name("render").encode("utf-8")
125+ render_patch = self.patch(method, "get_reader")
126+ render_patch.return_value = BytesReader(fake_render_result)
127+
128+ # Get the reader once.
129+ remote_ip_one = factory.make_ipv4_address()
130+ params_with_ip = dict(fake_params)
131+ params_with_ip['remote_ip'] = remote_ip_one
132+ reader = yield backend.get_boot_method_reader(method, params_with_ip)
133+ self.addCleanup(reader.finish)
134+
135+ # Get the reader twice.
136+ remote_ip_two = factory.make_ipv4_address()
137+ params_with_ip = dict(fake_params)
138+ params_with_ip['remote_ip'] = remote_ip_two
139+ reader = yield backend.get_boot_method_reader(method, params_with_ip)
140+ self.addCleanup(reader.finish)
141+
142+ # The both clients are saved.
143+ self.assertEquals(clients[0], backend.client_to_remote[remote_ip_one])
144+ self.assertEquals(clients[1], backend.client_to_remote[remote_ip_two])
145+
146+ # Only the first and second client should have been called once, and
147+ # all the other clients should not have been called.
148+ self.assertEquals(1, clients[0].call_count)
149+ self.assertEquals(1, clients[1].call_count)
150+ for idx in range(2, 10):
151+ self.assertThat(clients[idx], MockNotCalled())
152+
153+ @inlineCallbacks
154+ def test_get_boot_method_reader_grabs_new_client_on_lost_conn(self):
155+ # Fake configuration parameters, as discovered from the file path.
156+ fake_params = {"mac": factory.make_mac_address("-")}
157+ # Fake kernel configuration parameters, as returned from the RPC call.
158+ fake_kernel_params = make_kernel_parameters()
159+ fake_params = fake_kernel_params._asdict()
160+
161+ # Stub the output of list_boot_images so the label is set in the
162+ # kernel parameters.
163+ boot_image = {
164+ "osystem": fake_params["osystem"],
165+ "release": fake_params["release"],
166+ "architecture": fake_params["arch"],
167+ "subarchitecture": fake_params["subarch"],
168+ "purpose": fake_params["purpose"],
169+ "supported_subarches": "",
170+ "label": fake_params["label"],
171+ }
172+ self.patch(tftp_module, "list_boot_images").return_value = [boot_image]
173+ del fake_params["label"]
174+
175+ # Stub RPC call to return the fake configuration parameters.
176+ clients = []
177+ for _ in range(10):
178+ client = Mock()
179+ client.localIdent = factory.make_name("system_id")
180+ client.side_effect = lambda *args, **kwargs: (
181+ succeed(dict(fake_params)))
182+ clients.append(client)
183+ client_service = Mock()
184+ client_service.getClientNow.side_effect = [
185+ succeed(client)
186+ for client in clients
187+ ]
188+ client_service.getAllClients.side_effect = [
189+ clients[1:],
190+ clients[2:],
191+ ]
192+
193+ # get_boot_method_reader() takes a dict() of parameters and returns an
194+ # `IReader` of a PXE configuration, rendered by
195+ # `PXEBootMethod.get_reader`.
196+ backend = TFTPBackend(
197+ self.make_dir(), client_service)
198+
199+ # Stub get_reader to return the render parameters.
200+ method = PXEBootMethod()
201+ fake_render_result = factory.make_name("render").encode("utf-8")
202+ render_patch = self.patch(method, "get_reader")
203+ render_patch.return_value = BytesReader(fake_render_result)
204+
205+ # Get the reader once.
206+ remote_ip = factory.make_ipv4_address()
207+ params_with_ip = dict(fake_params)
208+ params_with_ip['remote_ip'] = remote_ip
209+ reader = yield backend.get_boot_method_reader(method, params_with_ip)
210+ self.addCleanup(reader.finish)
211+
212+ # The first client is now saved.
213+ self.assertEquals(clients[0], backend.client_to_remote[remote_ip])
214+
215+ # Get the reader twice.
216+ params_with_ip = dict(fake_params)
217+ params_with_ip['remote_ip'] = remote_ip
218+ reader = yield backend.get_boot_method_reader(method, params_with_ip)
219+ self.addCleanup(reader.finish)
220+
221+ # The second client is now saved.
222+ self.assertEquals(clients[1], backend.client_to_remote[remote_ip])
223+
224+ # Only the first and second client should have been called once, and
225+ # all the other clients should not have been called.
226+ self.assertEquals(1, clients[0].call_count)
227+ self.assertEquals(1, clients[1].call_count)
228+ for idx in range(2, 10):
229+ self.assertThat(clients[idx], MockNotCalled())
230+
231+ @inlineCallbacks
232 def test_get_boot_method_reader_returns_rendered_params(self):
233 # Fake configuration parameters, as discovered from the file path.
234 fake_params = {"mac": factory.make_mac_address("-")}
235diff --git a/src/provisioningserver/rackdservices/tftp.py b/src/provisioningserver/rackdservices/tftp.py
236index 5da8a60..eaea735 100644
237--- a/src/provisioningserver/rackdservices/tftp.py
238+++ b/src/provisioningserver/rackdservices/tftp.py
239@@ -67,6 +67,7 @@ from twisted.internet.defer import (
240 inlineCallbacks,
241 maybeDeferred,
242 returnValue,
243+ succeed,
244 )
245 from twisted.internet.task import deferLater
246 from twisted.python.filepath import FilePath
247@@ -164,9 +165,48 @@ class TFTPBackend(FilesystemSynchronousBackend):
248 base_path = FilePath(base_path)
249 super(TFTPBackend, self).__init__(
250 base_path, can_read=True, can_write=False)
251+ self.client_to_remote = {}
252 self.client_service = client_service
253 self.fetcher = RPCFetcher()
254
255+ def _get_new_client_for_remote(self, remote_ip):
256+ """Return a new client for the `remote_ip`.
257+
258+ Don't use directly called from `get_client_for`.
259+ """
260+ def store_client(client):
261+ self.client_to_remote[remote_ip] = client
262+ return client
263+
264+ d = self.client_service.getClientNow()
265+ d.addCallback(store_client)
266+ return d
267+
268+ def get_client_for(self, params):
269+ """Always gets the same client based on `params`.
270+
271+ This is done so that all TFTP requests from the same remote client go
272+ to the same regiond process. `RPCFetcher` only duplciate on the client
273+ and arguments, so if the client is not the same the duplicate effort
274+ is not consolidated.
275+ """
276+ remote_ip = params.get('remote_ip')
277+ if remote_ip:
278+ client = self.client_to_remote.get(remote_ip, None)
279+ if client is None:
280+ # Get a new client for the remote_ip.
281+ return self._get_new_client_for_remote(remote_ip)
282+ else:
283+ # Check that the existing client is still valid.
284+ clients = self.client_service.getAllClients()
285+ if client in clients:
286+ return succeed(client)
287+ else:
288+ del self.client_to_remote[remote_ip]
289+ return self._get_new_client_for_remote(remote_ip)
290+ else:
291+ return self.client_service.getClientNow()
292+
293 @inlineCallbacks
294 @typed
295 def get_boot_method(self, file_name: TFTPPath):
296@@ -259,7 +299,7 @@ class TFTPBackend(FilesystemSynchronousBackend):
297 d.addCallback(lambda data: KernelParameters(**data))
298 return d
299
300- d = self.client_service.getClientNow()
301+ d = self.get_client_for(params)
302 d.addCallback(fetch, params)
303 return d
304

Subscribers

People subscribed via source and target branches