Package ganeti :: Package rapi :: Module baserlib
[hide private]
[frames] | no frames]

Source Code for Module ganeti.rapi.baserlib

  1  # 
  2  # 
  3   
  4  # Copyright (C) 2006, 2007, 2008, 2012 Google Inc. 
  5  # All rights reserved. 
  6  # 
  7  # Redistribution and use in source and binary forms, with or without 
  8  # modification, are permitted provided that the following conditions are 
  9  # met: 
 10  # 
 11  # 1. Redistributions of source code must retain the above copyright notice, 
 12  # this list of conditions and the following disclaimer. 
 13  # 
 14  # 2. Redistributions in binary form must reproduce the above copyright 
 15  # notice, this list of conditions and the following disclaimer in the 
 16  # documentation and/or other materials provided with the distribution. 
 17  # 
 18  # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 
 19  # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 
 20  # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 
 21  # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 
 22  # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 
 23  # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 
 24  # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 
 25  # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 
 26  # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 
 27  # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 
 28  # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 
 29   
 30   
 31  """Remote API base resources library. 
 32   
 33  """ 
 34   
 35  # pylint: disable=C0103 
 36   
 37  # C0103: Invalid name, since the R_* names are not conforming 
 38   
 39  import logging 
 40   
 41  from ganeti import luxi 
 42  import ganeti.rpc.errors as rpcerr 
 43  from ganeti import rapi 
 44  from ganeti import http 
 45  from ganeti import errors 
 46  from ganeti import compat 
 47  from ganeti import constants 
 48  from ganeti import utils 
 49   
 50   
 51  # Dummy value to detect unchanged parameters 
 52  _DEFAULT = object() 
 53   
 54  #: Supported HTTP methods 
 55  _SUPPORTED_METHODS = compat.UniqueFrozenset([ 
 56    http.HTTP_DELETE, 
 57    http.HTTP_GET, 
 58    http.HTTP_POST, 
 59    http.HTTP_PUT, 
 60    ]) 
 61   
 62   
