diff --git a/agenta-backend/agenta_backend/models/converters.py b/agenta-backend/agenta_backend/models/converters.py index 834edd179d..731b4bda97 100644 --- a/agenta-backend/agenta_backend/models/converters.py +++ b/agenta-backend/agenta_backend/models/converters.py @@ -5,7 +5,7 @@ def app_variant_db_to_pydantic(app_variant_db: AppVariantDB, previous_variant_name: str = None) -> AppVariant: - return AppVariant(app_name=app_variant_db.app_name, variant_name=app_variant_db.variant_name, parameters=app_variant_db.parameters, previous_variant_name=previous_variant_name) + return AppVariant(app_name=app_variant_db.app_name, variant_name=app_variant_db.variant_name, parameters=app_variant_db.parameters, previous_variant_name=app_variant_db.previous_variant_name) def image_db_to_pydantic(image_db: ImageDB) -> Image: diff --git a/agenta-backend/agenta_backend/models/db_models.py b/agenta-backend/agenta_backend/models/db_models.py index 02d7783827..ee02f640ab 100644 --- a/agenta-backend/agenta_backend/models/db_models.py +++ b/agenta-backend/agenta_backend/models/db_models.py @@ -16,5 +16,5 @@ class AppVariantDB(SQLModel, table=True): variant_name: str = Field(...) image_id: int = Field(foreign_key="imagedb.id") parameters: Dict = Field(sa_column=Column(JSON)) - previous_variant_id: Optional[int] = Field(default=None, foreign_key="appvariantdb.id") - version: int = Field(default=1) + previous_variant_name: Optional[str] = Field(default=None) + is_deleted: bool = Field(default=False) # soft deletion for using the template variants diff --git a/agenta-backend/agenta_backend/routers/app_variant.py b/agenta-backend/agenta_backend/routers/app_variant.py index f0003e9e16..f8bd107020 100644 --- a/agenta-backend/agenta_backend/routers/app_variant.py +++ b/agenta-backend/agenta_backend/routers/app_variant.py @@ -6,10 +6,14 @@ from agenta_backend.config import settings from agenta_backend.models.api.api_models import URI, App, AppVariant, Image -from agenta_backend.services import db_manager, docker_utils -from fastapi import APIRouter, HTTPException, Body +from agenta_backend.services import app_manager, db_manager, docker_utils +from docker.errors import DockerException +from fastapi import APIRouter, Body, HTTPException +from sqlalchemy.exc import SQLAlchemyError router = APIRouter() +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) @router.get("/list_variants/", response_model=List[AppVariant]) @@ -42,7 +46,7 @@ async def list_apps() -> List[App]: List[App] """ try: - apps = db_manager.list_app_names() + apps = db_manager.list_apps() return apps except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -98,10 +102,7 @@ async def add_variant_from_previous(previous_app_variant: AppVariant, new_varian @router.post("/start/") async def start_variant(app_variant: AppVariant) -> URI: try: - image: Image = db_manager.get_image(app_variant) - uri: URI = docker_utils.start_container( - image_name=image.tags, app_name=app_variant.app_name, variant_name=app_variant.variant_name) - return uri + return app_manager.start_variant(app_variant) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -131,6 +132,7 @@ async def list_images(): @router.delete("/remove_variant/") async def remove_variant(app_variant: AppVariant): """Remove a variant from the server. + In the case it's the last variant using the image, stop the container and remove the image. Arguments: app_variant -- AppVariant to remove @@ -139,7 +141,33 @@ async def remove_variant(app_variant: AppVariant): HTTPException: If there is a problem removing the app variant """ try: - if not db_manager.remove_app_variant(app_variant): - raise HTTPException(status_code=404, detail="App variant not found") + app_manager.remove_app_variant(app_variant) + except SQLAlchemyError as e: + detail = f"Database error while trying to remove the app variant: {str(e)}" + raise HTTPException(status_code=500, detail=detail) + except DockerException as e: + detail = f"Docker error while trying to remove the app variant: {str(e)}" + raise HTTPException(status_code=500, detail=detail) except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + detail = f"Unexpected error while trying to remove the app variant: {str(e)}" + raise HTTPException(status_code=500, detail=detail) + + +@router.delete("/remove_app/") +async def remove_app(app: App): + """Remove app, all its variant, containers and images + + Arguments: + app -- App to remove + """ + try: + app_manager.remove_app(app) + except SQLAlchemyError as e: + detail = f"Database error while trying to remove the app: {str(e)}" + raise HTTPException(status_code=500, detail=detail) + except DockerException as e: + detail = f"Docker error while trying to remove the app: {str(e)}" + raise HTTPException(status_code=500, detail=detail) + except Exception as e: + detail = f"Unexpected error while trying to remove the app: {str(e)}" + raise HTTPException(status_code=500, detail=detail) diff --git a/agenta-backend/agenta_backend/services/app_manager.py b/agenta-backend/agenta_backend/services/app_manager.py new file mode 100644 index 0000000000..edc603b30a --- /dev/null +++ b/agenta-backend/agenta_backend/services/app_manager.py @@ -0,0 +1,124 @@ +"""Main Business logic +""" + +from agenta_backend.services import (db_manager, docker_utils) +from agenta_backend.models.api.api_models import (AppVariant, Image, URI, App) +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def remove_app_variant(app_variant: AppVariant): + """Removes appvariant from db, if it is the last one using an image, then + deletes the image from the db, shutdowns the container, deletes it and remove + the image from the registry + + Arguments: + app_variant -- the app variant to remove + """ + # checks if it is the last app variant using its image + try: + app_variant_db = db_manager.get_variant_from_db(app_variant) + except Exception as e: + logger.error(f"Error fetching app variant from the database: {str(e)}") + raise + + if app_variant_db is None: + msg = f"App variant {app_variant.app_name}/{app_variant.variant_name} not found in DB" + logger.error(msg) + raise ValueError(msg) + else: + try: + if db_manager.check_is_last_variant(app_variant_db): + image: Image = db_manager.get_image(app_variant) + try: + container_ids = docker_utils.stop_containers_based_on_image(image) + logger.info(f"Containers {container_ids} stopped") + for container_id in container_ids: + docker_utils.delete_container(container_id) + logger.info(f"Container {container_id} deleted") + except Exception as e: + logger.error(f"Error managing Docker resources: {str(e)}") + raise + try: + docker_utils.delete_image(image) + logger.info(f"Image {image.tags} deleted") + except: + logger.warning(f"Warning: Error deleting image {image.tags}. Probably multiple variants using it.") + db_manager.remove_image(image) + db_manager.remove_app_variant(app_variant) + except Exception as e: + logger.error(f"Error deleting app variant: {str(e)}") + raise + + +def remove_app(app: App): + """Removes all app variants from db, if it is the last one using an image, then + deletes the image from the db, shutdowns the container, deletes it and remove + the image from the registry + + Arguments: + app_name -- the app name to remove + """ + # checks if it is the last app variant using its image + app_name = app.app_name + if app_name not in [app.app_name for app in db_manager.list_apps()]: + msg = f"App {app_name} not found in DB" + logger.error(msg) + raise ValueError(msg) + try: + app_variants = db_manager.list_app_variants(app_name=app_name, show_soft_deleted=True) + except Exception as e: + logger.error(f"Error fetching app variants from the database: {str(e)}") + raise + if app_variants is None: + msg = f"App {app_name} not found in DB" + logger.error(msg) + raise ValueError(msg) + else: + try: + for app_variant in app_variants: + remove_app_variant(app_variant) + except Exception as e: + logger.error(f"Error deleting app variants: {str(e)}") + raise + + +def start_variant(app_variant: AppVariant) -> URI: + """ + Starts a Docker container for a given app variant. + + Fetches the associated image from the database and delegates to a Docker utility function + to start the container. The URI of the started container is returned. + + Args: + app_variant (AppVariant): The app variant for which a container is to be started. + + Returns: + URI: The URI of the started Docker container. + + Raises: + ValueError: If the app variant does not have a corresponding image in the database. + RuntimeError: If there is an error starting the Docker container. + """ + try: + image: Image = db_manager.get_image(app_variant) + except Exception as e: + logger.error( + f"Error fetching image for app variant {app_variant.app_name}/{app_variant.variant_name} from database: {str(e)}") + raise ValueError( + f"Image for app variant {app_variant.app_name}/{app_variant.variant_name} not found in database") from e + + try: + uri: URI = docker_utils.start_container( + image_name=image.tags, app_name=app_variant.app_name, variant_name=app_variant.variant_name) + logger.info( + f"Started Docker container for app variant {app_variant.app_name}/{app_variant.variant_name} at URI {uri}") + except Exception as e: + logger.error( + f"Error starting Docker container for app variant {app_variant.app_name}/{app_variant.variant_name}: {str(e)}") + raise RuntimeError( + f"Failed to start Docker container for app variant {app_variant.app_name}/{app_variant.variant_name}") from e + + return uri diff --git a/agenta-backend/agenta_backend/services/db_manager.py b/agenta-backend/agenta_backend/services/db_manager.py index 8ed50c2bb0..fcb843e1e8 100644 --- a/agenta-backend/agenta_backend/services/db_manager.py +++ b/agenta-backend/agenta_backend/services/db_manager.py @@ -5,14 +5,18 @@ from agenta_backend.models.converters import (app_variant_db_to_pydantic, image_db_to_pydantic) from agenta_backend.models.db_models import AppVariantDB, ImageDB +from agenta_backend.services import helpers from sqlmodel import Session, SQLModel, create_engine, func, and_ - +import logging # SQLite database connection DATABASE_URL = os.environ["DATABASE_URL"] engine = create_engine(DATABASE_URL) # Create tables if they don't exist SQLModel.metadata.create_all(engine) +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + def get_session(): """Returns a session to the database @@ -24,34 +28,6 @@ def get_session(): yield session -def add_app_variant(app_variant: AppVariant, image: Image): - """Adds a new app variant to the db. - First adds an app variant, then adds the image to the db and links it to the app variant - - Arguments: - app_variant -- AppVariant to add - image -- The Image associated with the app variant - """ - if app_variant is None or image is None or app_variant.app_name in [None, ""] or app_variant.variant_name in [None, ""] or image.docker_id in [None, ""] or image.tags in [None, ""]: - raise ValueError("App variant or image is None") - already_exists = any([av for av in list_app_variants() if av.app_name == - app_variant.app_name and av.variant_name == app_variant.variant_name]) - if already_exists: - raise ValueError("App variant already exists") - with Session(engine) as session: - # Add image - db_image = ImageDB(**image.dict()) - session.add(db_image) - session.commit() - session.refresh(db_image) - # Add app variant and link it to the app variant - db_app_variant = AppVariantDB( - image_id=db_image.id, **app_variant.dict()) - session.add(db_app_variant) - session.commit() - session.refresh(db_app_variant) - - def add_variant_based_on_image(app_variant: AppVariant, image: Image): """Adds an app variant based on an image. This the functionality called by the cli. Currently we are not using the parameters field, but it is there for future use. @@ -63,11 +39,12 @@ def add_variant_based_on_image(app_variant: AppVariant, image: Image): Raises: ValueError: if variant exists or missing inputs """ + clean_soft_deleted_variants() if app_variant is None or image is None or app_variant.app_name in [None, ""] or app_variant.variant_name in [None, ""] or image.docker_id in [None, ""] or image.tags in [None, ""]: raise ValueError("App variant or image is None") if app_variant.parameters is not None: raise ValueError("Parameters are not supported when adding based on image") - already_exists = any([av for av in list_app_variants() if av.app_name == + already_exists = any([av for av in list_app_variants(show_soft_deleted=True) if av.app_name == app_variant.app_name and av.variant_name == app_variant.variant_name]) if already_exists: raise ValueError("App variant with the same name already exists") @@ -97,6 +74,7 @@ def add_variant_based_on_previous(previous_app_variant: AppVariant, new_variant_ Raises: ValueError: _description_ """ + clean_soft_deleted_variants() if previous_app_variant is None or previous_app_variant.app_name in [None, ""] or previous_app_variant.variant_name in [None, ""]: raise ValueError("App variant is None") if parameters is None: @@ -108,12 +86,13 @@ def add_variant_based_on_previous(previous_app_variant: AppVariant, new_variant_ (AppVariantDB.app_name == previous_app_variant.app_name) & (AppVariantDB.variant_name == previous_app_variant.variant_name)).first() if template_variant is None: + print_all() raise ValueError("Template app variant not found") - elif template_variant.previous_variant_id is not None: + elif template_variant.previous_variant_name is not None: raise ValueError( "Template app variant is not a template, it is a forked variant itself") - already_exists = any([av for av in list_app_variants() if av.app_name == + already_exists = any([av for av in list_app_variants(show_soft_deleted=True) if av.app_name == previous_app_variant.app_name and av.variant_name == new_variant_name]) if already_exists: raise ValueError("App variant with the same name already exists") @@ -124,59 +103,51 @@ def add_variant_based_on_previous(previous_app_variant: AppVariant, new_variant_ variant_name=new_variant_name, image_id=template_variant.image_id, parameters=parameters, - previous_variant_id=template_variant.id, - version=template_variant.version + 1) + previous_variant_name=template_variant.variant_name) session.add(db_app_variant) session.commit() session.refresh(db_app_variant) -def list_app_variants(app_name: str = None) -> List[AppVariant]: +def list_app_variants(app_name: str = None, show_soft_deleted=False) -> List[AppVariant]: """ - Lists all the app variants from the db, only latest versions - TODO: TEST THIS - + Lists all the app variants from the db + Args: + app_name: if specified, only returns the variants for the app name + show_soft_deleted: if true, returns soft deleted variants as well + Returns: + List[AppVariant]: List of AppVariant objects """ - + clean_soft_deleted_variants() with Session(engine) as session: query = session.query(AppVariantDB) + if not show_soft_deleted: + query = query.filter(AppVariantDB.is_deleted == False) if app_name is not None: query = query.filter(AppVariantDB.app_name == app_name) - # Get latest versions only - subquery = session.query(AppVariantDB.app_name, AppVariantDB.variant_name, func.max(AppVariantDB.version).label("max_version"))\ - .group_by(AppVariantDB.app_name, AppVariantDB.variant_name).subquery() + subquery = session.query(AppVariantDB.app_name, AppVariantDB.variant_name).group_by( + AppVariantDB.app_name, AppVariantDB.variant_name).subquery() query = query.join(subquery, and_(AppVariantDB.app_name == subquery.c.app_name, - AppVariantDB.variant_name == subquery.c.variant_name, - AppVariantDB.version == subquery.c.max_version)) + AppVariantDB.variant_name == subquery.c.variant_name)) app_variants_db: List[AppVariantDB] = query.all() # Include previous variant name app_variants: List[AppVariant] = [] for av in app_variants_db: - if av.previous_variant_id is None: - app_variant = app_variant_db_to_pydantic(av) - else: - previous_variant = session.query(AppVariantDB).filter(AppVariantDB.id == av.previous_variant_id).first() - - if previous_variant: - app_variant = app_variant_db_to_pydantic(av, previous_variant.variant_name) - else: - raise ValueError("Previous variant not found!!") + app_variant = app_variant_db_to_pydantic(av) app_variants.append(app_variant) - return app_variants -def list_app_names() -> List[App]: +def list_apps() -> List[App]: """ Lists all the unique app names from the database """ - + clean_soft_deleted_variants() with Session(engine) as session: app_names = session.query(AppVariantDB.app_name).distinct().all() - # Unpack tuples to create a list of strings instead of a list of tuples return [App(app_name=name) for (name,) in app_names] @@ -193,7 +164,7 @@ def get_image(app_variant: AppVariant) -> Image: with Session(engine) as session: db_app_variant: AppVariantDB = session.query(AppVariantDB).filter( - (AppVariantDB.app_name == app_variant.app_name) & (AppVariantDB.variant_name == app_variant.variant_name)).order_by(AppVariantDB.version.desc()).first() + (AppVariantDB.app_name == app_variant.app_name) & (AppVariantDB.variant_name == app_variant.variant_name)).first() if db_app_variant: image_db: ImageDB = session.query(ImageDB).filter( ImageDB.id == db_app_variant.image_id).first() @@ -202,32 +173,112 @@ def get_image(app_variant: AppVariant) -> Image: raise Exception("App variant not found") -def remove_app_variant(app_variant: AppVariant) -> bool: - """Remove an app variant and its associated image from the db - in case it is the only variant in the db using the image +def remove_app_variant(app_variant: AppVariant): + """Remove an app variant from the db + the logic for removing the image is in app_manager.py Arguments: app_variant -- AppVariant to remove + """ + if app_variant is None or app_variant.app_name in [None, ""] or app_variant.variant_name in [None, ""]: + raise ValueError("App variant is None") + with Session(engine) as session: + app_variant_db = session.query(AppVariantDB).filter( + (AppVariantDB.app_name == app_variant.app_name) & (AppVariantDB.variant_name == app_variant.variant_name)).first() + if app_variant_db is None: + raise ValueError("App variant not found") + + if app_variant_db.previous_variant_name is not None: # forked variant + session.delete(app_variant_db) + elif check_is_last_variant(app_variant_db): # last variant using the image, okay to delete + session.delete(app_variant_db) + else: + app_variant_db.is_deleted = True # soft deletion + session.commit() + + +def remove_image(image: Image): + """Remove image from db based on pydantic class + + Arguments: + image -- Image to remove + """ + if image is None or image.docker_id in [None, ""] or image.tags in [None, ""]: + raise ValueError("Image is None") + with Session(engine) as session: + image_db = session.query(ImageDB).filter( + (ImageDB.docker_id == image.docker_id) & (ImageDB.tags == image.tags)).first() + if image_db is None: + raise ValueError("Image not found") + session.delete(image_db) + session.commit() + + +def check_is_last_variant(db_app_variant: AppVariantDB) -> bool: + """Checks whether the input variant is the sole variant that uses its linked image + This is a helpful function to determine whether to delete the image when removing a variant + Usually many variants will use the same image (these variants would have been created using the UI) + We only delete the image and shutdown the container if the variant is the last one using the image + + Arguments: + app_variant -- AppVariant to check Returns: - bool -- True if the app variant was removed, False otherwise + true if it's the last variant, false otherwise """ + with Session(engine) as session: + # If it's the only variant left that uses the image, delete the image + if session.query(AppVariantDB).filter(AppVariantDB.image_id == db_app_variant.image_id).count() == 1: + return True + else: + return False + + +def get_variant_from_db(app_variant: AppVariant) -> AppVariantDB: + """Checks whether the app variant exists in our db + and returns the AppVariantDB object if it does + + Arguments: + app_variant -- AppVariant to check + Returns: + AppVariantDB -- The AppVariantDB object if it exists, None otherwise + """ with Session(engine) as session: # Find app_variant in the database db_app_variant: AppVariantDB = session.query(AppVariantDB).filter( - (AppVariantDB.app_name == app_variant.app_name) & (AppVariantDB.variant_name == app_variant.variant_name)).order_by(AppVariantDB.version.desc()).first() + (AppVariantDB.app_name == app_variant.app_name) & (AppVariantDB.variant_name == app_variant.variant_name)).first() + logger.info(f"Found app variant: {db_app_variant}") if db_app_variant: - # If it's the original variant, delete associated image - if session.query(AppVariantDB).filter(AppVariantDB.image_id == db_app_variant.image_id).count() == 1: - db_image: ImageDB = session.query(ImageDB).filter( - ImageDB.id == db_app_variant.image_id).first() - if db_image: - session.delete(db_image) - else: - raise Exception("Image for app not found") - # Delete app_variant - session.delete(db_app_variant) - session.commit() - return True + return db_app_variant else: - return False + return None + + +def print_all(): + """Prints all the tables in the database + """ + with Session(engine) as session: + for app_variant in session.query(AppVariantDB).all(): + helpers.print_app_variant(app_variant) + for image in session.query(ImageDB).all(): + helpers.print_image(image) + + +def clean_soft_deleted_variants(): + """Remove soft-deleted app variants if their image is not used by any existing variant. + """ + with Session(engine) as session: + # Get all soft-deleted app variants + soft_deleted_variants: List[AppVariantDB] = session.query( + AppVariantDB).filter(AppVariantDB.is_deleted == True).all() + + for variant in soft_deleted_variants: + # Get non-deleted variants that use the same image + image_used = session.query(AppVariantDB).filter( + (AppVariantDB.image_id == variant.image_id) & (AppVariantDB.is_deleted == False)).first() + + # If the image is not used by any non-deleted variant, delete the variant + if image_used is None: + session.delete(variant) + + session.commit() diff --git a/agenta-backend/agenta_backend/services/docker_utils.py b/agenta-backend/agenta_backend/services/docker_utils.py index 230a7dd0d8..ea136cf1e3 100644 --- a/agenta-backend/agenta_backend/services/docker_utils.py +++ b/agenta-backend/agenta_backend/services/docker_utils.py @@ -3,10 +3,14 @@ import docker from agenta_backend.config import settings from agenta_backend.models.api.api_models import AppVariant, Image, URI - +import logging client = docker.from_env() +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + def port_generator(start_port=9000): port = start_port @@ -49,41 +53,65 @@ def start_container(image_name, app_name, variant_name) -> URI: f"traefik.http.routers.{app_name}-{variant_name}.service": f"{app_name}-{variant_name}", } container = client.containers.run( - image, detach=True, labels=labels, network="agenta-network") + image, detach=True, labels=labels, network="agenta-network", name=f"{app_name}-{variant_name}") return URI(uri=f"http://localhost/{app_name}/{variant_name}") -def stop_container(container_id): - container = client.containers.get(container_id) - response = container.stop() - return response - - -def delete_container(container_id): - container = client.containers.get(container_id) - response = container.remove() - return response - +def stop_containers_based_on_image(image: Image) -> List[str]: + """Stops all the containers that use a certain image -# def list_images(): -# images = client.images.list() -# return images + Arguments: + image -- Image containing the docker id + Raises: + RuntimeError: _description_ -# def pull_image(image_name, tag="latest"): -# image = client.images.pull( -# f"{settings.docker_registry_url}/{image_name}:{tag}") -# return image + Returns: + The container ids of the stopped containers + """ + stopped_container_ids = [] + for container in client.containers.list(all=True): + if container.image.id == image.docker_id: + try: + container.stop() + stopped_container_ids.append(container.id) + logger.info(f'Stopped container with id: {container.id}') + except docker.errors.APIError as ex: + logger.error(f'Error stopping container with id: {container.id}. Error: {str(ex)}') + raise RuntimeError(f'Error stopping container with id: {container.id}') from ex + return stopped_container_ids + + +def delete_container(container_id: str): + """Delete a container based on its id + + Arguments: + container_id -- _description_ + + Raises: + RuntimeError: _description_ + """ + try: + container = client.containers.get(container_id) + container.remove() + logger.info(f'Deleted container with id: {container.id}') + except docker.errors.APIError as ex: + logger.error(f'Error deleting container with id: {container.id}. Error: {str(ex)}') + raise RuntimeError(f'Error deleting container with id: {container.id}') from ex -# def push_image(image_name, tag="latest"): -# image = client.images.get(f"{image_name}:{tag}") -# response = client.images.push( -# f"{settings.docker_registry_url}/{image_name}", tag=tag) -# return response +def delete_image(image: Image): + """Delete an image based on its id + Arguments: + image -- _description_ -# def delete_image(image_name, tag="latest"): -# image = client.images.get(f"{image_name}:{tag}") -# response = client.images.remove(image.id) -# return response + Raises: + RuntimeError: _description_ + """ + try: + client.images.remove(image.docker_id) + logger.info(f'Deleted image with id: {image.docker_id}') + except docker.errors.APIError as ex: + logger.error(f'Error deleting image with id: {image.docker_id}. Error: {str(ex)}') + raise RuntimeError(f'Error deleting image with id: {image.docker_id}') from ex diff --git a/agenta-backend/agenta_backend/services/helpers.py b/agenta-backend/agenta_backend/services/helpers.py new file mode 100644 index 0000000000..55c2ddf66a --- /dev/null +++ b/agenta-backend/agenta_backend/services/helpers.py @@ -0,0 +1,17 @@ + +def print_app_variant(app_variant): + print(f"App Variant ID: {app_variant.id}") + print(f"App Variant Name: {app_variant.variant_name}") + print(f"App Name: {app_variant.app_name}") + print(f"Image ID: {app_variant.image_id}") + print(f"Parameters: {app_variant.parameters}") + print(f"Previous Variant Name: {app_variant.previous_variant_name}") + print(f"Is Deleted: {app_variant.is_deleted}") + print("------------------------") + + +def print_image(image): + print(f"Image ID: {image.id}") + print(f"Docker ID: {image.docker_id}") + print(f"Tags: {image.tags}") + print("------------------------") diff --git a/agenta-backend/tests/manual_tests.py b/agenta-backend/tests/manual_tests.py index ea631d55c2..d953a857b0 100644 --- a/agenta-backend/tests/manual_tests.py +++ b/agenta-backend/tests/manual_tests.py @@ -2,11 +2,13 @@ import docker from agenta_backend.config import settings -from agenta_backend.services.docker_utils import list_images, start_container, stop_container, delete_container -from agenta_backend.services.db_manager import add_app_variant, list_app_variants, get_image -from agenta_backend.models.api_models import AppVariant, Image, URI +from agenta_backend.services import app_manager +from agenta_backend.services import db_manager +from agenta_backend.models.api.api_models import AppVariant, Image, URI -client = docker.from_env() -uri = start_container("agenta-server/clitest", "clitest") - -print(uri) +db_manager.print_all() +app_manager.remove_app("baby_name_generator") +# app = AppVariant(app_name="baby_name_generator", variant_name="v0") +# app_manager.remove_app_variant(app) +# app = AppVariant(app_name="baby_name_generator", variant_name="v0.2") +# app_manager.remove_app_variant(app) diff --git a/agenta-backend/tests/test_db_manager.py b/agenta-backend/tests/test_db_manager.py index 8f7d84db4e..26a16bf928 100644 --- a/agenta-backend/tests/test_db_manager.py +++ b/agenta-backend/tests/test_db_manager.py @@ -5,11 +5,12 @@ from agenta_backend.models.api.api_models import App, AppVariant, Image from agenta_backend.services.db_manager import (add_variant_based_on_image, engine, get_image, get_session, - list_app_names, + list_apps, list_app_variants, remove_app_variant, - add_variant_based_on_previous) + add_variant_based_on_previous, print_all) from sqlmodel import Session +from time import sleep @pytest.fixture(autouse=True) @@ -89,7 +90,8 @@ def test_add_same_app_variant_twice(app_variant, image): def test_remove_non_existent_app_variant(): non_existent_app_variant = AppVariant( app_name=random_string(), variant_name=random_string()) - remove_app_variant(non_existent_app_variant) # Should not raise an error + with pytest.raises(ValueError): + remove_app_variant(non_existent_app_variant) # Should not raise an error assert len(list_app_variants()) == 0 @@ -161,7 +163,7 @@ def test_filter_by_app_name(image: Image): assert app_variant.app_name == app_name -def test_list_app_names(image): +def test_list_apps(image): # Assuming you have a setUp function that clears the database before each test app_name1 = 'test_app' app_name2 = 'other_app' @@ -176,8 +178,8 @@ def test_list_app_names(image): variant_name = ''.join(choice(ascii_letters) for _ in range(10)) add_variant_based_on_image(AppVariant(app_name=app_name2, variant_name=variant_name), image) - # Check that list_app_names returns all unique app names - app_names = list_app_names() + # Check that list_apps returns all unique app names + app_names = list_apps() assert len(app_names) == 2 assert App(app_name=app_name1) in app_names assert App(app_name=app_name2) in app_names @@ -269,16 +271,20 @@ def test_add_remove_chain_of_variants(app_variant, image): # Add another variant based on the previous one new_variant2 = AppVariant(app_name=app_variant.app_name, variant_name=random_string(), parameters={'param1': 'value2', 'param2': 20}) - add_variant_based_on_previous(previous_app_variant=new_variant1, + with pytest.raises(ValueError): + add_variant_based_on_previous(previous_app_variant=new_variant1, + new_variant_name=new_variant2.variant_name, + parameters=new_variant2.parameters) + add_variant_based_on_previous(previous_app_variant=app_variant, new_variant_name=new_variant2.variant_name, parameters=new_variant2.parameters) + assert get_image(new_variant1) is not None # Remove original variant remove_app_variant(app_variant) # Image should still exist as other variants are using it - with pytest.raises(Exception): - get_image(app_variant) + get_image(app_variant) assert get_image(new_variant1) is not None @@ -296,3 +302,48 @@ def test_add_remove_chain_of_variants(app_variant, image): # Now the image should not exist as all variants using it have been removed with pytest.raises(Exception): get_image(new_variant2) + + +def test_add_variant_based_on_previous(app_variant, app_variant2, image): + add_variant_based_on_image(app_variant, image) + parameters = {"key": "value"} + add_variant_based_on_previous(previous_app_variant=app_variant, + new_variant_name=app_variant2.variant_name, parameters=parameters) + app_variants = list_app_variants() + assert len(app_variants) == 2 + assert app_variants[1].app_name == app_variant.app_name + assert app_variants[1].variant_name == app_variant2.variant_name + + +def test_remove_app_variant_and_check_soft_deletion(app_variant, app_variant2, image): + add_variant_based_on_image(app_variant, image) + parameters = {"key": "value"} + add_variant_based_on_previous(app_variant, app_variant2.variant_name, parameters) + remove_app_variant(app_variant) + app_variants = list_app_variants(show_soft_deleted=True) + assert len(app_variants) == 2 + app_variants = list_app_variants() + assert len(app_variants) == 1 + + +def test_add_variant_after_remove(app_variant, app_variant2, image): + add_variant_based_on_image(app_variant, image) + remove_app_variant(app_variant) + parameters = {"key": "value"} + with pytest.raises(ValueError): + add_variant_based_on_previous(previous_app_variant=app_variant, + new_variant_name=app_variant2.variant_name, parameters=parameters) + + +def test_add_variant_based_on_previous_with_soft_deleted_variant(app_variant, app_variant2, image): + add_variant_based_on_image(app_variant, image) + parameters = {"key": "value"} + add_variant_based_on_previous(app_variant, app_variant2.variant_name+"2", parameters) + remove_app_variant(app_variant) + + add_variant_based_on_previous(app_variant, app_variant2.variant_name, parameters) + app_variants = list_app_variants() + print_all() + assert len(app_variants) == 2 + assert app_variants[1].app_name == app_variant.app_name + assert app_variants[1].variant_name == app_variant2.variant_name diff --git a/agenta-backend/tests/test_router_app_variant.py b/agenta-backend/tests/test_router_app_variant.py index 5ac4decb4d..263f79fd6c 100644 --- a/agenta-backend/tests/test_router_app_variant.py +++ b/agenta-backend/tests/test_router_app_variant.py @@ -6,7 +6,7 @@ import pytest from agenta_backend.main import app from agenta_backend.models.api.api_models import AppVariant, Image -from agenta_backend.services.db_manager import (add_app_variant, engine, +from agenta_backend.services.db_manager import (add_variant_based_on_image, engine, get_image, get_session, list_app_variants, remove_app_variant) @@ -79,7 +79,7 @@ def test_list_app_variant(): def test_list_app_variant_after_manual_add(app_variant, image): # This is the function from db_manager.py - add_app_variant(app_variant, image) + add_variant_based_on_image(app_variant, image) response = client.get("/app_variant/list_variants/") assert response.status_code == 200 assert len(response.json()) == 1 diff --git a/agenta-web/src/components/AppSelector/AppCard.tsx b/agenta-web/src/components/AppSelector/AppCard.tsx new file mode 100644 index 0000000000..c6d3e163be --- /dev/null +++ b/agenta-web/src/components/AppSelector/AppCard.tsx @@ -0,0 +1,85 @@ +import { Modal, Tooltip, Card } from 'antd'; +import { DeleteOutlined } from '@ant-design/icons'; +import { removeApp } from '@/lib/services/api'; +import useSWR, { mutate } from 'swr'; +import { useState } from 'react'; +import Link from 'next/link'; + +const DeleteModal = ({ visible, handleOk, handleCancel, appName, confirmLoading }) => { + return ( + +

