From f5f13c4f6b6fe3b4d98e349dfd7db59339803436 Mon Sep 17 00:00:00 2001 From: Michael Lando Date: Sun, 19 Feb 2017 12:35:04 +0200 Subject: push addional code Change-Id: Ia427bb3460cda3a896f8faced2de69eaf3807b74 Signed-off-by: Michael Lando --- .../action_library_client/action_library_client.py | 704 +++++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100644 openecomp-be/tools/build/scripts/action_library_client/action_library_client.py (limited to 'openecomp-be/tools/build/scripts/action_library_client/action_library_client.py') diff --git a/openecomp-be/tools/build/scripts/action_library_client/action_library_client.py b/openecomp-be/tools/build/scripts/action_library_client/action_library_client.py new file mode 100644 index 0000000000..7d6e32e4a8 --- /dev/null +++ b/openecomp-be/tools/build/scripts/action_library_client/action_library_client.py @@ -0,0 +1,704 @@ +#!/usr/bin/python + +############################################################################## +# +# action_library_client.py +# +# A command-line client for the SDC Action Library. +# +# +# Usage: +# +# Usage: action_library_client.py [--help] [--url ] [--in ] +# [--out ] [--config ] +# [--log ] [--uuid ] +# [--curl] [--dryrun] [--verbose] [--version] +# [--list | --create | --update= | --delete | +# --checkout | --undocheckout | --checkin | --submit] +# +# Optional arguments: +# --help Show this help message and exit +# --url REST endpoint URL +# --in Path to JSON input file (else STDIN) +# --out Path to JSON output file (else STDOUT or logfile) +# --config Path to configuration file +# --log Path to logfile (else STDOUT) +# --uuid Action UUID, (=='actionInvariantUUID') +# --curl Use curl transport impl +# --dryrun Describe what will happen, execute nothing +# --verbose Verbose diagnostic output +# --version Print script version and exit +# --list List actions +# --create Create new action (requires --in) +# --update Update existing action (requires --uuid, --in) +# --delete Delete existing action (requires --uuid) +# --checkout Create minor version candidate (requires --uuid) +# --undocheckout Discard minor version candidate (requires --uuid) +# --checkin Create minor version from candidate (requires --uuid) +# --submit Create next major version (requires --uuid) +# +# For example: +# +# ./action_library_client.py --url http://10.147.97.199:8080 --list +# +# Output: +# - Return values: +# - 0 - OK +# - 1 - GENERAL_ERROR +# - 2 - ARGUMENTS_ERROR +# - 3 - HTTP_FORBIDDEN_ERROR +# - 4 - HTTP_BAD_REQUEST_ERROR +# - 5 - HTTP_GENERAL_ERROR +# - 6 - PROCESS_ERROR +# - JSON - to stdout: +# - Delimited by "----------" +# - Delimiter overrideable with ALC_JSON_DELIMITER setting. +# +# Configuration/env settings: +# - ALC_HTTP_USER - HTTP BASIC username +# - ALC_HTTP_PASS - HTTP BASIC password +# - ALC_HTTP_INSECURE - allow untrusted SSL (server) connections. +# - ALC_TIMEOUT_SECONDS - invocation (e.g. HTTP) timeout in seconds. +# - ALC_JSON_DELIMITER - JSON delimiter in ouput. +# - ALC_ECOMP_INSTANCE_ID - X-ECOMP-InstanceID header +# +# Configuration by 0600-mode INI file (section "action_library_client") is preferred. +# +# See: +# http://10.147.97.199:8080/api-docs/ - REST API Swagger docs +# https://www.python.org/dev/peps/pep-0008/ - style guide +# ../doc/SDC_Action_Lib_API_AID_1610_13.pdf - REST API dev guide +# +# Version history: +# - 1.0.0 November 28th 2016, LP, initial impl. +# - 1.0.1 November 29th 2016, LP, constants, documentation, add --version. +# - 1.0.2 November 29th 2016, LP, logging to files, stream-handling. +# - 1.0.3 November 30th 2016, LP, optionally read config from env or config file. +# - 1.1.0 December 3rd 2016, LP, backport from Python 3.4.2 to 2.6.6(!). +# +############################################################################## + + +import sys +import os +import logging +import base64 +import tempfile +import uuid +import json +import ssl +import urllib2 +import subprocess +import ConfigParser +from abc import abstractmethod + + +############################################################################### + + +class Constants(object): + """Common constants, for want of a better language feature...""" + # Values. + VERSION = "1.1.0" + APPLICATION = "action_library_client" + ACTIONS_URI = "onboarding-api/workflow/v1.0/actions" + ECOMP_INSTANCE_ID = "sdc_alc" + TIMEOUT_SECONDS_DEFAULT = 30 + JSON_DELIMITER_DEFAULT = "----------" + LOG_FORMAT = "%(name)s\t%(levelname)s\t%(asctime)s\t%(message)s" + # Env variable names. + ENV_HTTP_USER = "ALC_HTTP_USER" + ENV_HTTP_PASS = "ALC_HTTP_PASS" + ENV_HTTP_INSECURE = "ALC_HTTP_INSECURE" + ENV_HTTP_CAFILE = "ALC_HTTP_CAFILE" + ENV_TIMEOUT_SECONDS = "ALC_TIMEOUT_SECONDS" + ENV_JSON_DELIMITER = "ALC_JSON_DELIMITER" + ENV_ECOMP_INSTANCE_ID = "ALC_ECOMP_INSTANCE_ID" + + +############################################################################### + + +class ResponseCodes(object): + """Responses returned by IRESTClient impls.""" + OK = 0 + GENERAL_ERROR = 1 + ARGUMENTS_ERROR = 2 + HTTP_NOT_FOUND_ERROR = 3 + HTTP_FORBIDDEN_ERROR = 4 + HTTP_BAD_REQUEST_ERROR = 5 + HTTP_GENERAL_ERROR = 6 + PROCESS_GENERAL_ERROR = 9 + + +############################################################################### + + +class FinalizeStatus(object): + """Finalization operations.""" + Checkout = "Checkout" + UndoCheckout = "Undo_Checkout" + CheckIn = "Checkin" + Submit = "Submit" + + +############################################################################### + + +class ArgsDict(dict): + """A dict which makes attributes accessible as properties.""" + def __getattr__(self, attr): + return self[attr] + + def __setattr__(self, attr, value): + self[attr] = value + + +############################################################################### + + +class ArgumentParser(object): + """A minimal reimpl of the argparse library, core in later Python releases""" + ACTIONS = ["list", "create", "update", "delete", "checkout", "undocheckout", "checkin", "submit"] + PARMS = ["url", "in", "out", "config", "log", "uuid"] + OTHER = ["curl", "dryrun", "verbose", "version", "help"] + + def parse_args(self, clargs): + """Parse command-line args, returning a dict that exposes everything as properties.""" + args = ArgsDict() + args.action = None + for arg in self.ACTIONS + self.PARMS + self.OTHER: + args[arg] = None + skip = False + try: + for i, clarg in enumerate(clargs): + if skip: + skip = False + continue + if not clarg.startswith("--"): + raise Exception("Invalid argument: {0}".format(clarg)) + arg = str(clarg[2:]) + if arg in self.ACTIONS: + if args.action: + raise Exception("Duplicate actions: --{0}, {1}".format(args.action, clarg)) + args.action = arg + elif arg in self.PARMS: + try: + args[arg] = clargs[i + 1] + skip = True + except IndexError: + raise Exception("Option {0} requires an argument".format(clarg)) + elif arg in self.OTHER: + args[arg] = True + else: + raise Exception("Invalid argument: {0}".format(clarg)) + + # Check action args. + + if args.action: + if not args.url: + raise Exception("--url required for every action") + if not args.uuid: + if args.action not in ["create", "list"]: + raise Exception("--uuid required for every action EXCEPT --list/--create") + + # Read from file or stdin, and replace the problematic "in" + # property with "infile". + + if args.action in ["create", "update"]: + if args["in"]: + args.infile = open(args["in"], mode="r") + else: + args.infile = sys.stdin + + except Exception as e: + print(e) + ArgumentParser.usage() + sys.exit(ResponseCodes.ARGUMENTS_ERROR) + return args + + @staticmethod + def usage(): + """Print usage message.""" + print("" + + "Usage: action_library_client.py [--help] [--url ] [--in ]\n" + + " [--out ] [--config ]\n" + + " [--log ] [--uuid ]\n" + + " [--curl] [--dryrun] [--verbose] [--version]\n" + + " [--list | --create | --update= | --delete |\n" + + " --checkout | --undocheckout | --checkin | --submit]\n" + + "\n" + + "Optional arguments:\n" + + " --help Show this help message and exit\n" + + " --url REST endpoint URL\n" + + " --in Path to JSON input file (else STDIN)\n" + + " --out Path to JSON output file (else STDOUT or logfile)\n" + + " --config Path to configuration file\n" + + " --log Path to logfile (else STDOUT)\n" + + " --uuid Action UUID, (=='actionInvariantUUID')\n" + + " --curl Use curl transport impl\n" + + " --dryrun Describe what will happen, execute nothing\n" + + " --verbose Verbose diagnostic output\n" + + " --version Print script version and exit\n" + + " --list List actions\n" + + " --create Create new action (requires --in)\n" + + " --update Update existing action (requires --uuid, --in)\n" + + " --delete Delete existing action (requires --uuid)\n" + + " --checkout Create minor version candidate (requires --uuid)\n" + + " --undocheckout Discard minor version candidate (requires --uuid)\n" + + " --checkin Create minor version from candidate (requires --uuid)\n" + + " --submit Create next major version (requires --uuid)") + + +############################################################################### + + +class Settings(object): + """Settings read from (optional) configfile, or environment.""" + + def __init__(self, args): + """Construct for command-line args.""" + self.config = ConfigParser.ConfigParser() + if args.config: + self.config.read(args.config) + + def get(self, name, default_value=None): + """Get setting from configfile or environment""" + try: + return self.config.get(Constants.APPLICATION, name) + except (KeyError, ConfigParser.NoSectionError, ConfigParser.NoOptionError): + try: + return os.environ[name] + except KeyError: + return default_value + + +############################################################################### + + +# Python3: metaclass=ABCMeta +class IRESTClient(object): + """Base class for local, proxy and dryrun impls.""" + + def __init__(self, args): + self.args = args + self.logger = Runner.get_logger() + self.settings = Settings(args) + + @abstractmethod + def list(self): + """Abstract list operation.""" + pass + + @abstractmethod + def create(self): + """Abstract list operation.""" + pass + + @abstractmethod + def update(self): + """Abstract list operation.""" + pass + + @abstractmethod + def delete(self): + """Abstract list operation.""" + pass + + @abstractmethod + def version(self, status): + """Abstract list operation.""" + pass + + @staticmethod + def new_uuid(): + """Generate UUID.""" + return str(uuid.uuid4()) + + def get_timeout_seconds(self): + """Get request timeout in seconds.""" + return self.settings.get(Constants.ENV_TIMEOUT_SECONDS, + Constants.TIMEOUT_SECONDS_DEFAULT) + + def get_http_insecure(self): + """Get whether SSL certificate checks are (inadvisably) disabled.""" + return True if self.settings.get(Constants.ENV_HTTP_INSECURE) else False + + def get_http_cafile(self): + """Get optional CA file for SSL server cert validation""" + if not self.get_http_insecure(): + return self.settings.get(Constants.ENV_HTTP_CAFILE) + + def get_basic_credentials(self): + """Generate Authorization: header.""" + usr = self.settings.get(Constants.ENV_HTTP_USER) + pwd = self.settings.get(Constants.ENV_HTTP_PASS) + if usr and pwd: + return base64.b64encode(bytes("{0}:{1}".format(usr, pwd))).decode("ascii") + else: + raise Exception("REST service credentials not found") + + def make_service_url(self): + """Generate service URL based on command-line arguments.""" + url = self.args.url + if "/onboarding-api/" not in url: + separator = "" if url.endswith("/") else "/" + url = "{0}{1}{2}".format(url, separator, str(Constants.ACTIONS_URI)) + if self.args.uuid: + separator = "" if url.endswith("/") else "/" + url = "{0}{1}{2}".format(url, separator, self.args.uuid) + return url + + def log_json_response(self, method, json_dict): + """Log JSON response regardless of transport.""" + json_str = json.dumps(json_dict, indent=4) + delimiter = self.settings.get(Constants.ENV_JSON_DELIMITER, Constants.JSON_DELIMITER_DEFAULT) + self.logger.info("HTTP {0} JSON response:\n{1}\n{2}\n{3}\n".format(method, delimiter, json_str, delimiter)) + if self.args.out: + with open(self.args.out, "w") as tmp: + tmp.write(json_str) + tmp.flush() + elif self.args.log: + # Directly to stdout if logging is sent to a file. + print(json_str) + + def log_action(self, action, status=None): + """Debug action before invocation.""" + url = self.make_service_url() + name = status if status else self.__get_name() + self.logger.debug("{0}::{1}({2})".format(name, action, url)) + + @staticmethod + def _get_result_from_http_response(code): + """Get script returncode from HTTP error.""" + if code == 400: + return ResponseCodes.HTTP_BAD_REQUEST_ERROR + elif code == 403: + return ResponseCodes.HTTP_FORBIDDEN_ERROR + elif code == 404: + return ResponseCodes.HTTP_NOT_FOUND_ERROR + return ResponseCodes.HTTP_GENERAL_ERROR + + def __get_name(self): + """Get classname for diags""" + return type(self).__name__ + + +############################################################################### + + +class NativeRESTClient(IRESTClient): + """In-process IRESTClient impl.""" + + def list(self): + """In-process list impl.""" + self.log_action("list") + return self.__exec(method="GET", expect_json=True) + + def create(self): + """In-process create impl.""" + self.log_action("create") + json_bytes = bytes(self.args.infile.read()) + return self.__exec(method="POST", json_bytes=json_bytes, expect_json=True) + + def update(self): + """In-process update impl.""" + self.log_action("update") + json_bytes = bytes(self.args.infile.read()) + return self.__exec(method="PUT", json_bytes=json_bytes, expect_json=True) + + def delete(self): + """In-process delete impl.""" + self.log_action("delete") + return self.__exec(method="DELETE") + + def version(self, status): + """In-process version impl.""" + self.log_action("version", status) + json_bytes = bytes(json.dumps({"status": status})) + return self.__exec(method="POST", json_bytes=json_bytes, expect_json=True) + + def __exec(self, method, json_bytes=None, expect_json=None): + """Build command, execute it, validate and return response.""" + try: + url = self.make_service_url() + timeout = float(self.get_timeout_seconds()) + cafile = self.get_http_cafile() + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Basic {0}".format(self.get_basic_credentials()), + "X-ECOMP-InstanceID": Constants.ECOMP_INSTANCE_ID, + "X-ECOMP-RequestID": IRESTClient.new_uuid() + } + + handler = urllib2.HTTPHandler + if hasattr(ssl, 'create_default_context'): + ctx = ssl.create_default_context(cafile=cafile) + if self.get_http_insecure(): + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + handler = urllib2.HTTPSHandler(context=ctx) if url.lower().startswith("https") else urllib2.HTTPHandler + + self.logger.debug("URL {0} {1}: {2}".format(url, method, json_bytes)) + + opener = urllib2.build_opener(handler) + request = urllib2.Request(url, data=json_bytes, headers=headers) + request.get_method = lambda: method + + f = None + try: + f = opener.open(request, timeout=timeout) + return self.__handle_response(f, method, expect_json) + finally: + if f: + f.close() + + except urllib2.HTTPError as err: + self.logger.exception(err) + return IRESTClient._get_result_from_http_response(err.getcode()) + except urllib2.URLError as err: + self.logger.exception(err) + return ResponseCodes.HTTP_GENERAL_ERROR + + def __handle_response(self, f, method, expect_json): + """Devolve response handling because of the """ + self.logger.debug("HTTP {0} status {1}, reason:\n{2}".format(method, f.getcode(), f.info())) + if expect_json: + # JSON responses get "returned", but actually it's the logging that + # most callers will be looking for. + json_body = json.loads(f.read().decode("utf-8")) + self.log_json_response(method, json_body) + return json_body + # Not JSON, but the operation succeeded, so return True. + return ResponseCodes.OK + + +############################################################################### + + +class CURLRESTClient(IRESTClient): + """Remote/curl IRESTClient impl.""" + + def list(self): + """curl list impl""" + self.log_action("list") + return self._exec(method="GET", expect_json=True) + + def create(self): + """curl create impl""" + self.log_action("create") + data_args = ["--data", "@{0}".format(self.args.infile.name)] + return self._exec(method="POST", extra_args=data_args, expect_json=True) + + def update(self): + """curl update impl""" + self.log_action("update") + data_args = ["--data", "@{0}".format(self.args.infile.name)] + return self._exec(method="PUT", extra_args=data_args, expect_json=True) + + def delete(self): + """curl delete impl""" + self.log_action("delete") + return self._exec(method="DELETE", expect_json=False) + + def version(self, status): + """curl version impl""" + self.log_action("version", status) + with tempfile.NamedTemporaryFile(mode="w", delete=False) as tmp: + tmp.write(json.dumps({"status": status})) + tmp.flush() + data_args = ["--data", "@{0}".format(tmp.name)] + return self._exec(method="POST", extra_args=data_args, expect_json=True) + + def make_curl_cmd(self, method, url, extra_args): + """Build curl command without executing.""" + cmd = ["curl", "-i", "-s", "-X", method] + if self.get_http_insecure(): + cmd.append("-k") + cmd.extend(["--connect-timeout", str(self.get_timeout_seconds())]) + cmd.extend(["--header", "Accept: application/json"]) + cmd.extend(["--header", "Content-Type: application/json"]) + cmd.extend(["--header", "Authorization: Basic {0}".format(self.get_basic_credentials())]) + cmd.extend(["--header", "X-ECOMP-InstanceID: {0}".format(Constants.ECOMP_INSTANCE_ID)]) + cmd.extend(["--header", "X-ECOMP-RequestID: {0}".format(IRESTClient.new_uuid())]) + if extra_args: + for extra_arg in extra_args: + cmd.append(extra_arg) + cmd.append("{0}".format(url)) + return cmd + + @staticmethod + def debug_curl_cmd(cmd): + """Debug curl command, for diags and dryrun.""" + buf = "" + for token in cmd: + if token is "curl" or token.startswith("-"): + buf = "{0}{1} ".format(buf, token) + else: + buf = "{0}\"{1}\" ".format(buf, token) + return buf + + def _exec(self, method, extra_args=None, expect_json=None): + """Execute action. + + Build command, invoke curl, validate and return response. + Overridden by DryRunRESTClient. + """ + url = self.make_service_url() + cmd = self.make_curl_cmd(method, url, extra_args) + self.logger.info("Executing: {0}".format(CURLRESTClient.debug_curl_cmd(cmd))) + + try: + output = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode() + if not expect_json: + return ResponseCodes.OK + try: + separator = output.index("\r\n\r\n{") + self.logger.debug("HTTP preamble:\n{0}".format(output[:separator])) + json_body = json.loads(output[(separator+4):]) + self.log_json_response(method, json_body) + return json_body + except ValueError: + self.logger.warning("Couldn't find HTTP separator in curl output:\n{}".format(output)) + code = CURLRESTClient.__get_http_code(output) + return IRESTClient._get_result_from_http_response(code) + except subprocess.CalledProcessError as err: + self.logger.exception(err) + return ResponseCodes.PROCESS_GENERAL_ERROR + + @staticmethod + def __get_http_code(output): + """Attempt to guess HTTP result from (error) output.""" + for line in output.splitlines(): + if line.startswith("HTTP"): + tokens = line.split() + if len(tokens) > 2: + try: + return int(tokens[1]) + except ValueError: + pass + return ResponseCodes.HTTP_GENERAL_ERROR + + +############################################################################### + + +class DryRunRESTClient(CURLRESTClient): + """Neutered IRESTClient impl; only logs.""" + + def _exec(self, method, extra_args=None, expect_json=None): + """Override.""" + url = self.make_service_url() + cmd = self.make_curl_cmd(method, url, extra_args) + self.logger.info("[DryRun] {0}".format(CURLRESTClient.debug_curl_cmd(cmd))) + + +############################################################################### + + +class Runner(object): + """A bunch of static housekeeping supporting the launcher.""" + + @staticmethod + def get_logger(): + """Get logger instance.""" + return logging.getLogger(Constants.APPLICATION) + + @staticmethod + def get_rest_client(args): + """Get the configured REST client impl, local, remote or dryrun.""" + if args.dryrun: + return DryRunRESTClient(args) + elif args.curl: + return CURLRESTClient(args) + else: + return NativeRESTClient(args) + + @staticmethod + def execute(args): + """Execute the requested action.""" + client = Runner.get_rest_client(args) + if args.version: + print(Constants.VERSION) + elif args.help: + ArgumentParser.usage() + elif args.action == "list": + return client.list() + elif args.action == "create": + return client.create() + elif args.action == "update": + return client.update() + elif args.action == "delete": + return client.delete() + elif args.action == "checkout": + return client.version(FinalizeStatus.Checkout) + elif args.action == "checkin": + return client.version(FinalizeStatus.CheckIn) + elif args.action == "undocheckout": + return client.version(FinalizeStatus.UndoCheckout) + elif args.action == "submit": + return client.version(FinalizeStatus.Submit) + else: + logger = Runner.get_logger() + logger.info("No action specified. Try --help.") + + @staticmethod + def parse_args(raw): + """Parse command-line args, returning dict.""" + return ArgumentParser().parse_args(raw) + + +############################################################################### + + +def execute(raw): + """Delegate which executes minus error-handling, exposed for unit-testing.""" + + # Intercept Python 2.X. + + if not (sys.version_info[0] == 2 and sys.version_info[1] >= 6): + raise EnvironmentError("Python 2.6/2.7 required") + + # Parse command-line args. + + args = Runner.parse_args(raw) + + # Redirect logging to a file (freeing up STDIN) if directed. + + logging.basicConfig(level=logging.INFO, filename=args.log, format=Constants.LOG_FORMAT) + + # Set loglevel. + + logger = Runner.get_logger() + if args.verbose: + logger.setLevel(logging.DEBUG) + logger.debug("Parsed arguments: {0}".format(args)) + + # Execute request. + + return Runner.execute(args) + + +############################################################################### + + +def main(raw): + """Execute for command-line arguments.""" + + logger = Runner.get_logger() + try: + result = execute(raw) + result_code = result if isinstance(result, int) else ResponseCodes.OK + logger.debug("Execution complete. Returning result {0} ({1})".format(result, result_code)) + sys.exit(result_code) + except Exception as err: + logger.exception(err) + sys.exit(ResponseCodes.GENERAL_ERROR) + + +############################################################################### + + +if __name__ == "__main__": + main(sys.argv[1:]) -- cgit 1.2.3-korg