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..51a56123 --- /dev/null +++ b/utilities/ui_ext/ext_generator/ext_generator.py @@ -0,0 +1,305 @@ +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", + 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)