from pathlib import Path from typing import Optional, Dict, List, cast from rich.prompt import Prompt from phi.api.workspace import log_workspace_event from phi.api.schemas.workspace import ( WorkspaceSchema, WorkspaceCreate, WorkspaceUpdate, WorkspaceEvent, ) from phi.api.schemas.team import TeamSchema, TeamIdentifier from phi.cli.config import PhiCliConfig from phi.cli.console import ( console, print_heading, print_info, print_subheading, log_config_not_available_msg, ) from phi.infra.type import InfraType from phi.infra.resources import InfraResources from phi.workspace.config import WorkspaceConfig from phi.workspace.enums import WorkspaceStarterTemplate from phi.utils.common import str_to_int from phi.utils.log import logger TEMPLATE_TO_NAME_MAP: Dict[WorkspaceStarterTemplate, str] = { WorkspaceStarterTemplate.agent_app: "agent-app", WorkspaceStarterTemplate.agent_api: "agent-api", } TEMPLATE_TO_REPO_MAP: Dict[WorkspaceStarterTemplate, str] = { WorkspaceStarterTemplate.agent_app: "https://github.com/phidatahq/agent-app.git", WorkspaceStarterTemplate.agent_api: "https://github.com/phidatahq/agent-api.git", } def create_workspace( name: Optional[str] = None, template: Optional[str] = None, url: Optional[str] = None ) -> Optional[WorkspaceConfig]: """Creates a new workspace and returns the WorkspaceConfig. This function clones a template or url on the users machine at the path: cwd/name """ import git from shutil import copytree from phi.cli.operator import initialize_phi from phi.utils.filesystem import rmdir_recursive from phi.workspace.helpers import get_workspace_dir_path from phi.utils.git import GitCloneProgress current_dir: Path = Path(".").resolve() # Phi should be initialized before creating a workspace phi_config: Optional[PhiCliConfig] = PhiCliConfig.from_saved_config() if not phi_config: phi_config = initialize_phi() if not phi_config: log_config_not_available_msg() return None phi_config = cast(PhiCliConfig, phi_config) ws_dir_name: Optional[str] = name repo_to_clone: Optional[str] = url ws_template = WorkspaceStarterTemplate.agent_app templates = list(WorkspaceStarterTemplate.__members__.values()) if repo_to_clone is None: # Get repo_to_clone from template if template is None: # Get starter template from the user if template is not provided # Display available starter templates and ask user to select one print_info("Select starter template or press Enter for default (agent-app)") for template_id, template_name in enumerate(templates, start=1): print_info(" [b][{}][/b] {}".format(template_id, WorkspaceStarterTemplate(template_name).value)) # Get starter template from the user template_choices = [str(idx) for idx, _ in enumerate(templates, start=1)] template_inp_raw = Prompt.ask("Template Number", choices=template_choices, default="1", show_choices=False) # Convert input to int template_inp = str_to_int(template_inp_raw) if template_inp is not None: template_inp_idx = template_inp - 1 ws_template = WorkspaceStarterTemplate(templates[template_inp_idx]) elif template.lower() in WorkspaceStarterTemplate.__members__.values(): ws_template = WorkspaceStarterTemplate(template) else: raise Exception(f"{template} is not a supported template, please choose from: {templates}") logger.debug(f"Selected Template: {ws_template.value}") repo_to_clone = TEMPLATE_TO_REPO_MAP.get(ws_template) if ws_dir_name is None: default_ws_name = "agent-app" if url is not None: # Get default_ws_name from url default_ws_name = url.split("/")[-1].split(".")[0] else: # Get default_ws_name from template default_ws_name = TEMPLATE_TO_NAME_MAP.get(ws_template, "agent-app") logger.debug(f"Asking for ws name with default: {default_ws_name}") # Ask user for workspace name if not provided ws_dir_name = Prompt.ask("Workspace Name", default=default_ws_name, console=console) if ws_dir_name is None: logger.error("Workspace name is required") return None if repo_to_clone is None: logger.error("URL or Template is required") return None # Check if we can create the workspace in the current dir ws_root_path: Path = current_dir.joinpath(ws_dir_name) if ws_root_path.exists(): logger.error(f"Directory {ws_root_path} exists, please delete directory or choose another name for workspace") return None print_info(f"Creating {str(ws_root_path)}") logger.debug("Cloning: {}".format(repo_to_clone)) try: _cloned_git_repo: git.Repo = git.Repo.clone_from( repo_to_clone, str(ws_root_path), progress=GitCloneProgress(), # type: ignore ) except Exception as e: logger.error(e) return None # Remove existing .git folder _dot_git_folder = ws_root_path.joinpath(".git") _dot_git_exists = _dot_git_folder.exists() if _dot_git_exists: logger.debug(f"Deleting {_dot_git_folder}") try: _dot_git_exists = not rmdir_recursive(_dot_git_folder) except Exception as e: logger.warning(f"Failed to delete {_dot_git_folder}: {e}") logger.info("Please delete the .git folder manually") pass phi_config.add_new_ws_to_config(ws_root_path=ws_root_path) try: # workspace_dir_path is the path to the ws_root/workspace dir workspace_dir_path: Path = get_workspace_dir_path(ws_root_path) workspace_secrets_dir = workspace_dir_path.joinpath("secrets").resolve() workspace_example_secrets_dir = workspace_dir_path.joinpath("example_secrets").resolve() print_info(f"Creating {str(workspace_secrets_dir)}") copytree( str(workspace_example_secrets_dir), str(workspace_secrets_dir), ) except Exception as e: logger.warning(f"Could not create workspace/secrets: {e}") logger.warning("Please manually copy workspace/example_secrets to workspace/secrets") print_info(f"Your new workspace is available at {str(ws_root_path)}\n") return setup_workspace(ws_root_path=ws_root_path) def setup_workspace(ws_root_path: Path) -> Optional[WorkspaceConfig]: """Setup a phi workspace at `ws_root_path` and return the WorkspaceConfig 1. Pre-requisites 1.1 Check ws_root_path exists and is a directory 1.2 Create PhiCliConfig if needed 1.3 Create a WorkspaceConfig if needed 1.4 Get the workspace name 1.5 Get the git remote origin url 1.6 Create anon user if needed 2. Create or update WorkspaceSchema 2.1 Check if a ws_schema exists for this workspace, meaning this workspace has a record in phi-api 2.2 Create WorkspaceSchema if it doesn't exist 2.3 Update WorkspaceSchema if git_url is updated """ from rich.live import Live from rich.status import Status from phi.cli.operator import initialize_phi from phi.utils.git import get_remote_origin_for_dir from phi.workspace.helpers import get_workspace_dir_path print_heading("Setting up workspace\n") ###################################################### ## 1. Pre-requisites ###################################################### # 1.1 Check ws_root_path exists and is a directory ws_is_valid: bool = ws_root_path is not None and ws_root_path.exists() and ws_root_path.is_dir() if not ws_is_valid: logger.error("Invalid directory: {}".format(ws_root_path)) return None # 1.2 Create PhiCliConfig if needed phi_config: Optional[PhiCliConfig] = PhiCliConfig.from_saved_config() if not phi_config: phi_config = initialize_phi() if not phi_config: log_config_not_available_msg() return None # 1.3 Create a WorkspaceConfig if needed logger.debug(f"Checking for a workspace at {ws_root_path}") ws_config: Optional[WorkspaceConfig] = phi_config.get_ws_config_by_path(ws_root_path) if ws_config is None: # There's no record of this workspace, reasons: # - The user is setting up a new workspace # - The user ran `phi init -r` which erased existing workspaces logger.debug(f"Could not find a workspace at: {ws_root_path}") # Check if the workspace contains a `workspace` dir workspace_ws_dir_path = get_workspace_dir_path(ws_root_path) logger.debug(f"Found the `workspace` configuration at: {workspace_ws_dir_path}") ws_config = phi_config.create_or_update_ws_config(ws_root_path=ws_root_path, set_as_active=True) if ws_config is None: logger.error(f"Failed to create WorkspaceConfig for {ws_root_path}") return None else: logger.debug(f"Found workspace at {ws_root_path}") # 1.4 Get the workspace name workspace_name = ws_root_path.stem.replace(" ", "-").replace("_", "-").lower() logger.debug(f"Workspace name: {workspace_name}") # 1.5 Get the git remote origin url git_remote_origin_url: Optional[str] = get_remote_origin_for_dir(ws_root_path) logger.debug("Git origin: {}".format(git_remote_origin_url)) # 1.6 Create anon user if the user is not logged in if phi_config.user is None: from phi.api.user import create_anon_user logger.debug("Creating anon user") with Live(transient=True) as live_log: status = Status("Creating user...", spinner="aesthetic", speed=2.0, refresh_per_second=10) live_log.update(status) anon_user = create_anon_user() status.stop() if anon_user is not None: phi_config.user = anon_user ###################################################### ## 2. Create or update WorkspaceSchema ###################################################### # 2.1 Check if a ws_schema exists for this workspace, meaning this workspace has a record in phi-api ws_schema: Optional[WorkspaceSchema] = ws_config.ws_schema if ws_config is not None else None if phi_config.user is not None: # 2.2 Create WorkspaceSchema if it doesn't exist if ws_schema is None or ws_schema.id_workspace is None: from phi.api.team import get_teams_for_user from phi.api.workspace import create_workspace_for_user # If ws_schema is None, this is a NEW WORKSPACE. # We make a call to the api to create a new ws_schema logger.debug("Creating ws_schema") logger.debug(f"Getting teams for user: {phi_config.user.email}") teams: Optional[List[TeamSchema]] = None selected_team: Optional[TeamSchema] = None team_identifier: Optional[TeamIdentifier] = None with Live(transient=True) as live_log: status = Status( "Checking for available teams...", spinner="aesthetic", speed=2.0, refresh_per_second=10 ) live_log.update(status) teams = get_teams_for_user(phi_config.user) status.stop() if teams is not None and len(teams) > 0: logger.debug(f"The user has {len(teams)} available teams. Checking if they want to use one of them") print_info("Which account would you like to create this workspace in?") print_info(" [b][1][/b] Personal (default)") for team_idx, team_schema in enumerate(teams, start=2): print_info(" [b][{}][/b] {}".format(team_idx, team_schema.name)) account_choices = ["1"] + [str(idx) for idx, _ in enumerate(teams, start=2)] account_inp_raw = Prompt.ask("Account Number", choices=account_choices, default="1", show_choices=False) account_inp = str_to_int(account_inp_raw) if account_inp is not None: if account_inp == 1: print_info("Creating workspace in your personal account") else: selected_team = teams[account_inp - 2] print_info(f"Creating workspace in {selected_team.name}") team_identifier = TeamIdentifier(id_team=selected_team.id_team, team_url=selected_team.url) with Live(transient=True) as live_log: status = Status("Creating workspace...", spinner="aesthetic", speed=2.0, refresh_per_second=10) live_log.update(status) ws_schema = create_workspace_for_user( user=phi_config.user, workspace=WorkspaceCreate( ws_name=workspace_name, git_url=git_remote_origin_url, ), team=team_identifier, ) status.stop() logger.debug(f"Workspace created: {workspace_name}") if selected_team is not None: logger.debug(f"Selected team: {selected_team.name}") ws_config = phi_config.create_or_update_ws_config( ws_root_path=ws_root_path, ws_schema=ws_schema, ws_team=selected_team, set_as_active=True ) # 2.3 Update WorkspaceSchema if git_url is updated if git_remote_origin_url is not None and ws_schema is not None and ws_schema.git_url != git_remote_origin_url: from phi.api.workspace import update_workspace_for_user, update_workspace_for_team logger.debug("Updating workspace") logger.debug(f"Existing git_url: {ws_schema.git_url}") logger.debug(f"New git_url: {git_remote_origin_url}") if ws_config is not None and ws_config.ws_team is not None: updated_workspace_schema = update_workspace_for_team( user=phi_config.user, workspace=WorkspaceUpdate( id_workspace=ws_schema.id_workspace, git_url=git_remote_origin_url, ), team=TeamIdentifier(id_team=ws_config.ws_team.id_team, team_url=ws_config.ws_team.url), ) else: updated_workspace_schema = update_workspace_for_user( user=phi_config.user, workspace=WorkspaceUpdate( id_workspace=ws_schema.id_workspace, git_url=git_remote_origin_url, ), ) if updated_workspace_schema is not None: # Update the ws_schema for this workspace. ws_config = phi_config.create_or_update_ws_config( ws_root_path=ws_root_path, ws_schema=updated_workspace_schema, set_as_active=True ) else: logger.debug("Failed to update workspace. Please setup again") if ws_config is not None: # logger.debug("Workspace Config: {}".format(ws_config.model_dump_json(indent=2))) print_subheading("Setup complete! Next steps:") print_info("1. Start workspace:") print_info("\tphi ws up") print_info("2. Stop workspace:") print_info("\tphi ws down") if ws_config.workspace_settings is not None: scripts_dir = ws_config.workspace_settings.scripts_dir install_ws_file = f"sh {ws_root_path}/{scripts_dir}/install.sh" print_info("3. Install workspace dependencies:") print_info(f"\t{install_ws_file}") if ws_config.ws_schema is not None and phi_config.user is not None: log_workspace_event( user=phi_config.user, workspace_event=WorkspaceEvent( id_workspace=ws_config.ws_schema.id_workspace, event_type="setup", event_status="success", event_data={"workspace_root_path": str(ws_root_path)}, ), ) return ws_config else: print_info("Workspace setup unsuccessful. Please try again.") return None ###################################################### ## End Workspace setup ###################################################### def start_workspace( phi_config: PhiCliConfig, ws_config: WorkspaceConfig, target_env: Optional[str] = None, target_infra: Optional[InfraType] = None, target_group: Optional[str] = None, target_name: Optional[str] = None, target_type: Optional[str] = None, dry_run: Optional[bool] = False, auto_confirm: Optional[bool] = False, force: Optional[bool] = None, pull: Optional[bool] = False, ) -> None: """Start a Phi Workspace. This is called from `phi ws up`""" if ws_config is None: logger.error("WorkspaceConfig invalid") return # Set the local environment variables before processing configs ws_config.set_local_env() # Get resource groups to deploy resource_groups_to_create: List[InfraResources] = ws_config.get_resources( env=target_env, infra=target_infra, order="create", ) # Track number of resource groups created num_rgs_created = 0 num_rgs_to_create = len(resource_groups_to_create) # Track number of resources created num_resources_created = 0 num_resources_to_create = 0 if num_rgs_to_create == 0: print_info("No resources to create") return logger.debug(f"Deploying {num_rgs_to_create} resource groups") for rg in resource_groups_to_create: _num_resources_created, _num_resources_to_create = rg.create_resources( group_filter=target_group, name_filter=target_name, type_filter=target_type, dry_run=dry_run, auto_confirm=auto_confirm, force=force, pull=pull, ) if _num_resources_created > 0: num_rgs_created += 1 num_resources_created += _num_resources_created num_resources_to_create += _num_resources_to_create logger.debug(f"Deployed {num_resources_created} resources in {num_rgs_created} resource groups") if dry_run: return if num_resources_created == 0: return print_heading(f"\n--**-- ResourceGroups deployed: {num_rgs_created}/{num_rgs_to_create}\n") workspace_event_status = "in_progress" if num_resources_created == num_resources_to_create: workspace_event_status = "success" else: logger.error("Some resources failed to create, please check logs") workspace_event_status = "failed" if phi_config.user is not None and ws_config.ws_schema is not None and ws_config.ws_schema.id_workspace is not None: # Log workspace start event log_workspace_event( user=phi_config.user, workspace_event=WorkspaceEvent( id_workspace=ws_config.ws_schema.id_workspace, event_type="start", event_status=workspace_event_status, event_data={ "target_env": target_env, "target_infra": target_infra, "target_group": target_group, "target_name": target_name, "target_type": target_type, "dry_run": dry_run, "auto_confirm": auto_confirm, "force": force, }, ), ) def stop_workspace( phi_config: PhiCliConfig, ws_config: WorkspaceConfig, target_env: Optional[str] = None, target_infra: Optional[InfraType] = None, target_group: Optional[str] = None, target_name: Optional[str] = None, target_type: Optional[str] = None, dry_run: Optional[bool] = False, auto_confirm: Optional[bool] = False, force: Optional[bool] = None, ) -> None: """Stop a Phi Workspace. This is called from `phi ws down`""" if ws_config is None: logger.error("WorkspaceConfig invalid") return # Set the local environment variables before processing configs ws_config.set_local_env() # Get resource groups to delete resource_groups_to_delete: List[InfraResources] = ws_config.get_resources( env=target_env, infra=target_infra, order="delete", ) # Track number of resource groups deleted num_rgs_deleted = 0 num_rgs_to_delete = len(resource_groups_to_delete) # Track number of resources deleted num_resources_deleted = 0 num_resources_to_delete = 0 if num_rgs_to_delete == 0: print_info("No resources to delete") return logger.debug(f"Deleting {num_rgs_to_delete} resource groups") for rg in resource_groups_to_delete: _num_resources_deleted, _num_resources_to_delete = rg.delete_resources( group_filter=target_group, name_filter=target_name, type_filter=target_type, dry_run=dry_run, auto_confirm=auto_confirm, force=force, ) if _num_resources_deleted > 0: num_rgs_deleted += 1 num_resources_deleted += _num_resources_deleted num_resources_to_delete += _num_resources_to_delete logger.debug(f"Deleted {num_resources_deleted} resources in {num_rgs_deleted} resource groups") if dry_run: return if num_resources_deleted == 0: return print_heading(f"\n--**-- ResourceGroups deleted: {num_rgs_deleted}/{num_rgs_to_delete}\n") workspace_event_status = "in_progress" if num_resources_to_delete == num_resources_deleted: workspace_event_status = "success" else: logger.error("Some resources failed to delete, please check logs") workspace_event_status = "failed" if phi_config.user is not None and ws_config.ws_schema is not None and ws_config.ws_schema.id_workspace is not None: # Log workspace stop event log_workspace_event( user=phi_config.user, workspace_event=WorkspaceEvent( id_workspace=ws_config.ws_schema.id_workspace, event_type="stop", event_status=workspace_event_status, event_data={ "target_env": target_env, "target_infra": target_infra, "target_group": target_group, "target_name": target_name, "target_type": target_type, "dry_run": dry_run, "auto_confirm": auto_confirm, "force": force, }, ), ) def update_workspace( phi_config: PhiCliConfig, ws_config: WorkspaceConfig, target_env: Optional[str] = None, target_infra: Optional[InfraType] = None, target_group: Optional[str] = None, target_name: Optional[str] = None, target_type: Optional[str] = None, dry_run: Optional[bool] = False, auto_confirm: Optional[bool] = False, force: Optional[bool] = None, pull: Optional[bool] = False, ) -> None: """Update a Phi Workspace. This is called from `phi ws patch`""" if ws_config is None: logger.error("WorkspaceConfig invalid") return # Set the local environment variables before processing configs ws_config.set_local_env() # Get resource groups to update resource_groups_to_update: List[InfraResources] = ws_config.get_resources( env=target_env, infra=target_infra, order="create", ) # Track number of resource groups updated num_rgs_updated = 0 num_rgs_to_update = len(resource_groups_to_update) # Track number of resources updated num_resources_updated = 0 num_resources_to_update = 0 if num_rgs_to_update == 0: print_info("No resources to update") return logger.debug(f"Updating {num_rgs_to_update} resource groups") for rg in resource_groups_to_update: _num_resources_updated, _num_resources_to_update = rg.update_resources( group_filter=target_group, name_filter=target_name, type_filter=target_type, dry_run=dry_run, auto_confirm=auto_confirm, force=force, pull=pull, ) if _num_resources_updated > 0: num_rgs_updated += 1 num_resources_updated += _num_resources_updated num_resources_to_update += _num_resources_to_update logger.debug(f"Updated {num_resources_updated} resources in {num_rgs_updated} resource groups") if dry_run: return if num_resources_updated == 0: return print_heading(f"\n--**-- ResourceGroups updated: {num_rgs_updated}/{num_rgs_to_update}\n") workspace_event_status = "in_progress" if num_resources_updated == num_resources_to_update: workspace_event_status = "success" else: logger.error("Some resources failed to update, please check logs") workspace_event_status = "failed" if phi_config.user is not None and ws_config.ws_schema is not None and ws_config.ws_schema.id_workspace is not None: # Log workspace start event log_workspace_event( user=phi_config.user, workspace_event=WorkspaceEvent( id_workspace=ws_config.ws_schema.id_workspace, event_type="update", event_status=workspace_event_status, event_data={ "target_env": target_env, "target_infra": target_infra, "target_group": target_group, "target_name": target_name, "target_type": target_type, "dry_run": dry_run, "auto_confirm": auto_confirm, "force": force, }, ), ) def delete_workspace(phi_config: PhiCliConfig, ws_to_delete: Optional[List[Path]]) -> None: if ws_to_delete is None or len(ws_to_delete) == 0: print_heading("No workspaces to delete") return for ws_root in ws_to_delete: phi_config.delete_ws(ws_root_path=ws_root) def set_workspace_as_active(ws_dir_name: Optional[str]) -> None: from phi.cli.operator import initialize_phi ###################################################### ## 1. Validate Pre-requisites ###################################################### ###################################################### # 1.1 Check PhiConf is valid ###################################################### phi_config: Optional[PhiCliConfig] = PhiCliConfig.from_saved_config() if not phi_config: phi_config = initialize_phi() if not phi_config: log_config_not_available_msg() return ###################################################### # 1.2 Check ws_root_path is valid ###################################################### # By default, we assume this command is run from the workspace directory ws_root_path: Optional[Path] = None if ws_dir_name is None: # If the user does not provide a ws_name, that implies `phi set` is ran from # the workspace directory. ws_root_path = Path(".").resolve() else: # If the user provides a workspace name manually, we find the dir for that ws ws_config: Optional[WorkspaceConfig] = phi_config.get_ws_config_by_dir_name(ws_dir_name) if ws_config is None: logger.error(f"Could not find workspace {ws_dir_name}") return ws_root_path = ws_config.ws_root_path ws_dir_is_valid: bool = ws_root_path is not None and ws_root_path.exists() and ws_root_path.is_dir() if not ws_dir_is_valid: logger.error("Invalid workspace directory: {}".format(ws_root_path)) return ###################################################### # 1.3 Validate WorkspaceConfig is available i.e. a workspace is available at this directory ###################################################### logger.debug(f"Checking for a workspace at path: {ws_root_path}") active_ws_config: Optional[WorkspaceConfig] = phi_config.get_ws_config_by_path(ws_root_path) if active_ws_config is None: # This happens when the workspace is not yet setup print_info(f"Could not find a workspace at path: {ws_root_path}") # TODO: setup automatically for the user print_info("If this workspace has not been setup, please run `phi ws setup` from the workspace directory") return ###################################################### ## 2. Set workspace as active ###################################################### print_heading(f"Setting workspace {active_ws_config.ws_root_path.stem} as active") phi_config.set_active_ws_dir(active_ws_config.ws_root_path) print_info("Active workspace updated") return