Are you sure you want to delete {appName}?

+
+ ); +}; + +const AppCard: React.FC = ({ appName }) => { + console.log("AppCard", appName); + const [visibleDelete, setVisibleDelete] = useState(false); + const [confirmLoading, setConfirmLoading] = useState(false); // add this line + const showDeleteModal = () => { + setVisibleDelete(true); + }; + + const handleDeleteOk = async () => { + setConfirmLoading(true); // add this line + await removeApp(appName); + setVisibleDelete(false); + setConfirmLoading(false); // add this line + mutate('http://localhost/api/app_variant/list_apps/'); + }; + + const handleDeleteCancel = () => { + setVisibleDelete(false); + }; + + return ( + <> + + + + + ]} + > + {appName}} + /> + + + + + ); +}; + +export default AppCard; \ No newline at end of file diff --git a/agenta-web/src/components/AppSelector/AppSelector.tsx b/agenta-web/src/components/AppSelector/AppSelector.tsx index f20b35180f..b908daa6ef 100644 --- a/agenta-web/src/components/AppSelector/AppSelector.tsx +++ b/agenta-web/src/components/AppSelector/AppSelector.tsx @@ -1,32 +1,30 @@ -// components/AppSelector.tsx -import { useContext, useState } from 'react'; +import { useState } from 'react'; import { useRouter } from 'next/router'; -import { Button, Input, Row, Space, Col, Modal, Tag, Tooltip, Card } from 'antd'; -import { EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { Input, Space, Modal } from 'antd'; import useSWR from 'swr' -import Link from 'next/link'; +import AppCard from './AppCard'; const fetcher = (...args) => fetch(...args).then(res => res.json()) -const AppSelector = () => { + +const AppSelector: React.FC = () => { const [newApp, setNewApp] = useState(''); const router = useRouter(); const [isModalOpen, setIsModalOpen] = useState(false); - const showModal = () => { + const showAddModal = () => { setIsModalOpen(true); }; - const handleOk = () => { + const handleAddOk = () => { setIsModalOpen(false); - // handleNavToApp(newApp); }; - const handleCancel = () => { + const handleAddCancel = () => { setIsModalOpen(false); }; - const [cards, setCards] = useState(["pitch_genius"]); // initial state with one card + const { data, error, isLoading } = useSWR('http://localhost/api/app_variant/list_apps/', fetcher) if (error) return
failed to load
if (isLoading) return
loading...
@@ -35,76 +33,12 @@ const AppSelector = () => { return (
- {data.map((app, index) => ( - - - - , - - - - ]} - > - {app.app_name}
} - /> - - + {data.map((app: any, index: number) => ( + ))} - - - - New app - soon - - } - /> - - - {/* */} - +