Skip to content

Commit b4d99e3

Browse files
rlizzoBordaSherin Thomas
authored
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]>
1 parent 6cc4933 commit b4d99e3

File tree

6 files changed

+280
-0
lines changed

6 files changed

+280
-0
lines changed

src/lightning_app/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
99
### Added
1010

1111
- Added the CLI command `lightning run model` to launch a `LightningLite` accelerated script ([#15506](https://github.com/Lightning-AI/lightning/pull/15506))
12+
- Added the CLI command `lightning delete app` to delete a lightning app on the cloud ([#15783](https://github.com/Lightning-AI/lightning/pull/15783))
1213

1314
- Show a message when `BuildConfig(requirements=[...])` is passed but a `requirements.txt` file is already present in the Work ([#15799](https://github.com/Lightning-AI/lightning/pull/15799))
1415
- Show a message when `BuildConfig(dockerfile="...")` is passed but a `Dockerfile` file is already present in the Work ([#15799](https://github.com/Lightning-AI/lightning/pull/15799))

src/lightning_app/cli/cmd_apps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ def list(self, cluster_id: str = None, limit: int = 100) -> None:
6767
console = Console()
6868
console.print(_AppList(self.list_apps(cluster_id=cluster_id, limit=limit)).as_table())
6969

70+
def delete(self, app_id: str) -> None:
71+
project = _get_project(self.api_client)
72+
self.api_client.lightningapp_instance_service_delete_lightningapp_instance(
73+
project_id=project.project_id,
74+
id=app_id,
75+
)
76+
7077

7178
class _AppList(Formatable):
7279
def __init__(self, apps: List[Externalv1LightningappInstance]) -> None:

src/lightning_app/cli/cmd_clusters.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ def create(
174174
except KeyboardInterrupt:
175175
click.echo(background_message)
176176

177+
def list_clusters(self) -> List[Externalv1Cluster]:
178+
resp = self.api_client.cluster_service_list_clusters(phase_not_in=[V1ClusterState.DELETED])
179+
return resp.clusters
180+
177181
def get_clusters(self) -> ClusterList:
178182
resp = self.api_client.cluster_service_list_clusters(phase_not_in=[V1ClusterState.DELETED])
179183
return ClusterList(resp.clusters)

src/lightning_app/cli/lightning_cli_delete.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
from typing import Optional
2+
13
import click
4+
import inquirer
5+
from inquirer.themes import GreenPassion
6+
from lightning_cloud.openapi import V1ClusterType
7+
from rich.console import Console
28

9+
from lightning_app.cli.cmd_apps import _AppManager
310
from lightning_app.cli.cmd_clusters import AWSClusterManager
411
from lightning_app.cli.cmd_ssh_keys import _SSHKeyManager
512

@@ -40,6 +47,168 @@ def delete_cluster(cluster: str, force: bool = False, do_async: bool = False) ->
4047
cluster_manager.delete(cluster_id=cluster, force=force, do_async=do_async)
4148

4249

50+
def _find_cluster_for_user(app_name: str, cluster_id: Optional[str]) -> str:
51+
console = Console()
52+
cluster_manager = AWSClusterManager()
53+
54+
default_cluster, valid_cluster_list = None, []
55+
for cluster in cluster_manager.list_clusters():
56+
valid_cluster_list.append(cluster.id)
57+
if cluster.spec.cluster_type == V1ClusterType.GLOBAL and default_cluster is None:
58+
default_cluster = cluster.id
59+
break
60+
61+
# when no cluster-id is passed in, use the default (Lightning Cloud) cluster
62+
if cluster_id is None:
63+
cluster_id = default_cluster
64+
65+
if cluster_id not in valid_cluster_list:
66+
console.print(f'[b][yellow]You don\'t have access to cluster "{cluster_id}"[/yellow][/b]')
67+
if len(valid_cluster_list) == 1:
68+
# if there are no BYOC clusters, then confirm that
69+
# the user wants to fall back to the Lightning Cloud.
70+
try:
71+
ask = [
72+
inquirer.Confirm(
73+
"confirm",
74+
message=f'Did you mean to specify the default Lightning Cloud cluster "{default_cluster}"?',
75+
default=True,
76+
),
77+
]
78+
if inquirer.prompt(ask, theme=GreenPassion(), raise_keyboard_interrupt=True)["confirm"] is False:
79+
console.print("[b][red]Aborted![/b][/red]")
80+
raise InterruptedError
81+
except KeyboardInterrupt:
82+
console.print("[b][red]Cancelled by user![/b][/red]")
83+
raise InterruptedError
84+
cluster_id = default_cluster
85+
else:
86+
# When there are BYOC clusters, have them select which cluster to use from all available.
87+
try:
88+
ask = [
89+
inquirer.List(
90+
"cluster",
91+
message=f'Please select which cluster app "{app_name}" should be deleted from',
92+
choices=valid_cluster_list,
93+
default=default_cluster,
94+
),
95+
]
96+
cluster_id = inquirer.prompt(ask, theme=GreenPassion(), raise_keyboard_interrupt=True)["cluster"]
97+
except KeyboardInterrupt:
98+
console.print("[b][red]Cancelled by user![/b][/red]")
99+
raise InterruptedError
100+
101+
if cluster_id is None:
102+
# stupid mypy thing...
103+
cluster_id = ""
104+
105+
return cluster_id
106+
107+
108+
def _find_selected_app_instance_id(app_name: str, cluster_id: str) -> str:
109+
console = Console()
110+
app_manager = _AppManager()
111+
112+
all_app_names_and_ids = {}
113+
selected_app_instance_id = None
114+
115+
for app in app_manager.list_apps(cluster_id=cluster_id):
116+
all_app_names_and_ids[app.name] = app.id
117+
# figure out the ID of some app_name on the cluster.
118+
if app_name == app.name or app_name == app.id:
119+
selected_app_instance_id = app.id
120+
break
121+
122+
if selected_app_instance_id is None:
123+
# when there is no app with the given app_name on the cluster,
124+
# ask the user which app they would like to delete.
125+
console.print(f'[b][yellow]Cluster "{cluster_id}" does not have an app named "{app_name}"[/yellow][/b]')
126+
try:
127+
ask = [
128+
inquirer.List(
129+
"app_name",
130+
message="Select the app name to delete",
131+
choices=list(all_app_names_and_ids.keys()),
132+
),
133+
]
134+
app_name = inquirer.prompt(ask, theme=GreenPassion(), raise_keyboard_interrupt=True)["app_name"]
135+
selected_app_instance_id = all_app_names_and_ids[app_name]
136+
except KeyboardInterrupt:
137+
console.print("[b][red]Cancelled by user![/b][/red]")
138+
raise InterruptedError
139+
140+
return selected_app_instance_id
141+
142+
143+
def _delete_app_confirmation_prompt(app_name: str, cluster_id: str) -> None:
144+
console = Console()
145+
146+
# when the --yes / -y flags were not passed, do a final
147+
# confirmation that the user wants to delete the app.
148+
try:
149+
ask = [
150+
inquirer.Confirm(
151+
"confirm",
152+
message=f'Are you sure you want to delete app "{app_name}" on cluster "{cluster_id}"?',
153+
default=False,
154+
),
155+
]
156+
if inquirer.prompt(ask, theme=GreenPassion(), raise_keyboard_interrupt=True)["confirm"] is False:
157+
console.print("[b][red]Aborted![/b][/red]")
158+
raise InterruptedError
159+
except KeyboardInterrupt:
160+
console.print("[b][red]Cancelled by user![/b][/red]")
161+
raise InterruptedError
162+
163+
164+
@delete.command("app")
165+
@click.argument("app-name", type=str)
166+
@click.option(
167+
"--cluster-id",
168+
type=str,
169+
default=None,
170+
help="Delete the Lighting App from a specific Lightning AI BYOC compute cluster",
171+
)
172+
@click.option(
173+
"skip_user_confirm_prompt",
174+
"--yes",
175+
"-y",
176+
is_flag=True,
177+
default=False,
178+
help="Do not prompt for confirmation.",
179+
)
180+
def delete_app(app_name: str, cluster_id: str, skip_user_confirm_prompt: bool) -> None:
181+
"""Delete a Lightning app.
182+
183+
Deleting an app also deletes all app websites, works, artifacts, and logs. This permanently removes any record of
184+
the app as well as all any of its associated resources and data. This does not affect any resources and data
185+
associated with other Lightning apps on your account.
186+
"""
187+
console = Console()
188+
189+
try:
190+
cluster_id = _find_cluster_for_user(app_name=app_name, cluster_id=cluster_id)
191+
selected_app_instance_id = _find_selected_app_instance_id(app_name=app_name, cluster_id=cluster_id)
192+
if not skip_user_confirm_prompt:
193+
_delete_app_confirmation_prompt(app_name=app_name, cluster_id=cluster_id)
194+
except InterruptedError:
195+
return
196+
197+
try:
198+
# Delete the app!
199+
app_manager = _AppManager()
200+
app_manager.delete(app_id=selected_app_instance_id)
201+
except Exception as e:
202+
console.print(
203+
f'[b][red]An issue occurred while deleting app "{app_name}. If the issue persists, please '
204+
"reach out to us at [link=mailto:[email protected]][email protected][/link][/b][/red]."
205+
)
206+
raise click.ClickException(str(e))
207+
208+
console.print(f'[b][green]App "{app_name}" has been successfully deleted from cluster "{cluster_id}"![/green][/b]')
209+
return
210+
211+
43212
@delete.command("ssh-key")
44213
@click.argument("key_id")
45214
def remove_ssh_key(key_id: str) -> None:

tests/tests_app/cli/test_cmd_apps.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,18 @@ def test_list_apps_on_cluster(list_memberships: mock.MagicMock, list_instances:
141141

142142
list_memberships.assert_called_once()
143143
list_instances.assert_called_once_with(project_id="default-project", cluster_id="12345", limit=100, phase_in=[])
144+
145+
146+
@mock.patch("lightning_cloud.login.Auth.authenticate", MagicMock())
147+
@mock.patch(
148+
"lightning_app.utilities.network.LightningClient.lightningapp_instance_service_delete_lightningapp_instance"
149+
)
150+
@mock.patch("lightning_app.cli.cmd_apps._get_project")
151+
def test_delete_app_on_cluster(get_project_mock: mock.MagicMock, delete_app_mock: mock.MagicMock):
152+
get_project_mock.return_value = V1Membership(project_id="default-project")
153+
154+
cluster_manager = _AppManager()
155+
cluster_manager.delete(app_id="12345")
156+
157+
delete_app_mock.assert_called()
158+
delete_app_mock.assert_called_once_with(project_id="default-project", id="12345")
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import sys
2+
from unittest import mock
3+
4+
import pytest
5+
from lightning_cloud.openapi import Externalv1Cluster, Externalv1LightningappInstance, V1ClusterSpec, V1ClusterType
6+
7+
from lightning_app.cli.lightning_cli_delete import _find_cluster_for_user, _find_selected_app_instance_id
8+
9+
10+
@pytest.mark.skipif(sys.platform == "win32", reason="currently not supported for windows.")
11+
@mock.patch("lightning_app.cli.lightning_cli_delete.AWSClusterManager.list_clusters")
12+
def test_find_cluster_for_user_when_provided_valid_cluster_id(list_clusters_mock: mock.MagicMock):
13+
list_clusters_mock.return_value = [
14+
Externalv1Cluster(
15+
id="default",
16+
spec=V1ClusterSpec(
17+
cluster_type=V1ClusterType.GLOBAL,
18+
),
19+
),
20+
Externalv1Cluster(
21+
id="custom",
22+
spec=V1ClusterSpec(
23+
cluster_type=V1ClusterType.BYOC,
24+
),
25+
),
26+
]
27+
returned_cluster_id = _find_cluster_for_user(app_name="whatever", cluster_id="custom")
28+
assert returned_cluster_id == "custom"
29+
30+
31+
@pytest.mark.skipif(sys.platform == "win32", reason="currently not supported for windows.")
32+
@mock.patch("lightning_app.cli.lightning_cli_delete.AWSClusterManager.list_clusters")
33+
def test_find_cluster_for_user_without_cluster_id_uses_default(list_clusters_mock: mock.MagicMock):
34+
list_clusters_mock.return_value = [
35+
Externalv1Cluster(
36+
id="default",
37+
spec=V1ClusterSpec(
38+
cluster_type=V1ClusterType.GLOBAL,
39+
),
40+
)
41+
]
42+
returned_cluster_id = _find_cluster_for_user(app_name="whatever", cluster_id=None)
43+
assert returned_cluster_id == "default"
44+
45+
46+
@pytest.mark.skipif(sys.platform == "win32", reason="currently not supported for windows.")
47+
@mock.patch("lightning_app.cli.lightning_cli_delete.AWSClusterManager.list_clusters")
48+
@mock.patch("lightning_app.cli.lightning_cli_delete.inquirer")
49+
def test_find_cluster_for_user_without_valid_cluster_id_asks_if_they_meant_to_use_valid(
50+
list_clusters_mock: mock.MagicMock,
51+
inquirer_mock: mock.MagicMock,
52+
):
53+
list_clusters_mock.return_value = [
54+
Externalv1Cluster(
55+
id="default",
56+
spec=V1ClusterSpec(
57+
cluster_type=V1ClusterType.GLOBAL,
58+
),
59+
)
60+
]
61+
_find_cluster_for_user(app_name="whatever", cluster_id="does-not-exist")
62+
inquirer_mock.assert_called()
63+
64+
65+
@pytest.mark.skipif(sys.platform == "win32", reason="currently not supported for windows.")
66+
@mock.patch("lightning_app.cli.lightning_cli_delete._AppManager.list_apps")
67+
def test_app_find_selected_app_instance_id_when_app_name_exists(list_apps_mock: mock.MagicMock):
68+
list_apps_mock.return_value = [
69+
Externalv1LightningappInstance(name="app-name", id="app-id"),
70+
]
71+
returned_app_instance_id = _find_selected_app_instance_id(app_name="app-name", cluster_id="default-cluster")
72+
assert returned_app_instance_id == "app-id"
73+
list_apps_mock.assert_called_once_with(cluster_id="default-cluster")
74+
75+
76+
@pytest.mark.skipif(sys.platform == "win32", reason="currently not supported for windows.")
77+
@mock.patch("lightning_app.cli.lightning_cli_delete._AppManager.list_apps")
78+
def test_app_find_selected_app_instance_id_when_app_id_exists(list_apps_mock: mock.MagicMock):
79+
list_apps_mock.return_value = [
80+
Externalv1LightningappInstance(name="app-name", id="app-id"),
81+
]
82+
returned_app_instance_id = _find_selected_app_instance_id(app_name="app-id", cluster_id="default-cluster")
83+
assert returned_app_instance_id == "app-id"
84+
list_apps_mock.assert_called_once_with(cluster_id="default-cluster")

0 commit comments

Comments
 (0)