Merge ~blake-rouse/maas:http-boot-endpoint into maas:master

Proposed by Blake Rouse
Status: Merged
Approved by: Blake Rouse
Approved revision: 1b3e28cc023852ea50daabf803fc44f0bc78bc3e
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~blake-rouse/maas:http-boot-endpoint
Merge into: maas:master
Diff against target: 462 lines (+370/-5)
4 files modified
src/provisioningserver/rackdservices/http.py (+97/-0)
src/provisioningserver/rackdservices/tests/test_http.py (+260/-3)
src/provisioningserver/rackdservices/tftp.py (+5/-2)
src/provisioningserver/templates/http/rackd.nginx.conf.template (+8/-0)
Reviewer Review Type Date Requested Status
Andres Rodriguez (community) Approve
Alberto Donato (community) Approve
MAAS Lander unittests Pending
Review via email: mp+364892@code.launchpad.net

Commit message

Add a HTTP /boot endpoint inside of the rackd process that takes a request and processes it through the same backend as the TFTP server. Add / proxy_pass to the nginx configuration to send requests on 5248 to the /boot endpoint.

Description of the change

This only adds the endpoint it does *not* update any booting machine to actually use the endpoint. That will occur in a following branch.

To validate that it works on a machine with the rackd install the following command should generate the same file that is located in /var/lib/maas/boot-resources/current/grub/grub.cfg.

curl http://localhost:5248/grub/grub.cfg

To test with PXE and a dynamically generated file use:

curl http://localhost:5248/pxelinux.cfg-01-{$mac-with-dashes}

To post a comment you must log in.
Revision history for this message
Alberto Donato (ack) wrote :

Nice! +1

It would be nice to also add a metric for transfer times for http boot resources (similarly to the one we have for tftp).
It's not a blocker for this branch, though

review: Approve
Revision history for this message
Andres Rodriguez (andreserl) wrote :

lgtm! Just quick questions inline.