63 -class OpcodeAttributes(object):
64 """Acts as a structure containing the per-method attribute names. 65 66 """ 67 __slots__ = [ 68 "method", 69 "opcode", 70 "rename", 71 "aliases", 72 "forbidden", 73 "get_input", 74 ] 75
76 - def __init__(self, method_name):
77 """Initializes the opcode attributes for the given method name. 78 79 """ 80 self.method = method_name 81 self.opcode = "%s_OPCODE" % method_name 82 self.rename = "%s_RENAME" % method_name 83 self.aliases = "%s_ALIASES" % method_name 84 self.forbidden = "%s_FORBIDDEN" % method_name 85 self.get_input = "Get%sOpInput" % method_name.capitalize()
86
87 - def GetModifiers(self):
88 """Returns the names of all the attributes that replace or modify a method. 89 90 """ 91 return [self.opcode, self.rename, self.aliases, self.forbidden, 92 self.get_input]
93
94 - def GetAll(self):
95 return [self.method] + self.GetModifiers()
96 97
98 -def _BuildOpcodeAttributes():
99 """Builds list of attributes used for per-handler opcodes. 100 101 """ 102 return [OpcodeAttributes(method) for method in _SUPPORTED_METHODS]
103 104 105 OPCODE_ATTRS = _BuildOpcodeAttributes() 106 107
108 -def BuildUriList(ids, uri_format, uri_fields=("name", "uri")):
109 """Builds a URI list as used by index resources. 110 111 @param ids: list of ids as strings 112 @param uri_format: format to be applied for URI 113 @param uri_fields: optional parameter for field IDs 114 115 """ 116 (field_id, field_uri) = uri_fields 117 118 def _MapId(m_id): 119 return { 120 field_id: m_id, 121 field_uri: uri_format % m_id, 122 }
123 124 # Make sure the result is sorted, makes it nicer to look at and simplifies 125 # unittests. 126 ids.sort() 127 128 return map(_MapId, ids) 129 130
131 -def MapFields(names, data):
132 """Maps two lists into one dictionary. 133 134 Example:: 135 >>> MapFields(["a", "b"], ["foo", 123]) 136 {'a': 'foo', 'b': 123} 137 138 @param names: field names (list of strings) 139 @param data: field data (list) 140 141 """ 142 if len(names) != len(data): 143 raise AttributeError("Names and data must have the same length") 144 return dict(zip(names, data))
145 146
147 -def MapBulkFields(itemslist, fields):
148 """Map value to field name in to one dictionary. 149 150 @param itemslist: a list of items values 151 @param fields: a list of items names 152 153 @return: a list of mapped dictionaries 154 155 """ 156 items_details = [] 157 for item in itemslist: 158 mapped = MapFields(fields, item) 159 items_details.append(mapped) 160 return items_details
161 162
163 -def FillOpcode(opcls, body, static, rename=None):
164 """Fills an opcode with body parameters. 165 166 Parameter types are checked. 167 168 @type opcls: L{opcodes.OpCode} 169 @param opcls: Opcode class 170 @type body: dict 171 @param body: Body parameters as received from client 172 @type static: dict 173 @param static: Static parameters which can't be modified by client 174 @type rename: dict 175 @param rename: Renamed parameters, key as old name, value as new name 176 @return: Opcode object 177 178 """ 179 if body is None: 180 params = {} 181 else: 182 CheckType(body, dict, "Body contents") 183 184 # Make copy to be modified 185 params = body.copy() 186 187 if rename: 188 for old, new in rename.items(): 189 if new in params and old in params: 190 raise http.HttpBadRequest("Parameter '%s' was renamed to '%s', but" 191 " both are specified" % 192 (old, new)) 193 if old in params: 194 assert new not in params 195 params[new] = params.pop(old) 196 197 if static: 198 overwritten = set(params.keys()) & set(static.keys()) 199 if overwritten: 200 raise http.HttpBadRequest("Can't overwrite static parameters %r" % 201 overwritten) 202 203 params.update(static) 204 205 # Convert keys to strings (simplejson decodes them as unicode) 206 params = dict((str(key), value) for (key, value) in params.items()) 207 208 try: 209 op = opcls(**params) # pylint: disable=W0142 210 op.Validate(False) 211 except (errors.OpPrereqError, TypeError), err: 212 raise http.HttpBadRequest("Invalid body parameters: %s" % err) 213 214 return op
215 216
217 -def HandleItemQueryErrors(fn, *args, **kwargs):
218 """Converts errors when querying a single item. 219 220 """ 221 try: 222 result = fn(*args, **kwargs) 223 except errors.OpPrereqError, err: 224 if len(err.args) == 2 and err.args[1] == errors.ECODE_NOENT: 225 raise http.HttpNotFound() 226 227 raise 228 229 # In case split query mechanism is used 230 if not result: 231 raise http.HttpNotFound() 232 233 return result
234 235
236 -def FeedbackFn(msg):
237 """Feedback logging function for jobs. 238 239 We don't have a stdout for printing log messages, so log them to the 240 http log at least. 241 242 @param msg: the message 243 244 """ 245 (_, log_type, log_msg) = msg 246 logging.info("%s: %s", log_type, log_msg)
247 248
249 -def CheckType(value, exptype, descr):
250 """Abort request if value type doesn't match expected type. 251 252 @param value: Value 253 @type exptype: type 254 @param exptype: Expected type 255 @type descr: string 256 @param descr: Description of value 257 @return: Value (allows inline usage) 258 259 """ 260 if not isinstance(value, exptype): 261 raise http.HttpBadRequest("%s: Type is '%s', but '%s' is expected" % 262 (descr, type(value).__name__, exptype.__name__)) 263 264 return value
265 266
267 -def CheckParameter(data, name, default=_DEFAULT, exptype=_DEFAULT):
268 """Check and return the value for a given parameter. 269 270 If no default value was given and the parameter doesn't exist in the input 271 data, an error is raise. 272 273 @type data: dict 274 @param data: Dictionary containing input data 275 @type name: string 276 @param name: Parameter name 277 @param default: Default value (can be None) 278 @param exptype: Expected type (can be None) 279 280 """ 281 try: 282 value = data[name] 283 except KeyError: 284 if default is not _DEFAULT: 285 return default 286 287 raise http.HttpBadRequest("Required parameter '%s' is missing" % 288 name) 289 290 if exptype is _DEFAULT: 291 return value 292 293 return CheckType(value, exptype, "'%s' parameter" % name)
294 295
296 -class ResourceBase(object):
297 """Generic class for resources. 298 299 """ 300 # Default permission requirements 301 GET_ACCESS = [] 302 PUT_ACCESS = [rapi.RAPI_ACCESS_WRITE] 303 POST_ACCESS = [rapi.RAPI_ACCESS_WRITE] 304 DELETE_ACCESS = [rapi.RAPI_ACCESS_WRITE] 305
306 - def __init__(self, items, queryargs, req, _client_cls=None):
307 """Generic resource constructor. 308 309 @param items: a list with variables encoded in the URL 310 @param queryargs: a dictionary with additional options from URL 311 @param req: Request context 312 @param _client_cls: L{luxi} client class (unittests only) 313 314 """ 315 assert isinstance(queryargs, dict) 316 317 self.items = items 318 self.queryargs = queryargs 319 self._req = req 320 321 if _client_cls is None: 322 _client_cls = luxi.Client 323 324 self._client_cls = _client_cls
325
326 - def _GetRequestBody(self):
327 """Returns the body data. 328 329 """ 330 return self._req.private.body_data
331 332 request_body = property(fget=_GetRequestBody) 333
334 - def _checkIntVariable(self, name, default=0):
335 """Return the parsed value of an int argument. 336 337 """ 338 val = self.queryargs.get(name, default) 339 if isinstance(val, list): 340 if val: 341 val = val[0] 342 else: 343 val = default 344 try: 345 val = int(val) 346 except (ValueError, TypeError): 347 raise http.HttpBadRequest("Invalid value for the" 348 " '%s' parameter" % (name,)) 349 return val
350
351 - def _checkStringVariable(self, name, default=None):
352 """Return the parsed value of a string argument. 353 354 """ 355 val = self.queryargs.get(name, default) 356 if isinstance(val, list): 357 if val: 358 val = val[0] 359 else: 360 val = default 361 return val
362
363 - def getBodyParameter(self, name, *args):
364 """Check and return the value for a given parameter. 365 366 If a second parameter is not given, an error will be returned, 367 otherwise this parameter specifies the default value. 368 369 @param name: the required parameter 370 371 """ 372 if args: 373 return CheckParameter(self.request_body, name, default=args[0]) 374 375 return CheckParameter(self.request_body, name)
376
377 - def useLocking(self):
378 """Check if the request specifies locking. 379 380 """ 381 return bool(self._checkIntVariable("lock"))
382
383 - def useBulk(self):
384 """Check if the request specifies bulk querying. 385 386 """ 387 return bool(self._checkIntVariable("bulk"))
388
389 - def useForce(self):
390 """Check if the request specifies a forced operation. 391 392 """ 393 return bool(self._checkIntVariable("force"))
394
395 - def dryRun(self):
396 """Check if the request specifies dry-run mode. 397 398 """ 399 return bool(self._checkIntVariable("dry-run"))
400
401 - def GetClient(self):
402 """Wrapper for L{luxi.Client} with HTTP-specific error handling. 403 404 """ 405 # Could be a function, pylint: disable=R0201 406 try: 407 return self._client_cls() 408 except rpcerr.NoMasterError, err: 409 raise http.HttpBadGateway("Can't connect to master daemon: %s" % err) 410 except rpcerr.PermissionError: 411 raise http.HttpInternalServerError("Internal error: no permission to" 412 " connect to the master daemon")
413
414 - def SubmitJob(self, op, cl=None):
415 """Generic wrapper for submit job, for better http compatibility. 416 417 @type op: list 418 @param op: the list of opcodes for the job 419 @type cl: None or luxi.Client 420 @param cl: optional luxi client to use 421 @rtype: string 422 @return: the job ID 423 424 """ 425 if cl is None: 426 cl = self.GetClient() 427 try: 428 return cl.SubmitJob(op) 429 except errors.JobQueueFull: 430 raise http.HttpServiceUnavailable("Job queue is full, needs archiving") 431 except errors.JobQueueDrainError: 432 raise http.HttpServiceUnavailable("Job queue is drained, cannot submit") 433 except rpcerr.NoMasterError, err: 434 raise http.HttpBadGateway("Master seems to be unreachable: %s" % err) 435 except rpcerr.PermissionError: 436 raise http.HttpInternalServerError("Internal error: no permission to" 437 " connect to the master daemon") 438 except rpcerr.TimeoutError, err: 439 raise http.HttpGatewayTimeout("Timeout while talking to the master" 440 " daemon: %s" % err)
441 442
443 -def GetResourceOpcodes(cls):
444 """Returns all opcodes used by a resource. 445 446 """ 447 return frozenset(filter(None, (getattr(cls, method_attrs.opcode, None) 448 for method_attrs in OPCODE_ATTRS)))
449 450
451 -def GetHandlerAccess(handler, method):
452 """Returns the access rights for a method on a handler. 453 454 @type handler: L{ResourceBase} 455 @type method: string 456 @rtype: string or None 457 458 """ 459 return getattr(handler, "%s_ACCESS" % method, None)
460 461
462 -def GetHandler(get_fn, aliases):
463 result = get_fn() 464 if not isinstance(result, dict) or aliases is None: 465 return result 466 467 for (param, alias) in aliases.items(): 468 if param in result: 469 if alias in result: 470 raise http.HttpBadRequest("Parameter '%s' has an alias of '%s', but" 471 " both values are present in response" % 472 (param, alias)) 473 result[alias] = result[param] 474 475 return result
476 477 478 # Constant used to denote that a parameter cannot be set 479 ALL_VALUES_FORBIDDEN = "all_values_forbidden" 480 481
482 -def ProduceForbiddenParamDict(class_name, method_name, param_list):
483 """Turns a list of parameter names and possibly values into a dictionary. 484 485 @type class_name: string 486 @param class_name: The name of the handler class 487 @type method_name: string 488 @param method_name: The name of the HTTP method 489 @type param_list: list of string or tuple of (string, list of any) 490 @param param_list: A list of forbidden parameters, specified in the RAPI 491 handler class 492 493 @return: The dictionary of forbidden param names to values or 494 ALL_VALUES_FORBIDDEN 495 496 """ 497 # A simple error-raising function 498 def _RaiseError(message): 499 raise errors.ProgrammerError( 500 "While examining the %s_FORBIDDEN field of class %s: %s" % 501 (method_name, class_name, message) 502 )
503 504 param_dict = {} 505 for value in param_list: 506 if isinstance(value, basestring): 507 param_dict[value] = ALL_VALUES_FORBIDDEN 508 elif isinstance(value, tuple): 509 if len(value) != 2: 510 _RaiseError("Tuples of only length 2 allowed") 511 param_name, forbidden_values = value 512 param_dict[param_name] = forbidden_values 513 else: 514 _RaiseError("Only strings or tuples allowed, found %s" % value) 515 516 return param_dict 517 518
519 -def InspectParams(params_dict, forbidden_params, rename_dict):
520 """Inspects a dictionary of params, looking for forbidden values. 521 522 @type params_dict: dict of string to anything 523 @param params_dict: A dictionary of supplied parameters 524 @type forbidden_params: dict of string to string or list of any 525 @param forbidden_params: The forbidden parameters, with a list of forbidden 526 values or the constant ALL_VALUES_FORBIDDEN 527 signifying that all values are forbidden 528 @type rename_dict: None or dict of string to string 529 @param rename_dict: The list of parameter renamings used by the method 530 531 @raise http.HttpForbidden: If a forbidden param has been set 532 533 """ 534 for param in params_dict: 535 # Check for possible renames to ensure nothing slips through 536 if rename_dict is not None and param in rename_dict: 537 param = rename_dict[param] 538 539 # Now see if there are restrictions on this parameter 540 if param in forbidden_params: 541 forbidden_values = forbidden_params[param] 542 if forbidden_values == ALL_VALUES_FORBIDDEN: 543 raise http.HttpForbidden("The parameter %s cannot be set via RAPI" % 544 param) 545 546 param_value = params_dict[param] 547 if param_value in forbidden_values: 548 raise http.HttpForbidden("The parameter %s cannot be set to the value" 549 " %s via RAPI" % (param, param_value))
550 551
552 -class _MetaOpcodeResource(type):
553 """Meta class for RAPI resources. 554 555 """
556 - def __call__(mcs, *args, **kwargs):
557 """Instantiates class and patches it for use by the RAPI daemon. 558 559 """ 560 # Access to private attributes of a client class, pylint: disable=W0212 561 obj = type.__call__(mcs, *args, **kwargs) 562 563 for m_attrs in OPCODE_ATTRS: 564 method, op_attr, rename_attr, aliases_attr, _, fn_attr = m_attrs.GetAll() 565 if hasattr(obj, method): 566 # If the method handler is already defined, "*_RENAME" or 567 # "Get*OpInput" shouldn't be (they're only used by the automatically 568 # generated handler) 569 assert not hasattr(obj, rename_attr) 570 assert not hasattr(obj, fn_attr) 571 572 # The aliases are allowed only on GET calls 573 assert not hasattr(obj, aliases_attr) or method == http.HTTP_GET 574 575 # GET methods can add aliases of values they return under a different 576 # name 577 if method == http.HTTP_GET and hasattr(obj, aliases_attr): 578 setattr(obj, method, 579 compat.partial(GetHandler, getattr(obj, method), 580 getattr(obj, aliases_attr))) 581 else: 582 # Try to generate handler method on handler instance 583 try: 584 opcode = getattr(obj, op_attr) 585 except AttributeError: 586 pass 587 else: 588 setattr(obj, method, 589 compat.partial(obj._GenericHandler, opcode, 590 getattr(obj, rename_attr, None), 591 getattr(obj, fn_attr, obj._GetDefaultData))) 592 593 # Finally, the method (generated or not) should be wrapped to handle 594 # forbidden values 595 if hasattr(obj, m_attrs.forbidden): 596 forbidden_dict = ProduceForbiddenParamDict( 597 obj.__class__.__name__, method, getattr(obj, m_attrs.forbidden) 598 ) 599 setattr( 600 obj, method, compat.partial(obj._ForbiddenHandler, 601 getattr(obj, method), 602 forbidden_dict, 603 getattr(obj, m_attrs.rename, None)) 604 ) 605 606 return obj
607 608
609 -class OpcodeResource(ResourceBase):
610 """Base class for opcode-based RAPI resources. 611 612 Instances of this class automatically gain handler functions through 613 L{_MetaOpcodeResource} for any method for which a C{$METHOD$_OPCODE} variable 614 is defined at class level. Subclasses can define a C{Get$Method$OpInput} 615 method to do their own opcode input processing (e.g. for static values). The 616 C{$METHOD$_RENAME} variable defines which values are renamed (see 617 L{baserlib.FillOpcode}). 618 Still default behavior cannot be totally overriden. There are opcode params 619 that are available to all opcodes, e.g. "depends". In case those params 620 (currently only "depends") are found in the original request's body, they are 621 added to the dictionary of parsed parameters and eventually passed to the 622 opcode. If the parsed body is not represented as a dictionary object, the 623 values are not added. 624 625 @cvar GET_OPCODE: Set this to a class derived from L{opcodes.OpCode} to 626 automatically generate a GET handler submitting the opcode 627 @cvar GET_RENAME: Set this to rename parameters in the GET handler (see 628 L{baserlib.FillOpcode}) 629 @cvar GET_FORBIDDEN: Set this to disable listed parameters and optionally 630 specific values from being set through the GET handler (see 631 L{baserlib.InspectParams}) 632 @cvar GET_ALIASES: Set this to duplicate return values in GET results (see 633 L{baserlib.GetHandler}) 634 @ivar GetGetOpInput: Define this to override the default method for 635 getting opcode parameters (see L{baserlib.OpcodeResource._GetDefaultData}) 636 637 @cvar PUT_OPCODE: Set this to a class derived from L{opcodes.OpCode} to 638 automatically generate a PUT handler submitting the opcode 639 @cvar PUT_RENAME: Set this to rename parameters in the PUT handler (see 640 L{baserlib.FillOpcode}) 641 @cvar PUT_FORBIDDEN: Set this to disable listed parameters and optionally 642 specific values from being set through the PUT handler (see 643 L{baserlib.InspectParams}) 644 @ivar GetPutOpInput: Define this to override the default method for 645 getting opcode parameters (see L{baserlib.OpcodeResource._GetDefaultData}) 646 647 @cvar POST_OPCODE: Set this to a class derived from L{opcodes.OpCode} to 648 automatically generate a POST handler submitting the opcode 649 @cvar POST_RENAME: Set this to rename parameters in the POST handler (see 650 L{baserlib.FillOpcode}) 651 @cvar POST_FORBIDDEN: Set this to disable listed parameters and optionally 652 specific values from being set through the POST handler (see 653 L{baserlib.InspectParams}) 654 @ivar GetPostOpInput: Define this to override the default method for 655 getting opcode parameters (see L{baserlib.OpcodeResource._GetDefaultData}) 656 657 @cvar DELETE_OPCODE: Set this to a class derived from L{opcodes.OpCode} to 658 automatically generate a DELETE handler submitting the opcode 659 @cvar DELETE_RENAME: Set this to rename parameters in the DELETE handler (see 660 L{baserlib.FillOpcode}) 661 @cvar DELETE_FORBIDDEN: Set this to disable listed parameters and optionally 662 specific values from being set through the DELETE handler (see 663 L{baserlib.InspectParams}) 664 @ivar GetDeleteOpInput: Define this to override the default method for 665 getting opcode parameters (see L{baserlib.OpcodeResource._GetDefaultData}) 666 667 """ 668 __metaclass__ = _MetaOpcodeResource 669
670 - def _ForbiddenHandler(self, method_fn, forbidden_params, rename_dict):
671 """Examines provided parameters for forbidden values. 672 673 """ 674 InspectParams(self.queryargs, forbidden_params, rename_dict) 675 InspectParams(self.request_body, forbidden_params, rename_dict) 676 return method_fn()
677
678 - def _GetDefaultData(self):
679 return (self.request_body, None)
680
681 - def _GetRapiOpName(self):
682 """Extracts the name of the RAPI operation from the class name 683 684 """ 685 if self.__class__.__name__.startswith("R_2_"): 686 return self.__class__.__name__[4:] 687 return self.__class__.__name__
688
689 - def _GetCommonStatic(self):
690 """Return the static parameters common to all the RAPI calls 691 692 The reason is a parameter present in all the RAPI calls, and the reason 693 trail has to be build for all of them, so the parameter is read here and 694 used to build the reason trail, that is the actual parameter passed 695 forward. 696 697 """ 698 trail = [] 699 usr_reason = self._checkStringVariable("reason", default=None) 700 if usr_reason: 701 trail.append((constants.OPCODE_REASON_SRC_USER, 702 usr_reason, 703 utils.EpochNano())) 704 reason_src = "%s:%s" % (constants.OPCODE_REASON_SRC_RLIB2, 705 self._GetRapiOpName()) 706 trail.append((reason_src, "", utils.EpochNano())) 707 common_static = { 708 "reason": trail, 709 } 710 return common_static
711
712 - def _GetDepends(self):
713 ret = {} 714 if isinstance(self.request_body, dict): 715 depends = self.getBodyParameter("depends", None) 716 if depends: 717 ret.update({"depends": depends}) 718 return ret
719
720 - def _GenericHandler(self, opcode, rename, fn):
721 (body, specific_static) = fn() 722 if isinstance(body, dict): 723 body.update(self._GetDepends()) 724 static = self._GetCommonStatic() 725 if specific_static: 726 static.update(specific_static) 727 op = FillOpcode(opcode, body, static, rename=rename) 728 return self.SubmitJob([op])
729