Chip Boling | f5af85d | 2019-02-12 15:36:17 -0600 | [diff] [blame^] | 1 | # 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 | |
| 15 | import structlog |
| 16 | import xmltodict |
| 17 | from twisted.internet import reactor |
| 18 | from twisted.internet.defer import returnValue, inlineCallbacks |
| 19 | from pyvoltha.protos.device_pb2 import ImageDownload |
| 20 | from pyvoltha.protos.common_pb2 import AdminState |
| 21 | |
| 22 | log = structlog.get_logger() |
| 23 | |
| 24 | # TODO: Following two would be good provisionable parameters |
| 25 | DEFAULT_AUTO_AGE_MINUTES = 10 |
| 26 | DEFAULT_MAX_JOB_RUN_SECONDS = 3600 * 4 # Some OLT files are 250MB+ |
| 27 | |
| 28 | |
| 29 | class 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) |