review: Approve
Revision history for this message
Blake Rouse (blake-rouse) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/provisioningserver/rackdservices/http.py b/src/provisioningserver/rackdservices/http.py
2index a4b5976..b0f9332 100644
3--- a/src/provisioningserver/rackdservices/http.py
4+++ b/src/provisioningserver/rackdservices/http.py
5@@ -15,6 +15,7 @@ import sys
6
7 import attr
8 from netaddr import IPAddress
9+from provisioningserver import services
10 from provisioningserver.events import (
11 EVENT_TYPES,
12 send_node_event_ip_address,
13@@ -30,12 +31,19 @@ from provisioningserver.utils import (
14 )
15 from provisioningserver.utils.fs import atomic_write
16 from provisioningserver.utils.twisted import callOut
17+from tftp.errors import (
18+ AccessViolation,
19+ FileNotFound,
20+)
21 from twisted.application.internet import TimerService
22 from twisted.internet import reactor
23 from twisted.internet.defer import maybeDeferred
24 from twisted.internet.task import deferLater
25 from twisted.internet.threads import deferToThread
26+from twisted.python import context
27 from twisted.web import resource
28+from twisted.web.server import NOT_DONE_YET
29+from twisted.web.static import NoRangeStaticProducer
30
31
32 log = LegacyLogger()
33@@ -226,11 +234,100 @@ class HTTPLogResource(resource.Resource):
34 return b''
35
36
37+class HTTPBootResource(resource.Resource):
38+ isLeaf = True
39+
40+ def render_GET(self, request):
41+ # Be sure that the TFTP endpoint is running.
42+ try:
43+ tftp = services.getServiceNamed('tftp')
44+ except KeyError:
45+ # TFTP service is not installed cannot handle a boot request.
46+ request.setResponseCode(503)
47+ return b'HTTP boot service not ready.'
48+
49+ # Extract the local servers IP/port of the request.
50+ localHost = request.getHeader('X-Server-Addr')
51+ try:
52+ localPort = int(request.getHeader('X-Server-Port'))
53+ except (TypeError, ValueError):
54+ localPort = 0
55+
56+ # Extract the original clients IP/port of the request.
57+ remoteHost = request.getHeader('X-Forwarded-For')
58+ try:
59+ remotePort = int(request.getHeader('X-Forwarded-Port'))
60+ except (TypeError, ValueError):
61+ remotePort = 0
62+
63+ # localHost and remoteHost are required headers.
64+ if not localHost or not remoteHost:
65+ request.setResponseCode(400)
66+ return b'Missing X-Server-Addr and X-Forwarded-For HTTP headers.'
67+
68+ def handleFailure(failure):
69+ if failure.check(AccessViolation):
70+ request.setResponseCode(403)
71+ request.write(b'')
72+ elif failure.check(FileNotFound):
73+ request.setResponseCode(404)
74+ request.write(b'')
75+ else:
76+ log.err(failure, "Failed to handle boot HTTP request.")
77+ request.setResponseCode(500)
78+ request.write(str(failure.value).encode('utf-8'))
79+ request.finish()
80+
81+ def writeResponse(reader):
82+ # Some readers from `tftp` do not provide a way to get the size
83+ # of the generated content. Only set `Content-Length` when size
84+ # can be determined for the response.
85+ if hasattr(reader, 'size'):
86+ request.setHeader(b'Content-Length', reader.size)
87+
88+ # The readers from `tftp` use `finish` instead of `close`, but
89+ # `NoRangeStaticProducer` expects `close` instead of `finish`. Map
90+ # `finish` to `close` so the file handlers are cleaned up.
91+ reader.close = reader.finish
92+
93+ # Produce the result without allowing range. This producer will
94+ # call `close` on the reader and `finish` on the request when done.
95+ producer = NoRangeStaticProducer(request, reader)
96+ producer.start()
97+
98+ path = b'/'.join(request.postpath)
99+ d = context.call(
100+ {
101+ "local": (localHost, localPort),
102+ "remote": (remoteHost, remotePort),
103+ },
104+ tftp.backend.get_reader, path, skip_logging=True)
105+ d.addCallback(writeResponse)
106+ d.addErrback(handleFailure)
107+ d.addErrback(log.err, "Failed to handle boot HTTP request.")
108+
109+ # Log the HTTP request to rackd.log and push that event to the
110+ # region controller.
111+ log_path = path.decode('utf-8')
112+ log.info(
113+ "{path} requested by {remoteHost}",
114+ path=log_path, remoteHost=remoteHost)
115+ d = deferLater(
116+ reactor, 0, send_node_event_ip_address,
117+ event_type=EVENT_TYPES.NODE_HTTP_REQUEST,
118+ ip_address=remoteHost, description=log_path)
119+ d.addErrback(log.err, "Logging HTTP request failed.")
120+
121+ # Response is handled in the defer.
122+ return NOT_DONE_YET
123+
124+
125 class HTTPResource(resource.Resource):
126 """The root resource for HTTP."""
127
128 def __init__(self):
129 super().__init__()
130+ self.putChild(b'boot', HTTPBootResource())
131 self.putChild(b'log', HTTPLogResource())
132 self.putChild(
133 b'metrics', PrometheusMetricsResource(PROMETHEUS_METRICS))
134diff --git a/src/provisioningserver/rackdservices/tests/test_http.py b/src/provisioningserver/rackdservices/tests/test_http.py
135index 42227bc..9f10fa2 100644
136--- a/src/provisioningserver/rackdservices/tests/test_http.py
137+++ b/src/provisioningserver/rackdservices/tests/test_http.py
138@@ -27,6 +27,7 @@ from maastesting.twisted import (
139 TwistedLoggerFixture,
140 )
141 from provisioningserver import services
142+from provisioningserver.boot import BytesReader
143 from provisioningserver.events import EVENT_TYPES
144 from provisioningserver.rackdservices import http
145 from provisioningserver.rpc import (
146@@ -41,11 +42,26 @@ from testtools.matchers import (
147 IsInstance,
148 MatchesStructure,
149 )
150+from tftp.errors import (
151+ AccessViolation,
152+ FileNotFound,
153+)
154+from twisted.application.service import Service
155 from twisted.internet import reactor
156-from twisted.internet.defer import inlineCallbacks
157+from twisted.internet.defer import (
158+ fail,
159+ inlineCallbacks,
160+ succeed,
161+)
162 from twisted.web.http_headers import Headers
163-from twisted.web.server import Request
164-from twisted.web.test.test_web import DummyChannel
165+from twisted.web.server import (
166+ NOT_DONE_YET,
167+ Request,
168+)
169+from twisted.web.test.test_web import (
170+ DummyChannel,
171+ DummyRequest,
172+)
173
174
175 def prepareRegion(test):
176@@ -301,3 +317,244 @@ class TestHTTPLogResource(MAASTestCase):
177 ANY, 0, http.send_node_event_ip_address,
178 event_type=EVENT_TYPES.NODE_HTTP_REQUEST,
179 ip_address=ip, description=path))
180+
181+
182+class TestHTTPBootResource(MAASTestCase):
183+
184+ run_tests_with = MAASTwistedRunTest.make_factory(timeout=5)
185+
186+ def setUp(self):
187+ super().setUp()
188+ self.tftp = Service()
189+ self.tftp.setName('tftp')
190+ self.tftp.backend = Mock()
191+ self.tftp.backend.get_reader = Mock()
192+ self.tftp.setServiceParent(services)
193+
194+ def teardown():
195+ if self.tftp:
196+ self.tftp.disownServiceParent()
197+ self.tftp = None
198+
199+ self.addCleanup(teardown)
200+
201+ def render_GET(self, resource, request):
202+ result = resource.render_GET(request)
203+ if isinstance(result, bytes):
204+ request.write(result)
205+ request.finish()
206+ return succeed(None)
207+ elif result is NOT_DONE_YET:
208+ if request.finished:
209+ return succeed(None)
210+ else:
211+ return request.notifyFinish()
212+ else:
213+ raise ValueError("Unexpected return value: %r" % (result,))
214+
215+ @inlineCallbacks
216+ def test_render_GET_503_when_no_tftp_service(self):
217+ # Remove the fake 'tftp' service.
218+ self.tftp.disownServiceParent()
219+ self.tftp = None
220+
221+ path = factory.make_name('path')
222+ ip = factory.make_ip_address()
223+ request = DummyRequest([path.encode('utf-8')])
224+ request.requestHeaders = Headers({
225+ 'X-Server-Addr': ['192.168.1.1'],
226+ 'X-Server-Port': ['5248'],
227+ 'X-Forwarded-For': [ip],
228+ 'X-Forwarded-Port': ['%s' % factory.pick_port()],
229+ })
230+
231+ self.patch(http.log, 'info')
232+ mock_deferLater = self.patch(http, 'deferLater')
233+ mock_deferLater.side_effect = always_succeed_with(None)
234+
235+ resource = http.HTTPBootResource()
236+ yield self.render_GET(resource, request)
237+
238+ self.assertEquals(503, request.responseCode)
239+ self.assertEquals(
240+ b'HTTP boot service not ready.', b''.join(request.written))
241+
242+ @inlineCallbacks
243+ def test_render_GET_400_when_no_local_addr(self):
244+ path = factory.make_name('path')
245+ ip = factory.make_ip_address()
246+ request = DummyRequest([path.encode('utf-8')])
247+ request.requestHeaders = Headers({
248+ 'X-Forwarded-For': [ip],
249+ 'X-Forwarded-Port': ['%s' % factory.pick_port()],
250+ })
251+
252+ self.patch(http.log, 'info')
253+ mock_deferLater = self.patch(http, 'deferLater')
254+ mock_deferLater.side_effect = always_succeed_with(None)
255+
256+ resource = http.HTTPBootResource()
257+ yield self.render_GET(resource, request)
258+
259+ self.assertEquals(400, request.responseCode)
260+ self.assertEquals(
261+ b'Missing X-Server-Addr and X-Forwarded-For HTTP headers.',
262+ b''.join(request.written))
263+
264+ @inlineCallbacks
265+ def test_render_GET_400_when_no_remote_addr(self):
266+ path = factory.make_name('path')
267+ request = DummyRequest([path.encode('utf-8')])
268+ request.requestHeaders = Headers({
269+ 'X-Server-Addr': ['192.168.1.1'],
270+ 'X-Server-Port': ['5248'],
271+ })
272+
273+ self.patch(http.log, 'info')
274+ mock_deferLater = self.patch(http, 'deferLater')
275+ mock_deferLater.side_effect = always_succeed_with(None)
276+
277+ resource = http.HTTPBootResource()
278+ yield self.render_GET(resource, request)
279+
280+ self.assertEquals(400, request.responseCode)
281+ self.assertEquals(
282+ b'Missing X-Server-Addr and X-Forwarded-For HTTP headers.',
283+ b''.join(request.written))
284+
285+ @inlineCallbacks
286+ def test_render_GET_403_access_violation(self):
287+ path = factory.make_name('path')
288+ ip = factory.make_ip_address()
289+ request = DummyRequest([path.encode('utf-8')])
290+ request.requestHeaders = Headers({
291+ 'X-Server-Addr': ['192.168.1.1'],
292+ 'X-Server-Port': ['5248'],
293+ 'X-Forwarded-For': [ip],
294+ 'X-Forwarded-Port': ['%s' % factory.pick_port()],
295+ })
296+
297+ self.patch(http.log, 'info')
298+ mock_deferLater = self.patch(http, 'deferLater')
299+ mock_deferLater.side_effect = always_succeed_with(None)
300+
301+ self.tftp.backend.get_reader.return_value = fail(AccessViolation())
302+
303+ resource = http.HTTPBootResource()
304+ yield self.render_GET(resource, request)
305+
306+ self.assertEquals(403, request.responseCode)
307+ self.assertEquals(
308+ b'', b''.join(request.written))
309+
310+ @inlineCallbacks
311+ def test_render_GET_404_file_not_found(self):
312+ path = factory.make_name('path')
313+ ip = factory.make_ip_address()
314+ request = DummyRequest([path.encode('utf-8')])
315+ request.requestHeaders = Headers({
316+ 'X-Server-Addr': ['192.168.1.1'],
317+ 'X-Server-Port': ['5248'],
318+ 'X-Forwarded-For': [ip],
319+ 'X-Forwarded-Port': ['%s' % factory.pick_port()],
320+ })
321+
322+ self.patch(http.log, 'info')
323+ mock_deferLater = self.patch(http, 'deferLater')
324+ mock_deferLater.side_effect = always_succeed_with(None)
325+
326+ self.tftp.backend.get_reader.return_value = fail(FileNotFound(path))
327+
328+ resource = http.HTTPBootResource()
329+ yield self.render_GET(resource, request)
330+
331+ self.assertEquals(404, request.responseCode)
332+ self.assertEquals(
333+ b'', b''.join(request.written))
334+
335+ @inlineCallbacks
336+ def test_render_GET_500_server_error(self):
337+ path = factory.make_name('path')
338+ ip = factory.make_ip_address()
339+ request = DummyRequest([path.encode('utf-8')])
340+ request.requestHeaders = Headers({
341+ 'X-Server-Addr': ['192.168.1.1'],
342+ 'X-Server-Port': ['5248'],
343+ 'X-Forwarded-For': [ip],
344+ 'X-Forwarded-Port': ['%s' % factory.pick_port()],
345+ })
346+
347+ self.patch(http.log, 'info')
348+ mock_deferLater = self.patch(http, 'deferLater')
349+ mock_deferLater.side_effect = always_succeed_with(None)
350+
351+ exc = factory.make_exception("internal error")
352+ self.tftp.backend.get_reader.return_value = fail(exc)
353+
354+ resource = http.HTTPBootResource()
355+ yield self.render_GET(resource, request)
356+
357+ self.assertEquals(500, request.responseCode)
358+ self.assertEquals(
359+ str(exc).encode('utf-8'), b''.join(request.written))
360+
361+ @inlineCallbacks
362+ def test_render_GET_produces_from_reader(self):
363+ path = factory.make_name('path')
364+ ip = factory.make_ip_address()
365+ request = DummyRequest([path.encode('utf-8')])
366+ request.requestHeaders = Headers({
367+ 'X-Server-Addr': ['192.168.1.1'],
368+ 'X-Server-Port': ['5248'],
369+ 'X-Forwarded-For': [ip],
370+ 'X-Forwarded-Port': ['%s' % factory.pick_port()],
371+ })
372+
373+ self.patch(http.log, 'info')
374+ mock_deferLater = self.patch(http, 'deferLater')
375+ mock_deferLater.side_effect = always_succeed_with(None)
376+
377+ content = factory.make_string(size=100).encode('utf-8')
378+ reader = BytesReader(content)
379+ self.tftp.backend.get_reader.return_value = succeed(reader)
380+
381+ resource = http.HTTPBootResource()
382+ yield self.render_GET(resource, request)
383+
384+ self.assertEquals(
385+ [100], request.responseHeaders.getRawHeaders(b'Content-Length'))
386+ self.assertEquals(
387+ content, b''.join(request.written))
388+
389+ @inlineCallbacks
390+ def test_render_GET_logs_node_event_with_original_path_ip(self):
391+ path = factory.make_name('path')
392+ ip = factory.make_ip_address()
393+ request = DummyRequest([path.encode('utf-8')])
394+ request.requestHeaders = Headers({
395+ 'X-Server-Addr': ['192.168.1.1'],
396+ 'X-Server-Port': ['5248'],
397+ 'X-Forwarded-For': [ip],
398+ 'X-Forwarded-Port': ['%s' % factory.pick_port()],
399+ })
400+
401+ log_info = self.patch(http.log, 'info')
402+ mock_deferLater = self.patch(http, 'deferLater')
403+ mock_deferLater.side_effect = always_succeed_with(None)
404+
405+ self.tftp.backend.get_reader.return_value = fail(AccessViolation())
406+
407+ resource = http.HTTPBootResource()
408+ yield self.render_GET(resource, request)
409+
410+ self.assertThat(
411+ log_info,
412+ MockCalledOnceWith(
413+ "{path} requested by {remoteHost}",
414+ path=path, remoteHost=ip))
415+ self.assertThat(
416+ mock_deferLater,
417+ MockCalledOnceWith(
418+ ANY, 0, http.send_node_event_ip_address,
419+ event_type=EVENT_TYPES.NODE_HTTP_REQUEST,
420+ ip_address=ip, description=path))
421diff --git a/src/provisioningserver/rackdservices/tftp.py b/src/provisioningserver/rackdservices/tftp.py
422index 6254283..268189e 100644
423--- a/src/provisioningserver/rackdservices/tftp.py
424+++ b/src/provisioningserver/rackdservices/tftp.py
425@@ -360,7 +360,7 @@ class TFTPBackend(FilesystemSynchronousBackend):
426
427 @deferred
428 @typed
429- def get_reader(self, file_name: TFTPPath):
430+ def get_reader(self, file_name: TFTPPath, skip_logging: bool=False):
431 """See `IBackend.get_reader()`.
432
433 If `file_name` matches a boot method then the response is obtained
434@@ -371,7 +371,10 @@ class TFTPBackend(FilesystemSynchronousBackend):
435 # of '/', example being 'bootx64.efi'. Convert all '\' to '/' to be
436 # unix compatiable.
437 file_name = file_name.replace(b'\\', b'/')
438- log_request(file_name)
439+ if not skip_logging:
440+ # HTTP handler will call with `skip_logging` set to True so that
441+ # 2 log messages are not created.
442+ log_request(file_name)
443 d = self.get_boot_method(file_name)
444 d.addCallback(partial(self.handle_boot_method, file_name))
445 d.addErrback(self.no_response_errback, file_name)
446diff --git a/src/provisioningserver/templates/http/rackd.nginx.conf.template b/src/provisioningserver/templates/http/rackd.nginx.conf.template
447index 8b67e90..0634a2d 100644
448--- a/src/provisioningserver/templates/http/rackd.nginx.conf.template
449+++ b/src/provisioningserver/templates/http/rackd.nginx.conf.template
450@@ -37,4 +37,12 @@ server {
451 proxy_set_header X-Original-URI $request_uri;
452 proxy_set_header X-Original-Remote-IP $remote_addr;
453 }
454+
455+ location / {
456+ proxy_pass http://localhost:5249/boot/;
457+ proxy_set_header X-Server-Addr $server_addr;
458+ proxy_set_header X-Server-Port $server_port;
459+ proxy_set_header X-Forwarded-For $remote_addr;
460+ proxy_set_header X-Forwarded-Port $remote_port;
461+ }
462 }

Subscribers

People subscribed via source and target branches