From 6f55d31ef2aa5a2d3b760faf696d8d405bda5bac Mon Sep 17 00:00:00 2001 From: Nikola Vladimirov Iliev Date: Mon, 10 Sep 2018 16:07:47 +0300 Subject: [PATCH] Add ui extensions commands Following commads are added: generate, list, deploy, delete. They will help the user to manage his ui extensions. CLI Spinner class is implemented to indicate the action of the user. Extension generator is class which inherits generator abstract class and give the ability to copy existing template from the file system, of the user, and populate it with the data, given by the user. Prompt and PromptLauncher classes are build on top of python click lib to give the ability to validate and collect the data from the user. Signed-off-by: Nikola Vladimirov Iliev --- .gitignore | 1 + .travis.yml | 1 + tests/run-tests.sh | 1 + tests/ui-ext-test.sh | 23 ++ utilities/__init__.py | 0 utilities/colors.py | 12 + utilities/ui_ext/__init__.py | 0 utilities/ui_ext/cli_spinners/__init__.py | 0 utilities/ui_ext/cli_spinners/cli_spinner.py | 153 +++++++++ utilities/ui_ext/ext_generator/__init__.py | 0 .../ui_ext/ext_generator/ext_generator.py | 306 ++++++++++++++++++ utilities/ui_ext/ext_generator/generator.py | 120 +++++++ utilities/ui_ext/prompt_launcher/__init__.py | 0 utilities/ui_ext/prompt_launcher/prompt.py | 147 +++++++++ .../ui_ext/prompt_launcher/prompt_launcher.py | 101 ++++++ .../prompt_launcher/validator_factory.py | 19 ++ .../prompt_launcher/validators/__init__.py | 0 .../validators/folder_validator.py | 15 + .../validators/length_validator.py | 21 ++ .../validators/pattern_validator.py | 18 ++ .../prompt_launcher/validators/validator.py | 5 + utilities/ui_ext/ui_ext_api/__init__.py | 0 utilities/ui_ext/ui_ext_api/ui_ext_api.py | 199 ++++++++++++ vcd_cli/ui_extensions.py | 99 ++++++ vcd_cli/vcd.py | 1 + 25 files changed, 1242 insertions(+) create mode 100644 tests/ui-ext-test.sh create mode 100644 utilities/__init__.py create mode 100644 utilities/colors.py create mode 100644 utilities/ui_ext/__init__.py create mode 100644 utilities/ui_ext/cli_spinners/__init__.py create mode 100644 utilities/ui_ext/cli_spinners/cli_spinner.py create mode 100644 utilities/ui_ext/ext_generator/__init__.py create mode 100644 utilities/ui_ext/ext_generator/ext_generator.py create mode 100644 utilities/ui_ext/ext_generator/generator.py create mode 100644 utilities/ui_ext/prompt_launcher/__init__.py create mode 100644 utilities/ui_ext/prompt_launcher/prompt.py create mode 100644 utilities/ui_ext/prompt_launcher/prompt_launcher.py create mode 100644 utilities/ui_ext/prompt_launcher/validator_factory.py create mode 100644 utilities/ui_ext/prompt_launcher/validators/__init__.py create mode 100644 utilities/ui_ext/prompt_launcher/validators/folder_validator.py create mode 100644 utilities/ui_ext/prompt_launcher/validators/length_validator.py create mode 100644 utilities/ui_ext/prompt_launcher/validators/pattern_validator.py create mode 100644 utilities/ui_ext/prompt_launcher/validators/validator.py create mode 100644 utilities/ui_ext/ui_ext_api/__init__.py create mode 100644 utilities/ui_ext/ui_ext_api/ui_ext_api.py create mode 100644 vcd_cli/ui_extensions.py diff --git a/.gitignore b/.gitignore index cc1a13b5..dcc56e4b 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ docs/_build/ target/ .idea/ +.vscode/ docs/.bundle docs/_site diff --git a/.travis.yml b/.travis.yml index 263846af..ea486cb6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ python: install: - pip install git+https://github.com/vmware/pyvcloud.git - pip install -r requirements.txt + - pip install -e . - python setup.py install - pip install tox diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 1c6ec097..a846e5e2 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -6,3 +6,4 @@ D=`dirname $0` $D/tenant-onboard.sh $D/tenant-operations.sh $D/cleanup.sh +$D/ui-ext-test.sh diff --git a/tests/ui-ext-test.sh b/tests/ui-ext-test.sh new file mode 100644 index 00000000..9208729e --- /dev/null +++ b/tests/ui-ext-test.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -e + +VCD="C:\Users\nvladimirovi\AppData\Roaming\Python\Python36\Scripts\vcd.exe" +VCD_UI_EXT_ABS_PATH=D:/test-py-cli/ui_plugin +VCD_HOST=bos1-vcd-sp-static-198-58.eng.vmware.com +VCD_ORG=System +VCD_USER=administrator +VCD_PASSWORD='********' + +# $VCD login $VCD_HOST $VCD_ORG $VCD_USER --password $VCD_PASSWORD + +$VCD version + +echo 'This should deploy ui extension' +$VCD uiext deploy --path $VCD_UI_EXT_ABS_PATH -p -pr +echo 'This should list all ui extensions' +$VCD uiext list +echo 'This should delete all ui extensions' +$VCD uiext delete -a +echo 'This list command should print "There are no UI Extensions..." in the command line' +$VCD uiext list diff --git a/utilities/__init__.py b/utilities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utilities/colors.py b/utilities/colors.py new file mode 100644 index 00000000..cbd71bd4 --- /dev/null +++ b/utilities/colors.py @@ -0,0 +1,12 @@ +from enum import Enum + +Colors = Enum('Colors', { + 'HEADER': '\033[95m', + 'OKBLUE': '\033[94m', + 'OKGREEN': '\033[92m', + 'WARNING': '\033[93m', + 'FAIL': '\033[91m', + 'ENDC': '\033[0m', + 'BOLD': '\033[1m', + 'UNDERLINE': '\033[4m' +}) diff --git a/utilities/ui_ext/__init__.py b/utilities/ui_ext/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utilities/ui_ext/cli_spinners/__init__.py b/utilities/ui_ext/cli_spinners/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utilities/ui_ext/cli_spinners/cli_spinner.py b/utilities/ui_ext/cli_spinners/cli_spinner.py new file mode 100644 index 00000000..fe57e7e9 --- /dev/null +++ b/utilities/ui_ext/cli_spinners/cli_spinner.py @@ -0,0 +1,153 @@ +import threading, time, sys +from enum import Enum + +Spinners = Enum('Spinners', { + "line": { + "interval": 130, + "frames": [ + "-", + "\\", + "|", + "/" + ] + }, + "dots": { + "interval": 500, + "frames": [ + ".", + "..", + "..." + ] + } +}) + +def backspace(n): + # print((b'\x08').decode(), end='') # use \x08 char to go back + print('\r', end='') + +class CliSpinner: + """CliSpinner library. + Attributes + ---------- + CLEAR_LINE : str + Code to clear the line + """ + CLEAR_LINE = '\033[K' + + def __init__(self, text=None, spinner='dots', placement='left', stream=sys.stdout): + """Constructs the CliSpinner object. + Parameters + ---------- + text : str, optional + Text to display while spinning. + spinner : str|dict, optional + String or dictionary representing spinner. String can be one of 2 spinners + supported. + placement: str, optional + Side of the text to place the spinner on. Can be `left` or `right`. + Defaults to `left`. + stream : io, optional + Output. + """ + self._spinner = Spinners[spinner].value + self.text = text + self._stop_spinner = None + self._interval = self._spinner['interval'] + self._stream = stream + self._frame_index = 0 + self._placement = placement + + def clear(self): + """Clears the line and returns cursor to the start. + of line + Returns + ------- + self + """ + self._stream.write('\r') + self._stream.write(self.CLEAR_LINE) + + return self + + def _frame(self): + """Builds and returns the frame to be rendered + Returns + ------- + frame + """ + frame = self._spinner['frames'][self._frame_index] + self._frame_index += 1 + + if self._frame_index == len(self._spinner['frames']): + self._frame_index = 0 + + return frame + + def _render_frame(self): + """Renders the frame on the line after clearing it. + """ + frame = self._frame() + + if self.text is not None: + output = u'{0} {1}'.format(*[ + (self.text, frame) + if self._placement == 'right' else + (frame, self.text) + ][0]) + else: + output = '{0}'.format(frame) + self._stream.write(output) + backspace(len(output)) + + def _render(self): + """Runs the render until thread flag is set. + Returns + ------- + self + """ + while not self._stop_spinner.is_set(): + self._render_frame() + time.sleep(0.001 * self._interval) + + return self + + def start(self, text=None): + """Starts the spinner on a separate thread. + Parameters + ---------- + text : None, optional + Text to be used alongside spinner + Returns + ------- + self + """ + if text is not None: + self.text = text + + self._stop_spinner = threading.Event() + self._spinner_thread = threading.Thread(target=self._render) + self._spinner_thread.setDaemon(True) + self._render_frame() + self._spinner_id = self._spinner_thread.name + self._spinner_thread.start() + + return self + + def stop(self, message=None): + """Stops the spinner and clears the line. + Returns + ------- + self + """ + if self._spinner_thread: + self._stop_spinner.set() + self._spinner_thread.join() + + self._frame_index = 0 + self._spinner_id = None + self.clear() + + if message is not None: + print(message) + + return self diff --git a/utilities/ui_ext/ext_generator/__init__.py b/utilities/ui_ext/ext_generator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utilities/ui_ext/ext_generator/ext_generator.py b/utilities/ui_ext/ext_generator/ext_generator.py new file mode 100644 index 00000000..ba7587f1 --- /dev/null +++ b/utilities/ui_ext/ext_generator/ext_generator.py @@ -0,0 +1,306 @@ +import os, pprint, queue, json + +from utilities.ui_ext.prompt_launcher.prompt_launcher import PromptLauncher +from utilities.ui_ext.prompt_launcher.prompt import Prompt +from utilities.ui_ext.cli_spinners.cli_spinner import CliSpinner +from utilities.ui_ext.ext_generator.generator import Generator +from utilities.ui_ext.prompt_launcher.validator_factory import ValidatorFactory +from pathlib import Path +from shutil import copyfile, rmtree + +class ExtGenerator(Generator): + def __init__(self): + """Constructs the ExtGenerator object. + Returns + ------- + None + """ + + def parse_manifest(self): + """Find manifest.json in the template path, + read the file, parse it to dict and returns it. + If no manifest file is found default dict will be + returned. + Returns + ------- + manifest : dict + """ + + file = None + + try: + file = Path(os.path.join(self.new_project_dir, 'src/public/manifest.json')) + raise Exception("blabal") + except Exception: + # Assign the file Path obj + file = self.find_file(self.answers["template_path"], "manifest.json") + + # Check if file is found + if file is not None: + # Read the file + with open(file, "r") as f: + # Parse it to dict and returns it + return json.loads(f.read()) + else: + # Return default manifest values + return { + "urn": "vmware:vcloud:plugin:lifecycle", + "name": "plugin-lifecycle", + "containerVersion": "9.1.0", + "version": "1.0.0", + "scope": ["service-provider"], + "permissions": [], + "description": "", + "vendor": "VMware", + "license": "MIT", + "link": "http://someurl.com", + "module": "SubnavPluginModule", + "route": "plugin-lifecycle" + } + + def populate_manifest(self, basename, original_file_abs_path): + """Open template file, read the file in-memory + and populate the data given from user, creates + the manifest.json with the new data. + Returns + ------- + None + """ + # Read the original file + readFile = open(original_file_abs_path, "r") + + # Create the new file and write the content + with open(basename, "w") as f: + # Parse the json content of the file + files_json = json.loads(readFile.read()) + # Close the readable stream + readFile.close() + + # Loop through answers + for key, value in self.answers.items(): + # If answer key exist in the json + if key in files_json: + # And if it is scope or permissions + if key == 'scope' or key == 'permissions': + # And if there is no content + if len(self.answers[key]) < 1: + # Assign empty array + value = [] + else: + # If it's list already + if isinstance(self.answers[key], list): + value = self.answers[key] + else: + # Create array of strings + value = self.answers[key].split(", ") + + # Assign the value to origin json file + files_json[key] = value + + # Write the new json to the newly created file with sorted keys and 4 spaces indentation + f.write(json.dumps(files_json, sort_keys=True, indent=4)) + # Close the writable stream + f.close() + + def generate_files(self, entrie_full_abs_path, new_entrie_path, entrie): + """Generate vCD UI Plugin files, manifest.json + is populated with the data from the questions + with which user has been promped. + Returns + ------- + None + """ + # Base name of the file + basename = os.path.basename(entrie_full_abs_path) + + # If the file is manifest.json + if basename == 'manifest.json': + # Generate new current working directory + newCWD = os.path.join(self.new_project_dir, new_entrie_path.split(basename)[0][1:]) + oldCWD = os.getcwd() + # Get in new current working directory + os.chdir(newCWD) + # Populate manifest.json file with the data from users answers + self.populate_manifest(basename, os.path.abspath(entrie.absolute())) + # Reset the current working directory + os.chdir(oldCWD) + else: + # Copy the file from his original directory to his new one + copyfile(os.path.abspath(entrie.absolute()), os.path.join(self.new_project_dir, new_entrie_path[1:])) + + def copy_files(self, directory=None): + """Generate vCD UI Plugin from template. + Paramenters + ------- + directory | Path + + Returns + ------- + None + """ + + # Create queue for directories + q = queue.Queue() + # Put the start point directory + q.put(directory) + + # Loop while q is not empty + while q.empty() != True: + # Pop the first element from the queue + node = q.get() + + # Loop through the node entries + for entrie in node.iterdir(): + # Split the absolute path + entrie_full_abs_path = os.path.abspath(entrie.absolute()) + entrie_path_parts = entrie_full_abs_path.split(self.template_name) + # Get the relative path to the entrie + new_entrie_path = entrie_path_parts[len(entrie_path_parts) - 1] + + # If entries is file + if entrie.is_file(): + self.generate_files(entrie_full_abs_path, new_entrie_path, entrie) + # else if it is directory and it isn't node modules + elif entrie.is_dir() and entrie.name != 'node_modules': + # Create the new directory + os.mkdir(os.path.join(self.new_project_dir, new_entrie_path[1:])) + + # Add the entrie to the queue to loop through his directories if any + q.put(entrie) + + def generate(self): + """Generate vCD UI Plugin from template. + Returns + ------- + None + """ + + # Load inital user prompts + prompts = PromptLauncher([ + Prompt( + "template_path", + str, + message="Please enter a valid absolute template path", + default="D:/git-repos/vcd-ext-sdk/ui/plugin-lifecycle", + validator=ValidatorFactory.checkForFolderExistence(), + err_message="The path has to be valid absolute path" + ), + Prompt( + "projectName", + str, + message="Project name", + default="ui_plugin" + ) + ]) + + # Assign answers + self.answers = prompts.multi_prompt() + # Assign template name + self.template_name = self.answers["template_path"] + # Construct the path of the new project + self.new_project_dir = os.path.join(os.getcwd(), self.answers["projectName"]) + + # Indicate that the system search for manifes.json + spinner = CliSpinner(text="Parse manifest.json", spinner='line') + spinner.start() + + # Assign manifest.json + self.manifest = self.parse_manifest() + + # Stop inidaction + spinner.stop() + + # Load prompts + prompts.add([ + Prompt( + "urn", + str, + message="Plugin urn", + default=self.manifest["urn"] + ), + Prompt( + "name", + str, + message="Plugin name", + default=self.manifest["name"] + ), + Prompt( + "containerVersion", + str, + message="Plugin containerVersion", + default=self.manifest["containerVersion"] + ), + Prompt( + "version", + str, + message="Plugin version", + default=self.manifest["version"] + ), + Prompt( + "scope", + str, + message="Plugin scope", + default=self.manifest["scope"] + ), + Prompt( + "permissions", + str, + message="Plugin permissions", + default=self.manifest["permissions"] + ), + Prompt( + "description", + str, + message="Plugin description", + default=self.manifest["description"], + err_message="Plugin description has to be greather then 3 and less then 255 characters", + validator=ValidatorFactory.length(0, 255), + ), + Prompt( + "vendor", + str, + message="Plugin vendor", + default=self.manifest["vendor"] + ), + Prompt( + "license", + str, + message="Plugin license", + default=self.manifest["license"] + ), + Prompt( + "link", + str, + message="Plugin link", + default=self.manifest["link"], + err_message="The link url is not valid, please enter valid url address and with length between 8 - 100 characters.", + validator=[ + ValidatorFactory.length(8, 100), + ValidatorFactory.pattern(r'^((http|https)://)') + ] + ), + Prompt("route", str, message="Plugin route", default=self.manifest["route"]) + ]) + # Prompt user + project_answers = prompts.multi_prompt() + + # Collect answers + self.answers = { **self.answers, **project_answers } + + # Indicate generating + spinner = CliSpinner(text="Generate tempalte", spinner='line') + spinner.start() + + # If this path exists remove it + if os.path.exists(self.new_project_dir): + rmtree(self.new_project_dir) + + # Create the directory to given path + os.mkdir(self.new_project_dir) + + # Start copy files + self.copy_files(Path(self.answers["template_path"])) + + # Stop inidaction + spinner.stop("Completed!") + diff --git a/utilities/ui_ext/ext_generator/generator.py b/utilities/ui_ext/ext_generator/generator.py new file mode 100644 index 00000000..f474a226 --- /dev/null +++ b/utilities/ui_ext/ext_generator/generator.py @@ -0,0 +1,120 @@ +import os, queue + +from abc import ABC, abstractmethod, abstractstaticmethod +from pathlib import Path + +class Generator(ABC): + @property + def answers(self): + """Getter for answers property. + Returns + ------- + answers : dict + """ + + return self._answers + + @answers.setter + def answers(self, answers): + """Setter for answers property. + Returns + ------- + answers : dict + """ + + self._answers = answers + + @property + def template_name(self): + """Getter for template_name property. + Returns + ------- + template_name : str + """ + + return self._template_name + + @template_name.setter + def template_name(self, template_path): + """Setter for template_name property. + Returns + ------- + template_name : str + """ + + # Split the absolute template path + templates_path_parts = template_path.split("/ui/") + # Assign template name + self._template_name = templates_path_parts[len(templates_path_parts) - 1] + + @property + def new_project_dir(self): + """Getter for new_project_dir property. + Returns + ------- + new_project_dir : str + """ + + return self._new_project_dir + + @new_project_dir.setter + def new_project_dir(self, new_project_dir): + """Setter for new_project_dir property. + Returns + ------- + new_project_dir : str + """ + + self._new_project_dir = new_project_dir + + @abstractmethod + def generate(self): + """Generate vCD UI Plugin from template. + Returns + ------- + None + """ + pass + + def binary_search_file_by_name(self, alist, item): + """Find file with given name in list of files. + Returns + ------- + file : Path + """ + first = 0 + last = len(alist) - 1 + found = False + + while first <= last and not found: + midpoint = int((first + last) / 2) + + if os.path.basename(os.path.abspath(alist[midpoint].absolute())) == item: + found = alist[midpoint] + else: + if item < alist[midpoint].name: + last = midpoint - 1 + else: first = midpoint + 1 + + return found + + def find_file(self, rootPath, fileName): + """Find file with given name in given root directory. + Returns + ------- + file : Path + """ + q = queue.Queue() + q.put(Path(rootPath)) + + while q.empty() is False: + node = q.get() + + found = self.binary_search_file_by_name(list(node.iterdir()), "manifest.json") + + if found is False: + for entrie in node.iterdir(): + if entrie.is_dir(): + q.put(entrie) + else: + return found \ No newline at end of file diff --git a/utilities/ui_ext/prompt_launcher/__init__.py b/utilities/ui_ext/prompt_launcher/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utilities/ui_ext/prompt_launcher/prompt.py b/utilities/ui_ext/prompt_launcher/prompt.py new file mode 100644 index 00000000..4c37eb5b --- /dev/null +++ b/utilities/ui_ext/prompt_launcher/prompt.py @@ -0,0 +1,147 @@ +class Prompt: + """Represent abstraction of python click + propmt data. + """ + def __init__(self, name, type, message=None, default=None, validator=None, err_message=None): + self.name = name + self.type = type + self.message = message + self.default = default + self.validator = validator + self.error_message = err_message + + @property + def name(self): + """Getter for name property. + Returns + ------- + name : str + name value + """ + return self._name + + @name.setter + def name(self, name): + """Setter for name property. + Parameters + ---------- + name : str + Defines the name value + """ + + if len(name) < 1: + raise Exception("The name property can not be with lenght less ten 1.") + + self._name = name + + @property + def type(self): + """Getter for type property. + Returns + ------- + type | str + type value + """ + return self._type + + @type.setter + def type(self, type): + """Setter for type property. + Parameters + ---------- + type : str + Defines the name value + """ + + self._type = type + + @property + def message(self): + """Getter for message property. + Returns + ------- + message | str + message value + """ + + return self._message + + @message.setter + def message(self, message=None): + """Setter for message property. + Parameters + ---------- + message : str + Defines the message value + """ + + if type(message) is not str: + raise Exception("The message property has to be string") + + self._message = message + + @property + def default(self): + """Getter for default property. + Returns + ------- + default | any + default value + """ + + return self._default + + @default.setter + def default(self, default): + """Setter for default property. + Parameters + ---------- + default : any + Defines the message value + """ + + self._default = default + + @property + def validator(self): + """Getter for validator property. + Returns + ------- + validator | Validator / Validator[] + validator value + """ + + return self._validator + + @validator.setter + def validator(self, validator): + """Setter for validator property. + Parameters + ---------- + validator : Validator | Validator[] + Defines the validator value + """ + + self._validator = validator + + @property + def error_message(self): + """Getter for error_message property. + Returns + ------- + error_message | boolean + error_message value + """ + return self._error_message + + @error_message.setter + def error_message(self, err_message): + """Setter for error_message property. + Parameters + ---------- + error_message : boolean + Defines the error_message value + """ + + if err_message is not None: + self._error_message = err_message \ No newline at end of file diff --git a/utilities/ui_ext/prompt_launcher/prompt_launcher.py b/utilities/ui_ext/prompt_launcher/prompt_launcher.py new file mode 100644 index 00000000..9edb55c1 --- /dev/null +++ b/utilities/ui_ext/prompt_launcher/prompt_launcher.py @@ -0,0 +1,101 @@ +import click + +from utilities.colors import Colors + +class PromptLauncher: + """Collect and execute prompt objects. + """ + def __init__(self, prompts=[]): + """Constructs the PromptLauncher object. + Paramenters + ------- + prompts | Prompt + + click | Click ( lib for python CLI ) + + Returns + ------- + None + """ + self._prompts = prompts + + def add(self, propmts): + """Add new items to dict with concat + Returns + ------- + None + """ + + self._prompts = self._prompts + propmts + + def pop_prompt(self): + """Pop the first element from the prompts list. + Returns + ------- + prompt | Prompt + prompt value + """ + return self._prompts.pop(0) + + def recuresive_prompt(self, prompt, valuedict): + return self.prompt(prompt, valuedict) + + def prompt(self, prompt, valuedict={}): + """Execute given prompt object. + Returns + ------- + valuedict | Dictionary + valuedict value + """ + + if prompt.validator: + value = None + + if type(prompt.validator) is list: + userInput = click.prompt( + prompt._message, + type=prompt.type, + default=prompt.default + ) + + for validator in prompt.validator: + validator_return = validator.validate(userInput) + + if validator_return is None: + print(Colors['FAIL'].value + prompt.error_message + Colors['ENDC'].value) + return self.recuresive_prompt(prompt, valuedict) + + value = validator_return + else: + value = prompt.validator.validate(input=click.prompt( + prompt._message, + type=prompt.type, + default=prompt.default + )) + if value is not None: + valuedict[prompt.name] = value + else: + print(Colors['FAIL'].value + prompt.error_message + Colors['ENDC'].value) + return self.recuresive_prompt(prompt, valuedict) + else: + valuedict[prompt.name] = click.prompt( + prompt._message, + type=prompt.type, + default=prompt.default, + ) + + return valuedict + + def multi_prompt(self): + """Prompt the user with questions. + Returns + ------- + thisdict | Dictionary + thisdict value + """ + thisdict = {} + + while len(self._prompts) > 0: + prompt = self._prompts.pop(0) + thisdict = self.prompt(prompt, thisdict) + return thisdict \ No newline at end of file diff --git a/utilities/ui_ext/prompt_launcher/validator_factory.py b/utilities/ui_ext/prompt_launcher/validator_factory.py new file mode 100644 index 00000000..61435d22 --- /dev/null +++ b/utilities/ui_ext/prompt_launcher/validator_factory.py @@ -0,0 +1,19 @@ +import os, queue, re + +from abc import ABC, abstractmethod, abstractstaticmethod +from utilities.ui_ext.prompt_launcher.validators.folder_validator import ValidateFolderExistence +from utilities.ui_ext.prompt_launcher.validators.length_validator import LengthValidator +from utilities.ui_ext.prompt_launcher.validators.pattern_validator import PatternValidator + +class ValidatorFactory(ABC): + @abstractstaticmethod + def checkForFolderExistence(): + return ValidateFolderExistence() + + @abstractstaticmethod + def pattern(pattern): + return PatternValidator(pattern) + + @abstractstaticmethod + def length(minL, maxL): + return LengthValidator(minL=minL, maxL=maxL) \ No newline at end of file diff --git a/utilities/ui_ext/prompt_launcher/validators/__init__.py b/utilities/ui_ext/prompt_launcher/validators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utilities/ui_ext/prompt_launcher/validators/folder_validator.py b/utilities/ui_ext/prompt_launcher/validators/folder_validator.py new file mode 100644 index 00000000..9edddde9 --- /dev/null +++ b/utilities/ui_ext/prompt_launcher/validators/folder_validator.py @@ -0,0 +1,15 @@ +import os + +from utilities.ui_ext.prompt_launcher.validators.validator import Validator + +class ValidateFolderExistence(Validator): + def validate(self, input=None): + """Check given directory for existence + Returns + ------- + input : any + """ + if os.path.exists(input): + return input + + return None \ No newline at end of file diff --git a/utilities/ui_ext/prompt_launcher/validators/length_validator.py b/utilities/ui_ext/prompt_launcher/validators/length_validator.py new file mode 100644 index 00000000..0f959f53 --- /dev/null +++ b/utilities/ui_ext/prompt_launcher/validators/length_validator.py @@ -0,0 +1,21 @@ +from utilities.ui_ext.prompt_launcher.validators.validator import Validator + +class LengthValidator(Validator): + def __init__(self, minL=0, maxL=0): + self.minL = minL + self.maxL = maxL + + def validate(self, input=None): + """Check input length. + Parameters + ------- + minL | integer + maxL | integer + Returns + ------- + input : any + """ + if len(input) >= self.minL and len(input) <= self.maxL: + return input + + return None \ No newline at end of file diff --git a/utilities/ui_ext/prompt_launcher/validators/pattern_validator.py b/utilities/ui_ext/prompt_launcher/validators/pattern_validator.py new file mode 100644 index 00000000..183e06dd --- /dev/null +++ b/utilities/ui_ext/prompt_launcher/validators/pattern_validator.py @@ -0,0 +1,18 @@ +import re + +from utilities.ui_ext.prompt_launcher.validators.validator import Validator + +class PatternValidator(Validator): + def __init__(self, pattern): + self.pattern = pattern + + def validate(self, input=None): + """Check input structure agains given pattern. + Returns + ------- + input : any + """ + if re.match(self.pattern, input): + return input + + return None \ No newline at end of file diff --git a/utilities/ui_ext/prompt_launcher/validators/validator.py b/utilities/ui_ext/prompt_launcher/validators/validator.py new file mode 100644 index 00000000..97eebb2b --- /dev/null +++ b/utilities/ui_ext/prompt_launcher/validators/validator.py @@ -0,0 +1,5 @@ +from abc import ABC, abstractmethod, abstractstaticmethod + +class Validator(ABC): + def validate(self, input=None): + pass \ No newline at end of file diff --git a/utilities/ui_ext/ui_ext_api/__init__.py b/utilities/ui_ext/ui_ext_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utilities/ui_ext/ui_ext_api/ui_ext_api.py b/utilities/ui_ext/ui_ext_api/ui_ext_api.py new file mode 100644 index 00000000..e12aa223 --- /dev/null +++ b/utilities/ui_ext/ui_ext_api/ui_ext_api.py @@ -0,0 +1,199 @@ +import requests, base64, os, json, click, pprint + +from utilities.ui_ext.cli_spinners.cli_spinner import CliSpinner + +class UiPlugin: + def __init__(self, vcduri, token): + self._token = token + self.vcduri = vcduri + self.current_ui_extension = {} + + def __request(self, method, path=None, data=None, uri=None, auth=None, content_type="application/json", accept="application/json"): + headers = {} + if self._token: + headers['x-vcloud-authorization'] = self._token + if auth: + headers['Authorization'] = auth + if content_type: + headers['Content-Type'] = content_type + if accept: + headers['Accept'] = accept + + if path: + uri = self.vcduri+path + + r = requests.request(method, uri, headers=headers, data=data, verify=False) + if 200 <= r.status_code <= 299: + return r + raise Exception ("Unsupported HTTP status code (%d) encountered" % r.status_code) + + def getToken(self, username, org, password): + r = self.__request('POST', + '/api/sessions', + auth='Basic %s' % base64.b64encode('%s@%s:%s' % (username, org, password)), + accept='application/*+xml;version=29.0') + self._token = r.headers['x-vcloud-authorization'] + + def getUiExtensions(self): + return self.__request('GET', '/cloudapi/extensions/ui/') + + def getUiExtension(self, eid): + return self.__request('GET', '/cloudapi/extensions/ui/%s'%eid) + + def postUiExtension(self, data): + return self.__request('POST', '/cloudapi/extensions/ui/', json.dumps(data)) + + def putUiExtension(self, eid, data): + return self.__request('PUT', '/cloudapi/extensions/ui/%s'%eid, json.dumps(data)) + + def deleteUiExtension(self, eid): + return self.__request('DELETE', '/cloudapi/extensions/ui/%s'%eid) + + def postUiExtensionPlugin(self, eid, data): + return self.__request('POST', '/cloudapi/extensions/ui/%s/plugin'%eid, json.dumps(data)) + + def putUiExtensionPlugin(self, uri, data): + return self.__request('PUT', uri=uri, content_type="application/zip", accept=None, data=data) + + def deleteUiExtensionPlugin(self, eid): + return self.__request('DELETE', '/cloudapi/extensions/ui/%s/plugin'%eid) + + def getUiExtensionTenants(self, eid): + return self.__request('GET', '/cloudapi/extensions/ui/%s/tenants'%eid) + + def postUiExtensionTenantsPublishAll(self, eid): + return self.__request('POST', '/cloudapi/extensions/ui/%s/tenants/publishAll'%eid) + + def postUiExtensionTenantsPublish(self, eid, data): + return self.__request('POST', '/cloudapi/extensions/ui/%s/tenants/publish'%eid, data) + + def postUiExtensionTenantsUnPublishAll(self, eid): + return self.__request('POST', '/cloudapi/extensions/ui/%s/tenants/unpublishAll'%eid) + + def postUiExtensionTenantsUnPublish(self, eid, data): + return self.__request('POST', '/cloudapi/extensions/ui/%s/tenants/unpublish'%eid, data) + + def postUiExtensionPluginFromFile(self, eid, fn): + data = { + "fileName": fn.split('/')[-1], + "size": os.stat(fn).st_size + } + return self.postUiExtensionPlugin(eid, data) + + def putUiExtensionPluginFromFile(self, eid, fn): + data = open(fn, 'rb').read() + return self.putUiExtensionPlugin(eid, data) + + def deleteUiExtensionPluginSafe(self, eid): + if self.current_ui_extension.get('plugin_status', None) == 'ready': + return self.deleteUiExtensionPlugin(eid) + else: + print('Unable to delete plugin for %s' % eid) + return None + + def walkUiExtensions(self): + for ext in self.getUiExtensions().json(): + self.current_ui_extension = ext + yield ext + + def parseManifest(self, fn, enabled=True): + data = json.load(open(fn)) + return { + "pluginName": data['name'], + "vendor": data['vendor'], + "description": data['description'], + "version": data['version'], + "license": data['license'], + "link": data['link'], + "tenant_scoped": "tenant" in data['scope'], + "provider_scoped": "service-provider" in data['scope'], + "enabled": enabled + } + + def addExtension(self, data, fn, publishAll=False): + r = self.postUiExtension(data).json() + eid = r['id'] + self.addPlugin(eid, fn, publishAll=publishAll) + + def addPlugin(self, eid, fn, publishAll=False): + r = self.postUiExtensionPluginFromFile(eid, fn) + link = r.headers["Link"].split('>')[0][1:] + + self.putUiExtensionPluginFromFile(link, fn) + + if publishAll: + self.postUiExtensionTenantsPublishAll(eid) + + def removeAllUiExtensions(self): + for ext in self.walkUiExtensions(): + self.removeExtension(ext['id']) + + def removeExtension(self, eid): + self.removePlugin(eid) + self.deleteUiExtension(eid) + + def removePlugin(self, eid): + self.deleteUiExtensionPluginSafe(eid) + + def replacePlugin(self, eid, fn, publishAll=False): + self.removePlugin(eid) + self.addPlugin(eid, fn, publishAll=publishAll) + + def deploy(self, basedir, publishAll=False, preview=False): + spinner = CliSpinner(text="Deploying", spinner='line') + spinner.start() + + manifest = self.parseManifest('%s/src/public/manifest.json'%basedir, enabled=True) + + if preview == True: + pprint.pprint(manifest) + + eid = None + for ext in self.walkUiExtensions(): + if manifest['pluginName'] == ext['pluginName'] and manifest['version'] == ext['version']: + eid = ext['id'] + break + + if not eid: + self.addExtension(manifest, '%s/dist/plugin.zip'%basedir, publishAll=publishAll) + else: + self.replacePlugin(eid, '%s/dist/plugin.zip'%basedir, publishAll=publishAll) + + spinner.stop("Completed!") + + def delete(self, basedir, deleteAll=False): + spinner = CliSpinner(text="Deleting", spinner='line') + spinner.start() + + if deleteAll == True: + for ext in self.walkUiExtensions(): + try: + self.removeExtension(ext['id']) + except Exception as e: + spinner.stop() + raise e + + spinner.stop("Completed!") + return + + spinner.stop() + + exts = self.getUiExtensions().json() + + if len(exts) > 0: + pprint.pprint(exts) + + extID = click.prompt( + "Enter the id of the UI plugin which you want to delete", + type=str + ) + spinner.start() + try: + self.removeExtension(extID) + spinner.stop("Completed!") + return + except Exception as e: + spinner.stop() + raise e + else: + print("There are no UI Extensions...") \ No newline at end of file diff --git a/vcd_cli/ui_extensions.py b/vcd_cli/ui_extensions.py new file mode 100644 index 00000000..b3998269 --- /dev/null +++ b/vcd_cli/ui_extensions.py @@ -0,0 +1,99 @@ +# vCloud CLI 0.1 +# +# Copyright (c) 2017-2018 VMware, Inc. All Rights Reserved. +# +# This product is licensed to you under the +# Apache License, Version 2.0 (the "License"). +# You may not use this product except in compliance with the License. +# +# This product may include a number of subcomponents with +# separate copyright notices and license terms. Your use of the source +# code for the these subcomponents is subject to the terms and +# conditions of the subcomponent's license, as noted in the LICENSE file. +# + +import click, pprint, os + +from utilities.ui_ext.ext_generator.ext_generator import ExtGenerator +from utilities.ui_ext.ui_ext_api.ui_ext_api import UiPlugin +from vcd_cli.utils import restore_session +from vcd_cli.utils import stderr +from vcd_cli.vcd import vcd + +@vcd.group(short_help='Manage UI Extensions') +@click.pass_context +def uiext(ctx): + """Manage UI Extensions in vCloud Director. + +\b + Examples + vcd ext list + Get list of Extensions for current tenant. +\b + vcd ext deploy --publish ( -p ) --preview ( -pr ) + Deploy extension to vCD and if this extension already exists it will be replaced and/or be published for all tenants. + If --preview ( -pr ) flag is provided user will see in his command line his UI extension configuration. +\b + vcd ext delete --all ( -a ) + Delete specific or all plugins, if no flags are provided the user will be promped for UI extension id. + """ + pass + +@uiext.command('generate', short_help='generate UI Extension') +@click.pass_context +def generate_extension(ctx): + try: + gen = ExtGenerator() + gen.generate() + except Exception as e: + stderr(e, ctx) + +@uiext.command('list', short_help='list UI Extensions') +@click.pass_context +def list_extensions(ctx): + try: + restore_session(ctx, vdc_required=False) + client = ctx.obj['client'] + token = client._session.headers['x-vcloud-authorization'] + base_uri = client._uri.split("/api")[0] + ui = UiPlugin(base_uri, token) + pprint.pprint((ui.getUiExtensions().json())) + except Exception as e: + stderr(e, ctx) + +@uiext.command('deploy', short_help='Deploy extension to vCD and if this extension already exists it will be replaced and/or be published for all tenants') +@click.pass_context +@click.option('--path', default=None, type=str) +@click.option('--publish', '-p', default=False, is_flag=True) +@click.option('--preview', '-pr', default=False, is_flag=True) +def deploy(ctx, path, publish, preview): + try: + restore_session(ctx, vdc_required=False) + client = ctx.obj['client'] + token = client._session.headers['x-vcloud-authorization'] + base_uri = client._uri.split("/api")[0] + ui = UiPlugin(base_uri, token) + + if path: + if os.path.exists(path) == False: + raise FileNotFoundError() + + ui.deploy(path, publish, preview) + else: + ui.deploy(os.getcwd(), publish, preview) + except Exception as e: + stderr(e, ctx) + +@uiext.command('delete', short_help='Delete one or all extensions from vCD') +@click.pass_context +@click.option('--all', '-a', default=False, is_flag=True) +def delete(ctx, all): + try: + restore_session(ctx, vdc_required=False) + client = ctx.obj['client'] + token = client._session.headers['x-vcloud-authorization'] + base_uri = client._uri.split("/api")[0] + ui = UiPlugin(base_uri, token) + ui.delete(os.getcwd(), deleteAll=all) + except Exception as e: + stderr(e, ctx) diff --git a/vcd_cli/vcd.py b/vcd_cli/vcd.py index a5e1d30e..486910a8 100644 --- a/vcd_cli/vcd.py +++ b/vcd_cli/vcd.py @@ -129,4 +129,5 @@ def help(ctx, tree): from vcd_cli import vc # NOQA from vcd_cli import vdc # NOQA from vcd_cli import vm # NOQA + from vcd_cli import ui_extensions # NOQA init(autoreset=True)