blob: 8207a99066a8fa765de7433705e719258f252fd5 [file] [log] [blame]
Chip Bolingf5af85d2019-02-12 15:36:17 -06001# Copyright 2017-present Adtran, Inc.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import structlog
16import xmltodict
17from twisted.internet import reactor
18from twisted.internet.defer import returnValue, inlineCallbacks
19from pyvoltha.protos.device_pb2 import ImageDownload
20from pyvoltha.protos.common_pb2 import AdminState
21
22log = structlog.get_logger()
23
24# TODO: Following two would be good provisionable parameters
25DEFAULT_AUTO_AGE_MINUTES = 10
26DEFAULT_MAX_JOB_RUN_SECONDS = 3600 * 4 # Some OLT files are 250MB+
27
28
29class Download(object):
30 """Class to wrap an image download"""
31
32 def __init__(self, handler, request, protocols):
33 self._handler = handler
34 self._deferred = None
35 self.device_id = request.id
36 self._name = request.name
37 self._url = request.url
38 self._crc = request.crc
39 self._version = request.image_version
40 self._local = request.local_dir
41 self._save_config = request.save_config
42 self._supported_protocols = protocols
43
44 self._download_state = ImageDownload.DOWNLOAD_UNKNOWN
45 self._failure_reason = ImageDownload.UNKNOWN_ERROR
46 self._image_state = ImageDownload.IMAGE_UNKNOWN
47 self._additional_info = ''
48 self._downloaded_octets = 0
49
50 # Server profile info
51 self._server_profile_name = None
52 self._scheme = None
53 self._host = ''
54 self._port = None
55 self._path = ''
56 self._auth = None
57
58 # Download job info
59 self._download_job_name = None
60
61 self._age_out_period = DEFAULT_AUTO_AGE_MINUTES
62 self._max_execution = DEFAULT_MAX_JOB_RUN_SECONDS
63
64 def __str__(self):
65 return "ImageDownload: {}".format(self.name)
66
67 @staticmethod
68 def create(handler, request, supported_protocols):
69 """
70 Create and start a new image download
71
72 :param handler: (AdtranDeviceHandler) Device download is for
73 :param request: (ImageDownload) Request
74 :param supported_protocols: (list) download methods allowed (http, tftp, ...)
75 """
76 download = Download(handler, request, supported_protocols)
77 download._deferred = reactor.callLater(0, download.start_download)
78
79 return download
80
81 @property
82 def name(self):
83 return self._name
84
85 @property
86 def download_state(self):
87 return self._download_state
88
89 @property
90 def failure_reason(self):
91 return self._failure_reason
92
93 @property
94 def image_state(self):
95 return self._image_state
96
97 @property
98 def additional_info(self):
99 return self._additional_info
100
101 @property
102 def downloaded_bytes(self):
103 return self._downloaded_octets
104
105 @property
106 def profile_name(self):
107 return self._server_profile_name
108
109 def _cancel_deferred(self):
110 d, self._deferred = self._deferred, None
111 try:
112 if d is not None and not d.called:
113 d.cancel()
114 except Exception as e:
115 pass
116
117 @inlineCallbacks
118 def start_download(self):
119 import uuid
120 log.info('download-start', name=self.name)
121 if not self.parse_url():
122 self._download_failed()
123 returnValue('failed url parsing')
124
125 self._download_state = ImageDownload.DOWNLOAD_STARTED
126 self._failure_reason = ImageDownload.NO_ERROR
127
128 ##############################################################
129 # Configure the file server profile
130 try:
131 self._additional_info = 'Configuring Download Server profile'
132 self._server_profile_name = 'VOLTHA.download.{}'.format(uuid.uuid4())
133 profile = self.server_profile_xml
134 yield self._handler.netconf_client.edit_config(profile)
135
136 except Exception as e:
137 log.exception('server-profile', e=e)
138 self._server_profile_name = None
139 self._failure_reason = ImageDownload.UNKNOWN_ERROR
140 self._additional_info += ': Failure: {}'.format(e.message)
141 self._download_failed()
142 raise
143
144 ##############################################################
145 # Configure the software download maintenance job
146 try:
147 self._additional_info = 'Configuring Image Download Job'
148 self._download_job_name = 'VOLTHA.download.{}'.format(uuid.uuid4())
149 job = self.download_job_xml
150 yield self._handler.netconf_client.edit_config(job)
151
152 except Exception as e:
153 log.exception('server-profile', e=e)
154 self._download_job_name = None
155 self._failure_reason = ImageDownload.UNKNOWN_ERROR
156 self._additional_info += ': Failure: {}'.format(e.message)
157 self._download_failed()
158 raise
159
160 ##############################################################
161 # Schedule a task to monitor the download
162 try:
163 self._additional_info = 'Monitoring download status'
164 self._deferred = reactor.callLater(0.5, self.monitor_download_status)
165
166 except Exception as e:
167 log.exception('server-profile', e=e)
168 self._failure_reason = ImageDownload.UNKNOWN_ERROR
169 self._additional_info += ': Failure: {}'.format(e.message)
170 self._download_failed()
171 raise
172
173 returnValue('started')
174
175 def parse_url(self):
176 from urllib3 import util, exceptions
177 try:
178 results = util.parse_url(self._url)
179
180 # Server info
181 self._scheme = results.scheme.lower()
182 if self._scheme not in self._supported_protocols:
183 self._failure_reason = ImageDownload.INVALID_URL
184 self._additional_info = "Unsupported file transfer protocol: {}".format(results.scheme)
185 return False
186
187 self._host = results.host
188 self._port = results.port
189 self._path = results.path
190 self._auth = results.auth
191 return True
192
193 except exceptions.LocationValueError as e:
194 self._failure_reason = ImageDownload.INVALID_URL
195 self._additional_info = e.message
196 return False
197
198 except Exception as e:
199 self._failure_reason = ImageDownload.UNKNOWN_ERROR
200 self._additional_info = e.message
201 return False
202
203 @property
204 def server_profile_xml(self):
205 assert self._scheme in ['http', 'https', 'ftp', 'sftp', 'tftp'], 'Invalid protocol'
206
207 xml = """
208 <file-servers xmlns="http://www.adtran.com/ns/yang/adtran-file-servers">
209 <profiles>
210 <profile>"""
211
212 xml += '<name>{}</name>'.format(self._server_profile_name)
213 xml += '<connection-profile>'
214 xml += ' <host>{}</host>'.format(self._host)
215 xml += ' <port>{}</port>'.format(self._port) if self._port is not None else '<use-standard-port/>'
216
217 if self._scheme in ['http', 'https']:
218 xml += ' <protocol '
219 xml += 'xmlns:adtn-file-srv-https="http://www.adtran.com/ns/yang/adtran-file-servers-https">' +\
220 'adtn-file-srv-https:{}'.format(self._scheme)
221 xml += ' </protocol>'
222
223 elif self._scheme == 'sftp':
224 xml += ' <protocol '
225 xml += 'xmlns:adtn-file-srv-sftp="http://www.adtran.com/ns/yang/adtran-file-servers-sftp">' +\
226 'adtn-file-srv-sftp:sftp'
227 xml += ' </protocol>'
228
229 elif self._scheme in ['ftp', 'tftp']:
230 xml += '<protocol>adtn-file-srv:{}</protocol>'.format(self._scheme)
231
232 if self._auth is not None:
233 user_pass = self._auth.split(':')
234 xml += '<username>{}</username>'.format(user_pass[0])
235 xml += '<password>$0${}</password>'.format("".join(user_pass[1:]))
236 # And the trailer
237 xml += """
238 </connection-profile>
239 </profile>
240 </profiles>
241 </file-servers>
242 """
243 return xml
244
245 @property
246 def download_job_xml(self):
247 # TODO: May want to support notifications
248 # TODO: Not sure about this name for the entity
249 entity = 'main 0'
250 xml = """
251 <maintenance-jobs xmlns="http://www.adtran.com/ns/yang/adtran-maintenance-jobs" xmlns:adtn-phys-sw-mnt="http://www.adtran.com/ns/yang/adtran-physical-software-maintenance">
252 <maintenance-job>
253 <name>{}</name>
254 <enabled>true</enabled>
255 <notify-enabled>false</notify-enabled>
256 <maximum-execution-time>{}</maximum-execution-time>
257 <run-once>true</run-once>
258 <adtn-phys-sw-mnt:download-software>
259 <adtn-phys-sw-mnt:physical-entity>{}</adtn-phys-sw-mnt:physical-entity>
260 <adtn-phys-sw-mnt:software-name>software</adtn-phys-sw-mnt:software-name>
261 <adtn-phys-sw-mnt:remote-file>
262 <adtn-phys-sw-mnt:file-server-profile>{}</adtn-phys-sw-mnt:file-server-profile>
263 <adtn-phys-sw-mnt:filename>{}</adtn-phys-sw-mnt:filename>
264 """.format(self._download_job_name, self._max_execution, entity,
265 self._server_profile_name, self._name)
266
267 if self._path is not None:
268 xml += """
269 <adtn-phys-sw-mnt:filepath>{}</adtn-phys-sw-mnt:filepath>
270 """.format(self._path)
271
272 xml += """
273 </adtn-phys-sw-mnt:remote-file>
274 </adtn-phys-sw-mnt:download-software>
275 </maintenance-job>
276 </maintenance-jobs>
277 """
278 return xml
279
280 @property
281 def download_status_xml(self):
282 xml = """
283 <filter>
284 <maintenance-jobs-state xmlns="http://www.adtran.com/ns/yang/adtran-maintenance-jobs">
285 <maintenance-job>
286 <name>{}</name>
287 </maintenance-job>
288 </maintenance-jobs-state>
289 </filter>
290 """.format(self._download_job_name)
291 return xml
292
293 @property
294 def delete_server_profile_xml(self):
295 xml = """
296 <file-servers xmlns="http://www.adtran.com/ns/yang/adtran-file-servers">
297 <profiles operation="delete">
298 <profile>
299 <name>{}</name>
300 </profile>
301 </profiles>
302 </file-servers>
303 """.format(self._server_profile_name)
304 return xml
305
306 @property
307 def delete_download_job_xml(self):
308 xml = """
309 <maintenance-jobs xmlns="http://www.adtran.com/ns/yang/adtran-maintenance-jobs">
310 <maintenance-job operation="delete">>
311 <name>{}</name>
312 </maintenance-job>
313 </maintenance-jobs>
314 """.format(self._download_job_name)
315 return xml
316
317 @inlineCallbacks
318 def monitor_download_status(self):
319 log.debug('monitor-download', name=self.name)
320 try:
321 results = yield self._handler.netconf_client.get(self.download_status_xml)
322
323 result_dict = xmltodict.parse(results.data_xml)
324 entries = result_dict['data']['maintenance-jobs-state']['maintenance-job']
325
326 name = entries.get('name')
327 assert name == self._download_job_name, 'The job status name does not match. {} != {}'.format(name, self.name)
328 self._download_state = self.monitor_state_to_download_state(entries['state']['#text'])
329
330 completed = entries['timestamps'].get('completed-timestamp')
331 started = entries['timestamps'].get('start-timestamp')
332
333 if self._download_state == ImageDownload.DOWNLOAD_FAILED:
334 self._failure_reason = ImageDownload.UNKNOWN_ERROR
335 self._additional_info = entries['error'].get('error-message')
336
337 elif self._download_state == ImageDownload.INSUFFICIENT_SPACE:
338 self._failure_reason = ImageDownload.INSUFFICIENT_SPACE
339 self._additional_info = entries['error'].get('error-message')
340
341 elif self._download_state == ImageDownload.DOWNLOAD_STARTED:
342 self._failure_reason = ImageDownload.NO_ERROR
343 self._additional_info = 'Download started at {}'.format(started)
344
345 elif self._download_state == ImageDownload.DOWNLOAD_SUCCEEDED:
346 self._failure_reason = ImageDownload.NO_ERROR
347 self._additional_info = 'Download completed at {}'.format(completed)
348 else:
349 raise NotImplemented('Unsupported state')
350
351 done = self._download_state in [ImageDownload.DOWNLOAD_FAILED,
352 ImageDownload.DOWNLOAD_SUCCEEDED,
353 ImageDownload.INSUFFICIENT_SPACE]
354
355 except Exception as e:
356 log.exception('protocols', e=e)
357 done = False
358
359 if not done:
360 self._deferred = reactor.callLater(1, self.monitor_download_status)
361
362 returnValue('done' if done else 'not-done-yet')
363
364 def _download_failed(self):
365 log.info('download-failed', name=self.name)
366
367 self._cancel_deferred()
368 self._download_state = ImageDownload.DOWNLOAD_FAILED
369
370 # Cleanup NETCONF
371 reactor.callLater(0, self._cleanup_download_job, 20)
372 reactor.callLater(0, self._cleanup_server_profile, 20)
373 # TODO: Do we signal any completion due to failure?
374
375 def _download_complete(self):
376 log.info('download-completed', name=self.name)
377
378 self._cancel_deferred()
379 self._download_state = ImageDownload.DOWNLOAD_SUCCEEDED
380 self._downloaded_octets = 123456
381 self._failure_reason = ImageDownload.NO_ERROR
382
383 reactor.callLater(0, self._cleanup_download_job, 20)
384 reactor.callLater(0, self._cleanup_server_profile, 20)
385 # TODO: How do we signal completion?
386
387 device = self._handler.adapter_agent.get_device(self.device_id)
388 if device is not None:
389 # restore admin state to enabled
390 device.admin_state = AdminState.ENABLED
391 self._handler.adapter_agent.update_device(device)
392
393 def cancel_download(self, request):
394 log.info('cancel-sw-download', name=self.name)
395
396 self._cancel_deferred()
397
398 try:
399 # initiate cancelling software download to device at success
400 # delete image download record
401
402 self._handler.adapter_agent.delete_image_download(request)
403
404 device = self._handler.adapter_agent.get_device(self.device_id)
405 if device is not None:
406 # restore admin state to enabled
407 device.admin_state = AdminState.ENABLED
408 self._handler.adapter_agent.update_device(device)
409
410 except Exception as e:
411 log.exception(e.message)
412
413 reactor.callLater(0, self._cleanup_download_job, 20)
414 reactor.callLater(0, self._cleanup_server_profile, 20)
415
416 @inlineCallbacks
417 def _cleanup_server_profile(self, retries, attempt=1):
418 log.info('cleanup-server', name=self.name,
419 profile=self._server_profile_name,
420 attempt=attempt, remaining=retries)
421
422 if self._server_profile_name is not None:
423 try:
424 profile = self.delete_server_profile_xml
425 yield self._handler.netconf_client.edit_config(profile)
426 self._server_profile_name = None
427
428 except Exception as e:
429 log.exception(e.message)
430 if retries > 0:
431 reactor.callLater(attempt * 60, self._cleanup_download_job,
432 retries - 1, attempt + 1)
433
434 @inlineCallbacks
435 def _cleanup_download_job(self, retries, attempt=1):
436 log.info('cleanup-download', name=self.name,
437 profile=self._download_job_name,
438 attempt=attempt, remaining=retries)
439
440 if self._download_job_name is not None:
441 try:
442 job = self.delete_download_job_xml
443 yield self._handler.netconf_client.edit_config(job)
444 self._download_job_name = None
445
446 except Exception as e:
447 log.exception(e.message)
448 if retries > 0:
449 reactor.callLater(attempt * 60, self._cleanup_download_job,
450 retries - 1, attempt + 1)
451
452 @inlineCallbacks
453 def activate_image(self):
454 log.info('download-activate', name=self.name)
455
456 if self._download_state == ImageDownload.DOWNLOAD_SUCCEEDED:
457 pass # TODO: Implement
458 self._image_state = ImageDownload.IMAGE_ACTIVE
459
460 returnValue('TODO: Implement this')
461
462 @inlineCallbacks
463 def revert_image(self):
464 log.info('download-revert', name=self.name)
465
466 if self._download_state == ImageDownload.DOWNLOAD_SUCCEEDED:
467 pass # TODO: Implement
468 self._image_state = ImageDownload.IMAGE_INACTIVE
469
470 returnValue('TODO: Implement this')
471
472 def monitor_state_to_download_state(self, state):
473 if ':' in state:
474 state = state.split(':')[-1]
475 result = {
476 'downloading-software': ImageDownload.DOWNLOAD_STARTED, # currently downloading software
477 'storing-software': ImageDownload.DOWNLOAD_STARTED, # successfully downloaded the required software and is storing it to memory
478 'software-stored': ImageDownload.DOWNLOAD_SUCCEEDED, # successfully downloaded the required software and has stored it successfully to memory
479 'software-download-failed': ImageDownload.DOWNLOAD_FAILED, # unsuccessfully attemptedto download the required software
480 'invalid-software': ImageDownload.DOWNLOAD_FAILED, # successfully downloaded the required software but the software was determined to be invalid
481 'software-storage-failed': ImageDownload.INSUFFICIENT_SPACE, # successfully downloaded the required software but was unable to successfully stored it to memory
482 }.get(state.lower(), None)
483 log.info('download-software-state', result=result, state=state, name=self.name)
484 assert result is not None, 'Invalid state'
485 return result
486
487 def monitor_state_to_activate_state(self, state):
488 if ':' in state:
489 state = state.split(':')[-1]
490 result = {
491 'enabling-software': ImageDownload.IMAGE_ACTIVATE, # currently enabling the software
492 'software-enabled': ImageDownload.IMAGE_ACTIVE, # successfully enabled the required software
493 'enable-software-failed': ImageDownload.IMAGE_INACTIVE, # unsuccessfully attempted to enable the required software revision
494 'activating-software': ImageDownload.IMAGE_ACTIVATE, # currently activating the software
495 'software-activated': ImageDownload.IMAGE_ACTIVE, # successfully activated the required software. The job terminated successfully
496 'activate-software-failed': ImageDownload.IMAGE_INACTIVE, # unsuccessfully attempted to activate the required software revision
497 'committing-software': ImageDownload.IMAGE_ACTIVATE, # currently committing the software
498 'software-committed': ImageDownload.IMAGE_ACTIVATE, # successfully committed the required software. The job terminated successfully
499 'commit-software-failed': ImageDownload.IMAGE_INACTIVE, # unsuccessfully attempted to commit the required software revision
500 }.get(state.lower(), None)
501 log.info('download-activate-state', result=result, state=state, name=self.name)
502 assert result is not None, 'Invalid state'
503 return result
504
505 @staticmethod
506 def clear_all(client):
507 """
508 Remove all file server profiles and download jobs
509 :param client: (ncclient) NETCONF Client to use
510 """
511 from twisted.internet import defer
512 del_fs_xml = """
513 <file-servers xmlns="http://www.adtran.com/ns/yang/adtran-file-servers">
514 <profiles operation="delete"/>
515 </file-servers>
516 """
517 del_job_xml = """
518 <maintenance-jobs operation="delete" xmlns="http://www.adtran.com/ns/yang/adtran-maintenance-jobs"/>
519 """
520 dl = [client.edit_config(del_fs_xml, ignore_delete_error=True),
521 client.edit_config(del_job_xml, ignore_delete_error=True)]
522
523 return defer.gatherResults(dl, consumeErrors=True)