-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add CLI Command to Delete Lightning App (#15783)
* initial work on deleting apps * after PR review * delete CLI working * restructred to make tests easier * revert manifest changes * added changelog, fix mypy issue * updates * Update src/lightning_app/cli/cmd_apps.py Co-authored-by: Jirka Borovec <[email protected]> * Update src/lightning_app/cli/lightning_cli_delete.py Co-authored-by: Jirka Borovec <[email protected]> * Update src/lightning_app/cli/lightning_cli_delete.py Co-authored-by: Jirka Borovec <[email protected]> * Update src/lightning_app/cli/lightning_cli_delete.py Co-authored-by: Sherin Thomas <[email protected]> * Update src/lightning_app/cli/lightning_cli_delete.py Co-authored-by: Sherin Thomas <[email protected]> * import typing * adding tests * finished adding tests * addressed code review comments * fix mypy error * make mypy happy * make mypy happy * make mypy happy * make mypy happy * fix windows cli Co-authored-by: Jirka Borovec <[email protected]> Co-authored-by: Sherin Thomas <[email protected]>
- Loading branch information
1 parent
6cc4933
commit b4d99e3
Showing
6 changed files
with
280 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,12 @@ | ||
from typing import Optional | ||
|
||
import click | ||
import inquirer | ||
from inquirer.themes import GreenPassion | ||
from lightning_cloud.openapi import V1ClusterType | ||
from rich.console import Console | ||
|
||
from lightning_app.cli.cmd_apps import _AppManager | ||
from lightning_app.cli.cmd_clusters import AWSClusterManager | ||
from lightning_app.cli.cmd_ssh_keys import _SSHKeyManager | ||
|
||
|
@@ -40,6 +47,168 @@ def delete_cluster(cluster: str, force: bool = False, do_async: bool = False) -> | |
cluster_manager.delete(cluster_id=cluster, force=force, do_async=do_async) | ||
|
||
|
||
def _find_cluster_for_user(app_name: str, cluster_id: Optional[str]) -> str: | ||
console = Console() | ||
cluster_manager = AWSClusterManager() | ||
|
||
default_cluster, valid_cluster_list = None, [] | ||
for cluster in cluster_manager.list_clusters(): | ||
valid_cluster_list.append(cluster.id) | ||
if cluster.spec.cluster_type == V1ClusterType.GLOBAL and default_cluster is None: | ||
default_cluster = cluster.id | ||
break | ||
|
||
# when no cluster-id is passed in, use the default (Lightning Cloud) cluster | ||
if cluster_id is None: | ||
cluster_id = default_cluster | ||
|
||
if cluster_id not in valid_cluster_list: | ||
console.print(f'[b][yellow]You don\'t have access to cluster "{cluster_id}"[/yellow][/b]') | ||
if len(valid_cluster_list) == 1: | ||
# if there are no BYOC clusters, then confirm that | ||
# the user wants to fall back to the Lightning Cloud. | ||
try: | ||
ask = [ | ||
inquirer.Confirm( | ||
"confirm", | ||
message=f'Did you mean to specify the default Lightning Cloud cluster "{default_cluster}"?', | ||
default=True, | ||
), | ||
] | ||
if inquirer.prompt(ask, theme=GreenPassion(), raise_keyboard_interrupt=True)["confirm"] is False: | ||
console.print("[b][red]Aborted![/b][/red]") | ||
raise InterruptedError | ||
except KeyboardInterrupt: | ||
console.print("[b][red]Cancelled by user![/b][/red]") | ||
raise InterruptedError | ||
cluster_id = default_cluster | ||
else: | ||
# When there are BYOC clusters, have them select which cluster to use from all available. | ||
try: | ||
ask = [ | ||
inquirer.List( | ||
"cluster", | ||
message=f'Please select which cluster app "{app_name}" should be deleted from', | ||
choices=valid_cluster_list, | ||
default=default_cluster, | ||
), | ||
] | ||
cluster_id = inquirer.prompt(ask, theme=GreenPassion(), raise_keyboard_interrupt=True)["cluster"] | ||
except KeyboardInterrupt: | ||
console.print("[b][red]Cancelled by user![/b][/red]") | ||
raise InterruptedError | ||
|
||
if cluster_id is None: | ||
# stupid mypy thing... | ||
cluster_id = "" | ||
|
||
return cluster_id | ||
|
||
|
||
def _find_selected_app_instance_id(app_name: str, cluster_id: str) -> str: | ||
console = Console() | ||
app_manager = _AppManager() | ||
|
||
all_app_names_and_ids = {} | ||
selected_app_instance_id = None | ||
|
||
for app in app_manager.list_apps(cluster_id=cluster_id): | ||
all_app_names_and_ids[app.name] = app.id | ||
# figure out the ID of some app_name on the cluster. | ||
if app_name == app.name or app_name == app.id: | ||
selected_app_instance_id = app.id | ||
break | ||
|
||
if selected_app_instance_id is None: | ||
# when there is no app with the given app_name on the cluster, | ||
# ask the user which app they would like to delete. | ||
console.print(f'[b][yellow]Cluster "{cluster_id}" does not have an app named "{app_name}"[/yellow][/b]') | ||
try: | ||
ask = [ | ||
inquirer.List( | ||
"app_name", | ||
message="Select the app name to delete", | ||
choices=list(all_app_names_and_ids.keys()), | ||
), | ||
] | ||
app_name = inquirer.prompt(ask, theme=GreenPassion(), raise_keyboard_interrupt=True)["app_name"] | ||
selected_app_instance_id = all_app_names_and_ids[app_name] | ||
except KeyboardInterrupt: | ||
console.print("[b][red]Cancelled by user![/b][/red]") | ||
raise InterruptedError | ||
|
||
return selected_app_instance_id | ||
|
||
|
||
def _delete_app_confirmation_prompt(app_name: str, cluster_id: str) -> None: | ||
console = Console() | ||
|
||
# when the --yes / -y flags were not passed, do a final | ||
# confirmation that the user wants to delete the app. | ||
try: | ||
ask = [ | ||
inquirer.Confirm( | ||
"confirm", | ||
message=f'Are you sure you want to delete app "{app_name}" on cluster "{cluster_id}"?', | ||
default=False, | ||
), | ||
] | ||
if inquirer.prompt(ask, theme=GreenPassion(), raise_keyboard_interrupt=True)["confirm"] is False: | ||
console.print("[b][red]Aborted![/b][/red]") | ||
raise InterruptedError | ||
except KeyboardInterrupt: | ||
console.print("[b][red]Cancelled by user![/b][/red]") | ||
raise InterruptedError | ||
|
||
|
||
@delete.command("app") | ||
@click.argument("app-name", type=str) | ||
@click.option( | ||
"--cluster-id", | ||
type=str, | ||
default=None, | ||
help="Delete the Lighting App from a specific Lightning AI BYOC compute cluster", | ||
) | ||
@click.option( | ||
"skip_user_confirm_prompt", | ||
"--yes", | ||
"-y", | ||
is_flag=True, | ||
default=False, | ||
help="Do not prompt for confirmation.", | ||
) | ||
def delete_app(app_name: str, cluster_id: str, skip_user_confirm_prompt: bool) -> None: | ||
"""Delete a Lightning app. | ||
Deleting an app also deletes all app websites, works, artifacts, and logs. This permanently removes any record of | ||
the app as well as all any of its associated resources and data. This does not affect any resources and data | ||
associated with other Lightning apps on your account. | ||
""" | ||
console = Console() | ||
|
||
try: | ||
cluster_id = _find_cluster_for_user(app_name=app_name, cluster_id=cluster_id) | ||
selected_app_instance_id = _find_selected_app_instance_id(app_name=app_name, cluster_id=cluster_id) | ||
if not skip_user_confirm_prompt: | ||
_delete_app_confirmation_prompt(app_name=app_name, cluster_id=cluster_id) | ||
except InterruptedError: | ||
return | ||
|
||
try: | ||
# Delete the app! | ||
app_manager = _AppManager() | ||
app_manager.delete(app_id=selected_app_instance_id) | ||
except Exception as e: | ||
console.print( | ||
f'[b][red]An issue occurred while deleting app "{app_name}. If the issue persists, please ' | ||
"reach out to us at [link=mailto:[email protected]][email protected][/link][/b][/red]." | ||
) | ||
raise click.ClickException(str(e)) | ||
|
||
console.print(f'[b][green]App "{app_name}" has been successfully deleted from cluster "{cluster_id}"![/green][/b]') | ||
return | ||
|
||
|
||
@delete.command("ssh-key") | ||
@click.argument("key_id") | ||
def remove_ssh_key(key_id: str) -> None: | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import sys | ||
from unittest import mock | ||
|
||
import pytest | ||
from lightning_cloud.openapi import Externalv1Cluster, Externalv1LightningappInstance, V1ClusterSpec, V1ClusterType | ||
|
||
from lightning_app.cli.lightning_cli_delete import _find_cluster_for_user, _find_selected_app_instance_id | ||
|
||
|
||
@pytest.mark.skipif(sys.platform == "win32", reason="currently not supported for windows.") | ||
@mock.patch("lightning_app.cli.lightning_cli_delete.AWSClusterManager.list_clusters") | ||
def test_find_cluster_for_user_when_provided_valid_cluster_id(list_clusters_mock: mock.MagicMock): | ||
list_clusters_mock.return_value = [ | ||
Externalv1Cluster( | ||
id="default", | ||
spec=V1ClusterSpec( | ||
cluster_type=V1ClusterType.GLOBAL, | ||
), | ||
), | ||
Externalv1Cluster( | ||
id="custom", | ||
spec=V1ClusterSpec( | ||
cluster_type=V1ClusterType.BYOC, | ||
), | ||
), | ||
] | ||
returned_cluster_id = _find_cluster_for_user(app_name="whatever", cluster_id="custom") | ||
assert returned_cluster_id == "custom" | ||
|
||
|
||
@pytest.mark.skipif(sys.platform == "win32", reason="currently not supported for windows.") | ||
@mock.patch("lightning_app.cli.lightning_cli_delete.AWSClusterManager.list_clusters") | ||
def test_find_cluster_for_user_without_cluster_id_uses_default(list_clusters_mock: mock.MagicMock): | ||
list_clusters_mock.return_value = [ | ||
Externalv1Cluster( | ||
id="default", | ||
spec=V1ClusterSpec( | ||
cluster_type=V1ClusterType.GLOBAL, | ||
), | ||
) | ||
] | ||
returned_cluster_id = _find_cluster_for_user(app_name="whatever", cluster_id=None) | ||
assert returned_cluster_id == "default" | ||
|
||
|
||
@pytest.mark.skipif(sys.platform == "win32", reason="currently not supported for windows.") | ||
@mock.patch("lightning_app.cli.lightning_cli_delete.AWSClusterManager.list_clusters") | ||
@mock.patch("lightning_app.cli.lightning_cli_delete.inquirer") | ||
def test_find_cluster_for_user_without_valid_cluster_id_asks_if_they_meant_to_use_valid( | ||
list_clusters_mock: mock.MagicMock, | ||
inquirer_mock: mock.MagicMock, | ||
): | ||
list_clusters_mock.return_value = [ | ||
Externalv1Cluster( | ||
id="default", | ||
spec=V1ClusterSpec( | ||
cluster_type=V1ClusterType.GLOBAL, | ||
), | ||
) | ||
] | ||
_find_cluster_for_user(app_name="whatever", cluster_id="does-not-exist") | ||
inquirer_mock.assert_called() | ||
|
||
|
||
@pytest.mark.skipif(sys.platform == "win32", reason="currently not supported for windows.") | ||
@mock.patch("lightning_app.cli.lightning_cli_delete._AppManager.list_apps") | ||
def test_app_find_selected_app_instance_id_when_app_name_exists(list_apps_mock: mock.MagicMock): | ||
list_apps_mock.return_value = [ | ||
Externalv1LightningappInstance(name="app-name", id="app-id"), | ||
] | ||
returned_app_instance_id = _find_selected_app_instance_id(app_name="app-name", cluster_id="default-cluster") | ||
assert returned_app_instance_id == "app-id" | ||
list_apps_mock.assert_called_once_with(cluster_id="default-cluster") | ||
|
||
|
||
@pytest.mark.skipif(sys.platform == "win32", reason="currently not supported for windows.") | ||
@mock.patch("lightning_app.cli.lightning_cli_delete._AppManager.list_apps") | ||
def test_app_find_selected_app_instance_id_when_app_id_exists(list_apps_mock: mock.MagicMock): | ||
list_apps_mock.return_value = [ | ||
Externalv1LightningappInstance(name="app-name", id="app-id"), | ||
] | ||
returned_app_instance_id = _find_selected_app_instance_id(app_name="app-id", cluster_id="default-cluster") | ||
assert returned_app_instance_id == "app-id" | ||
list_apps_mock.assert_called_once_with(cluster_id="default-cluster") |