3 ##############################################################################
5 # action_library_client.py
7 # A command-line client for the SDC Action Library.
12 # Usage: action_library_client.py [--help] [--url <url>] [--in <filename>]
13 # [--out <filename>] [--config <filename>]
14 # [--log <filename>] [--uuid <uuid>]
15 # [--curl] [--dryrun] [--verbose] [--version]
16 # [--list | --create | --update= | --delete |
17 # --checkout | --undocheckout | --checkin | --submit]
20 # --help Show this help message and exit
21 # --url <url> REST endpoint URL
22 # --in <filename> Path to JSON input file (else STDIN)
23 # --out <filename> Path to JSON output file (else STDOUT or logfile)
24 # --config <filename> Path to configuration file
25 # --log <filename> Path to logfile (else STDOUT)
26 # --uuid <uuid> Action UUID, (=='actionInvariantUUID')
27 # --curl Use curl transport impl
28 # --dryrun Describe what will happen, execute nothing
29 # --verbose Verbose diagnostic output
30 # --version Print script version and exit
32 # --create Create new action (requires --in)
33 # --update Update existing action (requires --uuid, --in)
34 # --delete Delete existing action (requires --uuid)
35 # --checkout Create minor version candidate (requires --uuid)
36 # --undocheckout Discard minor version candidate (requires --uuid)
37 # --checkin Create minor version from candidate (requires --uuid)
38 # --submit Create next major version (requires --uuid)
42 # ./action_library_client.py --url http://10.147.97.199:8080 --list
48 # - 2 - ARGUMENTS_ERROR
49 # - 3 - HTTP_FORBIDDEN_ERROR
50 # - 4 - HTTP_BAD_REQUEST_ERROR
51 # - 5 - HTTP_GENERAL_ERROR
54 # - Delimited by "----------"
55 # - Delimiter overrideable with ALC_JSON_DELIMITER setting.
57 # Configuration/env settings:
58 # - ALC_HTTP_USER - HTTP BASIC username
59 # - ALC_HTTP_PASS - HTTP BASIC password
60 # - ALC_HTTP_INSECURE - allow untrusted SSL (server) connections.
61 # - ALC_TIMEOUT_SECONDS - invocation (e.g. HTTP) timeout in seconds.
62 # - ALC_JSON_DELIMITER - JSON delimiter in ouput.
63 # - ALC_ECOMP_INSTANCE_ID - X-ECOMP-InstanceID header
65 # Configuration by 0600-mode INI file (section "action_library_client") is preferred.
68 # http://10.147.97.199:8080/api-docs/ - REST API Swagger docs
69 # https://www.python.org/dev/peps/pep-0008/ - style guide
70 # ../doc/SDC_Action_Lib_API_AID_1610_13.pdf - REST API dev guide
73 # - 1.0.0 November 28th 2016, LP, initial impl.
74 # - 1.0.1 November 29th 2016, LP, constants, documentation, add --version.
75 # - 1.0.2 November 29th 2016, LP, logging to files, stream-handling.
76 # - 1.0.3 November 30th 2016, LP, optionally read config from env or config file.
77 # - 1.1.0 December 3rd 2016, LP, backport from Python 3.4.2 to 2.6.6(!).
79 ##############################################################################
93 from abc import abstractmethod
96 ###############################################################################
99 class Constants(object):
100 """Common constants, for want of a better language feature..."""
103 APPLICATION = "action_library_client"
104 ACTIONS_URI = "onboarding-api/workflow/v1.0/actions"
105 ECOMP_INSTANCE_ID = "sdc_alc"
106 TIMEOUT_SECONDS_DEFAULT = 30
107 JSON_DELIMITER_DEFAULT = "----------"
108 LOG_FORMAT = "%(name)s\t%(levelname)s\t%(asctime)s\t%(message)s"
109 # Env variable names.
110 ENV_HTTP_USER = "ALC_HTTP_USER"
111 ENV_HTTP_PASS = "ALC_HTTP_PASS"
112 ENV_HTTP_INSECURE = "ALC_HTTP_INSECURE"
113 ENV_HTTP_CAFILE = "ALC_HTTP_CAFILE"
114 ENV_TIMEOUT_SECONDS = "ALC_TIMEOUT_SECONDS"
115 ENV_JSON_DELIMITER = "ALC_JSON_DELIMITER"
116 ENV_ECOMP_INSTANCE_ID = "ALC_ECOMP_INSTANCE_ID"
119 ###############################################################################
122 class ResponseCodes(object):
123 """Responses returned by IRESTClient impls."""
127 HTTP_NOT_FOUND_ERROR = 3
128 HTTP_FORBIDDEN_ERROR = 4
129 HTTP_BAD_REQUEST_ERROR = 5
130 HTTP_GENERAL_ERROR = 6
131 PROCESS_GENERAL_ERROR = 9
134 ###############################################################################
137 class FinalizeStatus(object):
138 """Finalization operations."""
139 Checkout = "Checkout"
140 UndoCheckout = "Undo_Checkout"
145 ###############################################################################
148 class ArgsDict(dict):
149 """A dict which makes attributes accessible as properties."""
150 def __getattr__(self, attr):
153 def __setattr__(self, attr, value):
157 ###############################################################################
160 class ArgumentParser(object):
161 """A minimal reimpl of the argparse library, core in later Python releases"""
162 ACTIONS = ["list", "create", "update", "delete", "checkout", "undocheckout", "checkin", "submit"]
163 PARMS = ["url", "in", "out", "config", "log", "uuid"]
164 OTHER = ["curl", "dryrun", "verbose", "version", "help"]
166 def parse_args(self, clargs):
167 """Parse command-line args, returning a dict that exposes everything as properties."""
170 for arg in self.ACTIONS + self.PARMS + self.OTHER:
174 for i, clarg in enumerate(clargs):
178 if not clarg.startswith("--"):
179 raise Exception("Invalid argument: {0}".format(clarg))
181 if arg in self.ACTIONS:
183 raise Exception("Duplicate actions: --{0}, {1}".format(args.action, clarg))
185 elif arg in self.PARMS:
187 args[arg] = clargs[i + 1]
190 raise Exception("Option {0} requires an argument".format(clarg))
191 elif arg in self.OTHER:
194 raise Exception("Invalid argument: {0}".format(clarg))
200 raise Exception("--url required for every action")
202 if args.action not in ["create", "list"]:
203 raise Exception("--uuid required for every action EXCEPT --list/--create")
205 # Read from file or stdin, and replace the problematic "in"
206 # property with "infile".
208 if args.action in ["create", "update"]:
210 args.infile = open(args["in"], mode="r")
212 args.infile = sys.stdin
214 except Exception as e:
216 ArgumentParser.usage()
217 sys.exit(ResponseCodes.ARGUMENTS_ERROR)
222 """Print usage message."""
224 "Usage: action_library_client.py [--help] [--url <url>] [--in <filename>]\n" +
225 " [--out <filename>] [--config <filename>]\n" +
226 " [--log <filename>] [--uuid <uuid>]\n" +
227 " [--curl] [--dryrun] [--verbose] [--version]\n" +
228 " [--list | --create | --update= | --delete |\n" +
229 " --checkout | --undocheckout | --checkin | --submit]\n" +
231 "Optional arguments:\n" +
232 " --help Show this help message and exit\n" +
233 " --url <url> REST endpoint URL\n" +
234 " --in <filename> Path to JSON input file (else STDIN)\n" +
235 " --out <filename> Path to JSON output file (else STDOUT or logfile)\n" +
236 " --config <filename> Path to configuration file\n" +
237 " --log <filename> Path to logfile (else STDOUT)\n" +
238 " --uuid <uuid> Action UUID, (=='actionInvariantUUID')\n" +
239 " --curl Use curl transport impl\n" +
240 " --dryrun Describe what will happen, execute nothing\n" +
241 " --verbose Verbose diagnostic output\n" +
242 " --version Print script version and exit\n" +
243 " --list List actions\n" +
244 " --create Create new action (requires --in)\n" +
245 " --update Update existing action (requires --uuid, --in)\n" +
246 " --delete Delete existing action (requires --uuid)\n" +
247 " --checkout Create minor version candidate (requires --uuid)\n" +
248 " --undocheckout Discard minor version candidate (requires --uuid)\n" +
249 " --checkin Create minor version from candidate (requires --uuid)\n" +
250 " --submit Create next major version (requires --uuid)")
253 ###############################################################################
256 class Settings(object):
257 """Settings read from (optional) configfile, or environment."""
259 def __init__(self, args):
260 """Construct for command-line args."""
261 self.config = ConfigParser.ConfigParser()
263 self.config.read(args.config)
265 def get(self, name, default_value=None):
266 """Get setting from configfile or environment"""
268 return self.config.get(Constants.APPLICATION, name)
269 except (KeyError, ConfigParser.NoSectionError, ConfigParser.NoOptionError):
271 return os.environ[name]
276 ###############################################################################
279 # Python3: metaclass=ABCMeta
280 class IRESTClient(object):
281 """Base class for local, proxy and dryrun impls."""
283 def __init__(self, args):
285 self.logger = Runner.get_logger()
286 self.settings = Settings(args)
290 """Abstract list operation."""
295 """Abstract list operation."""
300 """Abstract list operation."""
305 """Abstract list operation."""
309 def version(self, status):
310 """Abstract list operation."""
316 return str(uuid.uuid4())
318 def get_timeout_seconds(self):
319 """Get request timeout in seconds."""
320 return self.settings.get(Constants.ENV_TIMEOUT_SECONDS,
321 Constants.TIMEOUT_SECONDS_DEFAULT)
323 def get_http_insecure(self):
324 """Get whether SSL certificate checks are (inadvisably) disabled."""
325 return True if self.settings.get(Constants.ENV_HTTP_INSECURE) else False
327 def get_http_cafile(self):
328 """Get optional CA file for SSL server cert validation"""
329 if not self.get_http_insecure():
330 return self.settings.get(Constants.ENV_HTTP_CAFILE)
332 def get_basic_credentials(self):
333 """Generate Authorization: header."""
334 usr = self.settings.get(Constants.ENV_HTTP_USER)
335 pwd = self.settings.get(Constants.ENV_HTTP_PASS)
337 return base64.b64encode(bytes("{0}:{1}".format(usr, pwd))).decode("ascii")
339 raise Exception("REST service credentials not found")
341 def make_service_url(self):
342 """Generate service URL based on command-line arguments."""
344 if "/onboarding-api/" not in url:
345 separator = "" if url.endswith("/") else "/"
346 url = "{0}{1}{2}".format(url, separator, str(Constants.ACTIONS_URI))
348 separator = "" if url.endswith("/") else "/"
349 url = "{0}{1}{2}".format(url, separator, self.args.uuid)
352 def log_json_response(self, method, json_dict):
353 """Log JSON response regardless of transport."""
354 json_str = json.dumps(json_dict, indent=4)
355 delimiter = self.settings.get(Constants.ENV_JSON_DELIMITER, Constants.JSON_DELIMITER_DEFAULT)
356 self.logger.info("HTTP {0} JSON response:\n{1}\n{2}\n{3}\n".format(method, delimiter, json_str, delimiter))
358 with open(self.args.out, "w") as tmp:
362 # Directly to stdout if logging is sent to a file.
365 def log_action(self, action, status=None):
366 """Debug action before invocation."""
367 url = self.make_service_url()
368 name = status if status else self.__get_name()
369 self.logger.debug("{0}::{1}({2})".format(name, action, url))
372 def _get_result_from_http_response(code):
373 """Get script returncode from HTTP error."""
375 return ResponseCodes.HTTP_BAD_REQUEST_ERROR
377 return ResponseCodes.HTTP_FORBIDDEN_ERROR
379 return ResponseCodes.HTTP_NOT_FOUND_ERROR
380 return ResponseCodes.HTTP_GENERAL_ERROR
382 def __get_name(self):
383 """Get classname for diags"""
384 return type(self).__name__
387 ###############################################################################
390 class NativeRESTClient(IRESTClient):
391 """In-process IRESTClient impl."""
394 """In-process list impl."""
395 self.log_action("list")
396 return self.__exec(method="GET", expect_json=True)
399 """In-process create impl."""
400 self.log_action("create")
401 json_bytes = bytes(self.args.infile.read())
402 return self.__exec(method="POST", json_bytes=json_bytes, expect_json=True)
405 """In-process update impl."""
406 self.log_action("update")
407 json_bytes = bytes(self.args.infile.read())
408 return self.__exec(method="PUT", json_bytes=json_bytes, expect_json=True)
411 """In-process delete impl."""
412 self.log_action("delete")
413 return self.__exec(method="DELETE")
415 def version(self, status):
416 """In-process version impl."""
417 self.log_action("version", status)
418 json_bytes = bytes(json.dumps({"status": status}))
419 return self.__exec(method="POST", json_bytes=json_bytes, expect_json=True)
421 def __exec(self, method, json_bytes=None, expect_json=None):
422 """Build command, execute it, validate and return response."""
424 url = self.make_service_url()
425 timeout = float(self.get_timeout_seconds())
426 cafile = self.get_http_cafile()
428 "Content-Type": "application/json",
429 "Accept": "application/json",
430 "Authorization": "Basic {0}".format(self.get_basic_credentials()),
431 "X-ECOMP-InstanceID": Constants.ECOMP_INSTANCE_ID,
432 "X-ECOMP-RequestID": IRESTClient.new_uuid()
435 handler = urllib2.HTTPHandler
436 if hasattr(ssl, 'create_default_context'):
437 ctx = ssl.create_default_context(cafile=cafile)
438 if self.get_http_insecure():
439 ctx.check_hostname = False
440 ctx.verify_mode = ssl.CERT_NONE
441 handler = urllib2.HTTPSHandler(context=ctx) if url.lower().startswith("https") else urllib2.HTTPHandler
443 self.logger.debug("URL {0} {1}: {2}".format(url, method, json_bytes))
445 opener = urllib2.build_opener(handler)
446 request = urllib2.Request(url, data=json_bytes, headers=headers)
447 request.get_method = lambda: method
451 f = opener.open(request, timeout=timeout)
452 return self.__handle_response(f, method, expect_json)
457 except urllib2.HTTPError as err:
458 self.logger.exception(err)
459 return IRESTClient._get_result_from_http_response(err.getcode())
460 except urllib2.URLError as err:
461 self.logger.exception(err)
462 return ResponseCodes.HTTP_GENERAL_ERROR
464 def __handle_response(self, f, method, expect_json):
465 """Devolve response handling because of the """
466 self.logger.debug("HTTP {0} status {1}, reason:\n{2}".format(method, f.getcode(), f.info()))
468 # JSON responses get "returned", but actually it's the logging that
469 # most callers will be looking for.
470 json_body = json.loads(f.read().decode("utf-8"))
471 self.log_json_response(method, json_body)
473 # Not JSON, but the operation succeeded, so return True.
474 return ResponseCodes.OK
477 ###############################################################################
480 class CURLRESTClient(IRESTClient):
481 """Remote/curl IRESTClient impl."""
485 self.log_action("list")
486 return self._exec(method="GET", expect_json=True)
489 """curl create impl"""
490 self.log_action("create")
491 data_args = ["--data", "@{0}".format(self.args.infile.name)]
492 return self._exec(method="POST", extra_args=data_args, expect_json=True)
495 """curl update impl"""
496 self.log_action("update")
497 data_args = ["--data", "@{0}".format(self.args.infile.name)]
498 return self._exec(method="PUT", extra_args=data_args, expect_json=True)
501 """curl delete impl"""
502 self.log_action("delete")
503 return self._exec(method="DELETE", expect_json=False)
505 def version(self, status):
506 """curl version impl"""
507 self.log_action("version", status)
508 with tempfile.NamedTemporaryFile(mode="w", delete=False) as tmp:
509 tmp.write(json.dumps({"status": status}))
511 data_args = ["--data", "@{0}".format(tmp.name)]
512 return self._exec(method="POST", extra_args=data_args, expect_json=True)
514 def make_curl_cmd(self, method, url, extra_args):
515 """Build curl command without executing."""
516 cmd = ["curl", "-i", "-s", "-X", method]
517 if self.get_http_insecure():
519 cmd.extend(["--connect-timeout", str(self.get_timeout_seconds())])
520 cmd.extend(["--header", "Accept: application/json"])
521 cmd.extend(["--header", "Content-Type: application/json"])
522 cmd.extend(["--header", "Authorization: Basic {0}".format(self.get_basic_credentials())])
523 cmd.extend(["--header", "X-ECOMP-InstanceID: {0}".format(Constants.ECOMP_INSTANCE_ID)])
524 cmd.extend(["--header", "X-ECOMP-RequestID: {0}".format(IRESTClient.new_uuid())])
526 for extra_arg in extra_args:
527 cmd.append(extra_arg)
528 cmd.append("{0}".format(url))
532 def debug_curl_cmd(cmd):
533 """Debug curl command, for diags and dryrun."""
536 if token is "curl" or token.startswith("-"):
537 buf = "{0}{1} ".format(buf, token)
539 buf = "{0}\"{1}\" ".format(buf, token)
542 def _exec(self, method, extra_args=None, expect_json=None):
545 Build command, invoke curl, validate and return response.
546 Overridden by DryRunRESTClient.
548 url = self.make_service_url()
549 cmd = self.make_curl_cmd(method, url, extra_args)
550 self.logger.info("Executing: {0}".format(CURLRESTClient.debug_curl_cmd(cmd)))
553 output = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode()
555 return ResponseCodes.OK
557 separator = output.index("\r\n\r\n{")
558 self.logger.debug("HTTP preamble:\n{0}".format(output[:separator]))
559 json_body = json.loads(output[(separator+4):])
560 self.log_json_response(method, json_body)
563 self.logger.warning("Couldn't find HTTP separator in curl output:\n{}".format(output))
564 code = CURLRESTClient.__get_http_code(output)
565 return IRESTClient._get_result_from_http_response(code)
566 except subprocess.CalledProcessError as err:
567 self.logger.exception(err)
568 return ResponseCodes.PROCESS_GENERAL_ERROR
571 def __get_http_code(output):
572 """Attempt to guess HTTP result from (error) output."""
573 for line in output.splitlines():
574 if line.startswith("HTTP"):
575 tokens = line.split()
578 return int(tokens[1])
581 return ResponseCodes.HTTP_GENERAL_ERROR
584 ###############################################################################
587 class DryRunRESTClient(CURLRESTClient):
588 """Neutered IRESTClient impl; only logs."""
590 def _exec(self, method, extra_args=None, expect_json=None):
592 url = self.make_service_url()
593 cmd = self.make_curl_cmd(method, url, extra_args)
594 self.logger.info("[DryRun] {0}".format(CURLRESTClient.debug_curl_cmd(cmd)))
597 ###############################################################################
600 class Runner(object):
601 """A bunch of static housekeeping supporting the launcher."""
605 """Get logger instance."""
606 return logging.getLogger(Constants.APPLICATION)
609 def get_rest_client(args):
610 """Get the configured REST client impl, local, remote or dryrun."""
612 return DryRunRESTClient(args)
614 return CURLRESTClient(args)
616 return NativeRESTClient(args)
620 """Execute the requested action."""
621 client = Runner.get_rest_client(args)
623 print(Constants.VERSION)
625 ArgumentParser.usage()
626 elif args.action == "list":
628 elif args.action == "create":
629 return client.create()
630 elif args.action == "update":
631 return client.update()
632 elif args.action == "delete":
633 return client.delete()
634 elif args.action == "checkout":
635 return client.version(FinalizeStatus.Checkout)
636 elif args.action == "checkin":
637 return client.version(FinalizeStatus.CheckIn)
638 elif args.action == "undocheckout":
639 return client.version(FinalizeStatus.UndoCheckout)
640 elif args.action == "submit":
641 return client.version(FinalizeStatus.Submit)
643 logger = Runner.get_logger()
644 logger.info("No action specified. Try --help.")
648 """Parse command-line args, returning dict."""
649 return ArgumentParser().parse_args(raw)
652 ###############################################################################
656 """Delegate which executes minus error-handling, exposed for unit-testing."""
658 # Intercept Python 2.X.
660 if not (sys.version_info[0] == 2 and sys.version_info[1] >= 6):
661 raise EnvironmentError("Python 2.6/2.7 required")
663 # Parse command-line args.
665 args = Runner.parse_args(raw)
667 # Redirect logging to a file (freeing up STDIN) if directed.
669 logging.basicConfig(level=logging.INFO, filename=args.log, format=Constants.LOG_FORMAT)
673 logger = Runner.get_logger()
675 logger.setLevel(logging.DEBUG)
676 logger.debug("Parsed arguments: {0}".format(args))
680 return Runner.execute(args)
683 ###############################################################################
687 """Execute for command-line arguments."""
689 logger = Runner.get_logger()
691 result = execute(raw)
692 result_code = result if isinstance(result, int) else ResponseCodes.OK
693 logger.debug("Execution complete. Returning result {0} ({1})".format(result, result_code))
694 sys.exit(result_code)
695 except Exception as err:
696 logger.exception(err)
697 sys.exit(ResponseCodes.GENERAL_ERROR)
700 ###############################################################################
703 if __name__ == "__main__":