diff --git a/deploifai/api/__init__.py b/deploifai/api/__init__.py index 83a13eb..17884a3 100644 --- a/deploifai/api/__init__.py +++ b/deploifai/api/__init__.py @@ -5,7 +5,7 @@ from deploifai.api.errors import DeploifaiAPIError from deploifai.utilities.credentials import get_auth_token -from deploifai.cloud_profile.cloud_profile import CloudProfile +from deploifai.utilities.cloud_profile import CloudProfile from deploifai.utilities import environment @@ -298,6 +298,49 @@ def get_cloud_profiles(self, workspace=None) -> typing.List[CloudProfile]: ] return cloud_profiles + def create_cloud_profile(self, provider, name, credentials, workspace, fragment): + mutation = """ + mutation( + $whereAccount: AccountWhereUniqueInput! + $data: CloudProfileCreateInput! + ) { + createCloudProfile(whereAccount: $whereAccount, data: $data) { + ...cloud_profile + } + } + """ + + variables = { + "whereAccount": {"username": workspace["username"]}, + "data": { + "name": name, + "provider": provider.value, + }, + } + + if provider.value == "AWS": + variables["data"]["awsCredentials"] = credentials + elif provider.value == "AZURE": + variables["data"]["azureCredentials"] = credentials + elif provider.value == "GCP": + variables["data"]["gcpCredentials"] = credentials + + try: + r = requests.post( + self.uri, + json={"query": mutation + fragment, "variables": variables}, + headers=self.headers, + ) + + create_mutation_data = r.json() + + return create_mutation_data["data"]["createCloudProfile"]["id"] + except TypeError as err: + raise DeploifaiAPIError("Could not create cloud profile. Please try again.") + except KeyError as err: + raise DeploifaiAPIError("Could not create cloud profile. Please try again.") + + def get_projects(self, workspace, fragment: str, where_project=None): query = ( """ @@ -327,9 +370,9 @@ def get_projects(self, workspace, fragment: str, where_project=None): api_data = r.json() return api_data["data"]["projects"] - except TypeError as err: + except TypeError: raise DeploifaiAPIError("Could not get projects. Please try again.") - except KeyError as err: + except KeyError: raise DeploifaiAPIError("Could not get projects. Please try again.") def get_project(self, project_id: str, fragment: str): @@ -357,19 +400,19 @@ def get_project(self, project_id: str, fragment: str): api_data = r.json() return api_data["data"]["project"] - except TypeError as err: + except TypeError: raise DeploifaiAPIError("Could not get project. Please try again.") - except KeyError as err: + except KeyError: raise DeploifaiAPIError("Could not get project. Please try again.") - def create_project(self, project_name: str, cloud_profile: CloudProfile): + def create_project(self, project_name: str, cloud_profile: CloudProfile, fragment): mutation = """ mutation( $whereAccount: AccountWhereUniqueInput! $data: CreateProjectInput! ) { createProject(whereAccount: $whereAccount, data: $data) { - id + ...project } } """ @@ -385,14 +428,14 @@ def create_project(self, project_name: str, cloud_profile: CloudProfile): try: r = requests.post( self.uri, - json={"query": mutation, "variables": variables}, + json={"query": mutation + fragment, "variables": variables}, headers=self.headers, ) create_mutation_data = r.json() return create_mutation_data["data"]["createProject"] - except TypeError as err: + except TypeError: raise DeploifaiAPIError("Could not create project. Please try again.") - except KeyError as err: + except KeyError: raise DeploifaiAPIError("Could not create project. Please try again.") diff --git a/deploifai/cli.py b/deploifai/cli.py index 582682d..6c3c874 100644 --- a/deploifai/cli.py +++ b/deploifai/cli.py @@ -10,8 +10,9 @@ from .application import application from .project import project from .data import data +from .cloud_profile import cloud_profile -commands = {"auth": auth, "project": project, "data": data, "application": application} +commands = {"auth": auth, "project": project, "data": data, "application": application, "cloud-profile": cloud_profile} @click.group(commands=commands) diff --git a/deploifai/cloud_profile/__init__.py b/deploifai/cloud_profile/__init__.py index e69de29..42ba2e1 100644 --- a/deploifai/cloud_profile/__init__.py +++ b/deploifai/cloud_profile/__init__.py @@ -0,0 +1,13 @@ +import click + +from .create import create + + +@click.group() +def cloud_profile(): + """ + Manage user's cloud profiles + """ + + +cloud_profile.add_command(create) diff --git a/deploifai/cloud_profile/create.py b/deploifai/cloud_profile/create.py new file mode 100644 index 0000000..20a36a4 --- /dev/null +++ b/deploifai/cloud_profile/create.py @@ -0,0 +1,165 @@ +import click +from PyInquirer import prompt +from deploifai.utilities.cloud_profile import Provider +from deploifai.context import ( + pass_deploifai_context_obj, + DeploifaiContextObj, + is_authenticated, +) +from deploifai.utilities.user import parse_user_profiles +from deploifai.api import DeploifaiAPIError + + +@click.command() +@click.option("--name", "-n", help="Cloud profile name", prompt="Choose a cloud profile name") +@click.option("--workspace", "-w", help="Workspace name", type=str) +@pass_deploifai_context_obj +@is_authenticated +def create(context: DeploifaiContextObj, name: str, workspace: str): + """ + Create a new cloud profile + """ + deploifai_api = context.api + + user_data = deploifai_api.get_user() + personal_workspace, team_workspaces = parse_user_profiles(user_data) + + workspaces_from_api = [personal_workspace] + team_workspaces + + # checking validity of workspace, and prompting workspaces choices if not specified + if workspace and len(workspace): + if any(ws["username"] == workspace for ws in workspaces_from_api): + for w in workspaces_from_api: + if w["username"] == workspace: + command_workspace = w + break + else: + # the workspace user input does not match with any of the workspaces the user has access to + click.secho( + f"{workspace} cannot be found. Please put in a workspace you have access to.", + fg="red", + ) + raise click.Abort() + else: + _choose_workspace = prompt( + { + "type": "list", + "name": "workspace", + "message": "Choose a workspace", + "choices": [ + {"name": ws["username"], "value": ws} for ws in workspaces_from_api + ], + } + ) + if _choose_workspace == {}: + raise click.Abort() + command_workspace = _choose_workspace["workspace"] + + try: + cloud_profiles = deploifai_api.get_cloud_profiles(workspace=command_workspace) + except DeploifaiAPIError as err: + click.secho(err, fg="red") + return + + existing_names = [cloud_profile.name for cloud_profile in cloud_profiles] + + if name in existing_names: + click.echo(click.style("Cloud profile name taken. Existing names: ", fg="red") + ' '.join(existing_names)) + raise click.Abort() + + provider = prompt( + { + "type": "list", + "name": "provider", + "message": "Choose a provider for the new cloud profile", + "choices": [{"name": provider.value, "value": provider} for provider in Provider] + } + )["provider"] + + cloud_credentials = {} + if provider == Provider.AWS: + cloud_credentials["awsAccessKey"] = prompt( + { + "type": "input", + "name": "awsAccessKey", + "message": "AWS Access Key ID (We'll keep these secured and encrypted)", + } + )["awsAccessKey"] + cloud_credentials["awsSecretAccessKey"] = prompt( + { + "type": "input", + "name": "awsSecretAccessKey", + "message": "AWS Secret Access Key (We'll keep these secured and encrypted)", + } + )["awsSecretAccessKey"] + elif provider == Provider.AZURE: + cloud_credentials["azureSubscriptionId"] = prompt( + { + "type": "input", + "name": "azureSubscriptionId", + "message": "Azure Account Subscription ID (We'll keep these secured and encrypted)", + } + )["azureSubscriptionId"] + cloud_credentials["azureTenantId"] = prompt( + { + "type": "input", + "name": "azureTenantId", + "message": "Azure Active Directory Tenant ID (We'll keep these secured and encrypted)", + } + )["azureTenantId"] + cloud_credentials["azureClientId"] = prompt( + { + "type": "input", + "name": "azureClientId", + "message": "Azure Client ID (We'll keep these secured and encrypted)", + } + )["azureClientId"] + cloud_credentials["azureClientSecret"] = prompt( + { + "type": "input", + "name": "azureClientSecret", + "message": "Azure Client Secret / Password (We'll keep these secured and encrypted)", + } + )["azureClientSecret"] + else: + cloud_credentials["gcpProjectId"] = prompt( + { + "type": "input", + "name": "gcpProjectId", + "message": "GCP Project ID (We'll keep these secured and encrypted)", + } + )["gcpProjectId"] + + gcp_service_account_key_file = prompt( + { + "type": "input", + "name": "gcp_service_account_key_file", + "message": "File path for the GCP Service Account Key File (We'll keep these secured and encrypted)", + } + )["gcp_service_account_key_file"] + + try: + with open(gcp_service_account_key_file) as gcp_service_account_key_json: + cloud_credentials["gcpServiceAccountKey"] = gcp_service_account_key_json.read() + except FileNotFoundError: + click.secho("File not found. Please input the correct file path.", fg="red") + raise click.Abort() + + try: + cloud_profile_fragment = """ + fragment cloud_profile on CloudProfile { + id + } + """ + _ = deploifai_api.create_cloud_profile( + provider, + name, + cloud_credentials, + command_workspace, + cloud_profile_fragment + ) + except DeploifaiAPIError as err: + click.secho(err, fg="red") + raise click.Abort() + + click.secho(f"Successfully created a new cloud profile named {name}.", fg="green") diff --git a/deploifai/context.py b/deploifai/context.py index f6bd485..1a83d45 100644 --- a/deploifai/context.py +++ b/deploifai/context.py @@ -35,20 +35,16 @@ def __init__(self): pass def read_config(self): - global_config = configparser.ConfigParser() - # read the global config file - global_config.read(global_config_filepath) + self.global_config.read(global_config_filepath) # initialise sections if they don't exist already for section in global_config_sections: - if section not in global_config.sections(): - global_config[section] = {} + if section not in self.global_config.sections(): + self.global_config[section] = {} self.debug_msg(f"Read global config file from {global_config_filepath}") - self.global_config = global_config - # read local config file self.local_config = local_config.read_config_file() @@ -145,3 +141,15 @@ def wrapper(click_context, *args, **kwargs): raise click.Abort() return functools.update_wrapper(wrapper, f) + + +def project_found(f): + @pass_context + def wrapper(click_context, *args, **kwargs): + deploifai_context = click_context.find_object(DeploifaiContextObj) + if deploifai_context.local_config is not None: + return click_context.invoke(f, *args, **kwargs) + + raise local_config.DeploifaiNotInitialisedError("Deploifai project not found") + + return functools.update_wrapper(wrapper, f) diff --git a/deploifai/data/info.py b/deploifai/data/info.py index 3701297..a2f8e10 100644 --- a/deploifai/data/info.py +++ b/deploifai/data/info.py @@ -1,10 +1,15 @@ import click -from deploifai.context import pass_deploifai_context_obj, DeploifaiContextObj +from deploifai.context import ( + pass_deploifai_context_obj, + DeploifaiContextObj, + project_found, +) @click.command() @pass_deploifai_context_obj +@project_found def info(context: DeploifaiContextObj): data_storage_config = context.local_config["DATA_STORAGE"] diff --git a/deploifai/project/browse.py b/deploifai/project/browse.py index 7bb009c..a140bdf 100644 --- a/deploifai/project/browse.py +++ b/deploifai/project/browse.py @@ -3,12 +3,17 @@ import requests from deploifai.api import DeploifaiAPIError -from deploifai.context import pass_deploifai_context_obj, DeploifaiContextObj +from deploifai.context import ( + pass_deploifai_context_obj, + DeploifaiContextObj, + project_found, +) from deploifai.utilities.frontend_routing import get_project_route @click.command() @pass_deploifai_context_obj +@project_found @click.option("--workspace", help="Workspace name", type=str) @click.option("--project", help="Project name", type=str) def browse(context: DeploifaiContextObj, project: str, workspace="unassigned"): diff --git a/deploifai/project/create.py b/deploifai/project/create.py index 47a56e1..2959d38 100644 --- a/deploifai/project/create.py +++ b/deploifai/project/create.py @@ -9,6 +9,7 @@ from deploifai.api import DeploifaiAPIError from deploifai.utilities.user import parse_user_profiles from PyInquirer import prompt +import os @click.command() @@ -68,39 +69,30 @@ def create(context: DeploifaiContextObj, name: str, workspace): click.echo("An error occurred when fetching projects. Please try again.") return - err_msg = "Project name taken. Choose a unique project name:" - project_names = [project["name"] for project in projects] + user_pwd_dir_names = os.listdir() - name_taken_err_msg = f"Project name taken. Existing names in chosen workspace: {' '.join(project_names)}\nChoose a unique project name:" - err_msg = name_taken_err_msg - - is_valid_name = not (name in project_names) + is_valid_name = True - while not is_valid_name: - prompt_name = prompt( - { - "type": "input", - "name": "project_name", - "message": err_msg, - } - ) + err_msg = "" - new_project_name = prompt_name["project_name"] + if name in user_pwd_dir_names: + is_valid_name = False + err_msg = f"There are existing files/directories in your computer also named {name}" + elif name in project_names: + is_valid_name = False + err_msg = f"Project name taken. Existing names in chosen workspace: {' '.join(project_names)}." + elif name.isalnum(): + is_valid_name = False + err_msg = "Project name should only contain alphanumeric characters." - if len(new_project_name) == 0: - err_msg = "Project name cannot be empty.\nChoose a non-empty project name:" - elif not new_project_name.isalnum(): - err_msg = f"Project name should only contain alphanumeric characters.\nChoose a valid project name:" - elif new_project_name in project_names: - err_msg = name_taken_err_msg - else: - name = new_project_name - is_valid_name = True + if not is_valid_name: + click.secho(err_msg, fg="red") + raise click.Abort() try: cloud_profiles = deploifai_api.get_cloud_profiles(workspace=command_workspace) - except DeploifaiAPIError as err: + except DeploifaiAPIError: click.echo("Could not fetch cloud profiles. Please try again.") return @@ -126,12 +118,27 @@ def create(context: DeploifaiContextObj, name: str, workspace): cloud_profile = choose_cloud_profile["cloud_profile"] + # create proejct in the backend try: project_id = deploifai_api.create_project(name, cloud_profile)["id"] except DeploifaiAPIError as err: - click.echo("Could not create project. Please try again.") - return + click.secho(err, fg="red") + raise click.Abort() click.secho(f"Successfully created new project named {name}.", fg="green") + # create a project directory locally, along with .deploifai directory within this project + try: + os.mkdir(name) + except OSError: + click.secho("An error when creating the project locally", fg="red") + raise click.Abort() + + click.secho(f"A new directory named {name} has been created locally.", fg="green") + + project_path = os.path.join(os.getcwd(), name) + local_config.create_config_files(project_path) + + context.local_config = local_config.read_config_file() + # set id in local config file local_config.set_project_config(project_id, context.local_config) diff --git a/deploifai/cloud_profile/cloud_profile.py b/deploifai/utilities/cloud_profile.py similarity index 65% rename from deploifai/cloud_profile/cloud_profile.py rename to deploifai/utilities/cloud_profile.py index d78c884..06646d2 100644 --- a/deploifai/cloud_profile/cloud_profile.py +++ b/deploifai/utilities/cloud_profile.py @@ -1,3 +1,10 @@ +from enum import Enum + +class Provider(Enum): + AWS = "AWS" + AZURE = "AZURE" + GCP = "GCP" + class CloudProfile: def __init__(self, id, name, provider, workspace): self.id = id diff --git a/deploifai/utilities/local_config.py b/deploifai/utilities/local_config.py index 8950dc1..8b23302 100644 --- a/deploifai/utilities/local_config.py +++ b/deploifai/utilities/local_config.py @@ -2,7 +2,25 @@ import pathlib import click -config_file_path = pathlib.Path(".deploifai").joinpath("local.cfg") + +def _find_local_config_dir(): + """ + Traverse up the file system and checks for a .deploifai directory. + If does not exist, raise error not found. + :return: if local config file exists, return pathlib.Path object that points to the config file + """ + path = pathlib.Path.cwd() + while not path.joinpath(".deploifai").exists() and path != path.parent: + path = path.parent + + if path == path.parent: + return None + # raise DeploifaiNotInitialisedError("Deploifai project not found.") + + return path.joinpath(".deploifai", "local.cfg") + + +config_file_path = _find_local_config_dir() """ Manages a .deploifai/local.cfg file to store configuration info about a project. @@ -37,17 +55,29 @@ def __init__(self, message): super(DeploifaiDataAlreadyInitialisedError, self).__init__(message) -def create_config_files(): +class DeploifaiNotInitialisedError(Exception): + """ + Exception when Deploifai project is not found. + """ + + def __init__(self, message): + click.echo(click.style("Project not found. To create a project: ", fg="red") + + click.style("deploifai project create NAME", fg="blue") + ) + super(DeploifaiNotInitialisedError, self).__init__(message) + + +def create_config_files(new_project_dir: str): + global config_file_path """ Creates the folder .deploifai that stores all the config files. :return: None """ - if pathlib.Path(".deploifai").exists(): - raise DeploifaiAlreadyInitialisedError( - "Deploifai has already been initialised in this directory." - ) + local_config_dir = pathlib.Path(new_project_dir).joinpath(".deploifai") + local_config_dir.mkdir(exist_ok=True) + + config_file_path = local_config_dir.joinpath("local.cfg") - pathlib.Path(".deploifai").mkdir() config_file_path.touch(exist_ok=True) # initialise sections if they don't exist already @@ -58,8 +88,7 @@ def create_config_files(): click.secho( """A .deploifai directory has been created, which contains configuration that Deploifai requires. - You should version control this directory. - """, + You should version control this directory.""", fg="blue", ) @@ -69,6 +98,9 @@ def read_config_file() -> configparser.ConfigParser: Read the config file in the existing .deploifai/local.cfg file :return: A ConfigParser that contains all the configs in the config file """ + if config_file_path is None: + return None + try: config = configparser.ConfigParser() # read the config file @@ -87,11 +119,8 @@ def read_config_file() -> configparser.ConfigParser: def save_config_file(config: configparser.ConfigParser): """ Save the local config from the context in the local.cfg file. - :param context: The deploifai context object + :param config: A ConfigParser object representing config data """ - if not config_file_path.exists(): - create_config_files() - with config_file_path.open("w") as config_file: config.write(config_file)