Skip to content

Commit f08bc67

Browse files
dmitsfBordaawaelchli
authored
[App] Introduce basic auth to Lightning CLI (#16105)
* Introduce basic auth to Lightning CLI for app creation * Parsing creds added * Adding auth field to app instance body * Adding tests * Adding changelog entry * Adding more tests * Update runtime.py * Setting auth on update * Fix test * Update lightning-cloud dep * Apply suggestions from code review Co-authored-by: Adrian Wälchli <[email protected]> * Update runtime.py * Fix for release * Update base.txt Co-authored-by: Jirka Borovec <[email protected]> Co-authored-by: Adrian Wälchli <[email protected]>
1 parent ca88f81 commit f08bc67

File tree

8 files changed

+188
-2
lines changed

8 files changed

+188
-2
lines changed

src/lightning_app/CHANGELOG.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
88

99
### Added
1010

11-
-
11+
- Added a possibility to set up basic authentication for Lightning apps ([#16105](https://github.com/Lightning-AI/lightning/pull/16105))
1212

1313

1414
### Changed
@@ -31,7 +31,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
3131
-
3232

3333

34-
3534
## [1.8.6] - 2022-12-21
3635

3736
### Added

src/lightning_app/cli/lightning_cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ def _run_app(
230230
env: tuple,
231231
secret: tuple,
232232
run_app_comment_commands: bool,
233+
enable_basic_auth: str,
233234
) -> None:
234235

235236
if not os.path.exists(file):
@@ -283,6 +284,7 @@ def _run_app(
283284
secrets=secrets,
284285
cluster_id=cluster_id,
285286
run_app_comment_commands=run_app_comment_commands,
287+
enable_basic_auth=enable_basic_auth,
286288
)
287289
if runtime_type == RuntimeType.CLOUD:
288290
click.echo("Application is ready in the cloud")
@@ -328,6 +330,12 @@ def run() -> None:
328330
default=False,
329331
help="run environment setup commands from the app comments.",
330332
)
333+
@click.option(
334+
"--enable-basic-auth",
335+
type=str,
336+
default="",
337+
help="Enable basic authentication for the app and use credentials provided in the format username:password",
338+
)
331339
def run_app(
332340
file: str,
333341
cloud: bool,
@@ -341,6 +349,7 @@ def run_app(
341349
secret: tuple,
342350
app_args: tuple,
343351
run_app_comment_commands: bool,
352+
enable_basic_auth: str,
344353
) -> None:
345354
"""Run an app from a file."""
346355
_run_app(
@@ -355,6 +364,7 @@ def run_app(
355364
env,
356365
secret,
357366
run_app_comment_commands,
367+
enable_basic_auth,
358368
)
359369

360370

src/lightning_app/runners/cloud.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
V1Flowserver,
3232
V1LightningappInstanceSpec,
3333
V1LightningappInstanceState,
34+
V1LightningAuth,
35+
V1LightningBasicAuth,
3436
V1LightningworkDrives,
3537
V1LightningworkSpec,
3638
V1Membership,
@@ -68,6 +70,7 @@
6870
from lightning_app.source_code.copytree import _filter_ignored, _parse_lightningignore
6971
from lightning_app.storage import Drive, Mount
7072
from lightning_app.utilities.app_helpers import _is_headless, Logger
73+
from lightning_app.utilities.auth import _credential_string_to_basic_auth_params
7174
from lightning_app.utilities.cloud import _get_project
7275
from lightning_app.utilities.dependency_caching import get_hash
7376
from lightning_app.utilities.load_app import load_app_from_file
@@ -211,6 +214,16 @@ def dispatch(
211214
"initialize the Runtime object with `entrypoint_file` argument?"
212215
)
213216

217+
# If enable_basic_auth is set, we parse credential string and set up authentication for the app
218+
auth: V1LightningAuth = None
219+
if self.enable_basic_auth != "":
220+
parsed_credentials = _credential_string_to_basic_auth_params(self.enable_basic_auth)
221+
auth = V1LightningAuth(
222+
basic=V1LightningBasicAuth(
223+
username=parsed_credentials["username"], password=parsed_credentials["password"]
224+
)
225+
)
226+
214227
# Determine the root of the project: Start at the entrypoint_file and look for nearby Lightning config files,
215228
# going up the directory structure. The root of the project is where the Lightning config file is located.
216229

@@ -296,6 +309,7 @@ def dispatch(
296309
shm_size=self.app.flow_cloud_compute.shm_size,
297310
preemptible=False,
298311
),
312+
auth=auth,
299313
)
300314

301315
# if requirements file at the root of the repository is present,
@@ -473,6 +487,7 @@ def dispatch(
473487
desired_state=app_release_desired_state,
474488
env=v1_env_vars,
475489
queue_server_type=queue_server_type,
490+
auth=app_spec.auth,
476491
)
477492
),
478493
)
@@ -488,6 +503,7 @@ def dispatch(
488503
name=app_name,
489504
env=v1_env_vars,
490505
queue_server_type=queue_server_type,
506+
auth=app_spec.auth,
491507
),
492508
)
493509
)

src/lightning_app/runners/runtime.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def dispatch(
3434
secrets: Dict[str, str] = None,
3535
cluster_id: str = None,
3636
run_app_comment_commands: bool = False,
37+
enable_basic_auth: str = "",
3738
) -> Optional[Any]:
3839
"""Bootstrap and dispatch the application to the target.
3940
@@ -51,6 +52,8 @@ def dispatch(
5152
secrets: Dict of secrets to be passed as environment variables to the app
5253
cluster_id: the Lightning AI cluster to run the app on. Defaults to managed Lightning AI cloud
5354
run_app_comment_commands: whether to parse commands from the entrypoint file and execute them before app startup
55+
enable_basic_auth: whether to enable basic authentication for the app
56+
(use credentials in the format username:password as an argument)
5457
"""
5558
from lightning_app.runners.runtime_type import RuntimeType
5659
from lightning_app.utilities.component import _set_flow_context
@@ -76,6 +79,7 @@ def dispatch(
7679
env_vars=env_vars,
7780
secrets=secrets,
7881
run_app_comment_commands=run_app_comment_commands,
82+
enable_basic_auth=enable_basic_auth,
7983
)
8084
# Used to indicate Lightning has been dispatched
8185
os.environ["LIGHTNING_DISPATCHED"] = "1"
@@ -99,6 +103,7 @@ class Runtime:
99103
env_vars: Dict[str, str] = field(default_factory=dict)
100104
secrets: Dict[str, str] = field(default_factory=dict)
101105
run_app_comment_commands: bool = False
106+
enable_basic_auth: str = ""
102107

103108
def __post_init__(self):
104109
if isinstance(self.backend, str):
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import Dict
2+
3+
4+
def _credential_string_to_basic_auth_params(credential_string: str) -> Dict[str, str]:
5+
"""Returns the name/ID pair for each given Secret name.
6+
7+
Raises a `ValueError` if any of the given Secret names do not exist.
8+
"""
9+
if credential_string.count(":") != 1:
10+
raise ValueError(
11+
"Credential string must follow the format username:password; "
12+
+ f"the provided one ('{credential_string}') does not."
13+
)
14+
15+
username, password = credential_string.split(":")
16+
17+
if not username:
18+
raise ValueError("Username cannot be empty.")
19+
20+
if not password:
21+
raise ValueError("Password cannot be empty.")
22+
23+
return {"username": username, "password": password}

tests/tests_app/cli/test_run_app.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def test_lightning_run_cluster_without_cloud(monkeypatch):
6969
env=("FOO=bar",),
7070
secret=(),
7171
run_app_comment_commands=False,
72+
enable_basic_auth="",
7273
)
7374

7475

@@ -96,6 +97,7 @@ def test_lightning_run_app_cloud(mock_dispatch: mock.MagicMock, open_ui, caplog,
9697
env=("FOO=bar",),
9798
secret=("BAR=my-secret",),
9899
run_app_comment_commands=False,
100+
enable_basic_auth="",
99101
)
100102
# capture logs.
101103
# TODO(yurij): refactor the test, check if the actual HTTP request is being sent and that the proper admin
@@ -112,6 +114,7 @@ def test_lightning_run_app_cloud(mock_dispatch: mock.MagicMock, open_ui, caplog,
112114
secrets={"BAR": "my-secret"},
113115
cluster_id="",
114116
run_app_comment_commands=False,
117+
enable_basic_auth="",
115118
)
116119

117120

@@ -139,6 +142,7 @@ def test_lightning_run_app_cloud_with_run_app_commands(mock_dispatch: mock.Magic
139142
env=("FOO=bar",),
140143
secret=("BAR=my-secret",),
141144
run_app_comment_commands=True,
145+
enable_basic_auth="",
142146
)
143147
# capture logs.
144148
# TODO(yurij): refactor the test, check if the actual HTTP request is being sent and that the proper admin
@@ -155,6 +159,7 @@ def test_lightning_run_app_cloud_with_run_app_commands(mock_dispatch: mock.Magic
155159
secrets={"BAR": "my-secret"},
156160
cluster_id="",
157161
run_app_comment_commands=True,
162+
enable_basic_auth="",
158163
)
159164

160165

@@ -175,4 +180,45 @@ def test_lightning_run_app_secrets(monkeypatch):
175180
env=(),
176181
secret=("FOO=my-secret"),
177182
run_app_comment_commands=False,
183+
enable_basic_auth="",
178184
)
185+
186+
187+
@mock.patch.dict(os.environ, {"LIGHTNING_CLOUD_URL": "https://beta.lightning.ai"})
188+
@mock.patch("lightning_app.cli.lightning_cli.dispatch")
189+
def test_lightning_run_app_enable_basic_auth_passed(mock_dispatch: mock.MagicMock, caplog, monkeypatch):
190+
"""This test just validates the command has ran properly when --enable-basic-auth argument is passed.
191+
192+
It checks the call to `dispatch` for the right arguments.
193+
"""
194+
monkeypatch.setattr("lightning_app.runners.cloud.logger", logging.getLogger())
195+
196+
with caplog.at_level(logging.INFO):
197+
_run_app(
198+
file=os.path.join(_PROJECT_ROOT, "tests/tests_app/core/scripts/app_metadata.py"),
199+
cloud=True,
200+
cluster_id="",
201+
without_server=False,
202+
name="",
203+
blocking=False,
204+
open_ui=False,
205+
no_cache=True,
206+
env=("FOO=bar",),
207+
secret=("BAR=my-secret",),
208+
run_app_comment_commands=False,
209+
enable_basic_auth="username:password",
210+
)
211+
mock_dispatch.assert_called_with(
212+
Path(os.path.join(_PROJECT_ROOT, "tests/tests_app/core/scripts/app_metadata.py")),
213+
RuntimeType.CLOUD,
214+
start_server=True,
215+
blocking=False,
216+
open_ui=False,
217+
name="",
218+
no_cache=True,
219+
env_vars={"FOO": "bar"},
220+
secrets={"BAR": "my-secret"},
221+
cluster_id="",
222+
run_app_comment_commands=False,
223+
enable_basic_auth="username:password",
224+
)

tests/tests_app/runners/test_cloud.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
V1GetClusterResponse,
2828
V1LightningappInstanceState,
2929
V1LightningappRelease,
30+
V1LightningAuth,
31+
V1LightningBasicAuth,
3032
V1LightningworkDrives,
3133
V1LightningworkSpec,
3234
V1ListClustersResponse,
@@ -459,6 +461,65 @@ def test_requirements_file(self, monkeypatch):
459461
project_id="test-project-id", app_id=mock.ANY, body=body
460462
)
461463

464+
@mock.patch("lightning_app.runners.backends.cloud.LightningClient", mock.MagicMock())
465+
def test_basic_auth_enabled(self, monkeypatch):
466+
mock_client = mock.MagicMock()
467+
mock_client.projects_service_list_memberships.return_value = V1ListMembershipsResponse(
468+
memberships=[V1Membership(name="test-project", project_id="test-project-id")]
469+
)
470+
mock_client.lightningapp_instance_service_list_lightningapp_instances.return_value = (
471+
V1ListLightningappInstancesResponse(lightningapps=[])
472+
)
473+
mock_client.lightningapp_v2_service_create_lightningapp_release.return_value = V1LightningappRelease()
474+
mock_client.cluster_service_list_clusters.return_value = V1ListClustersResponse([Externalv1Cluster(id="test")])
475+
cloud_backend = mock.MagicMock()
476+
cloud_backend.client = mock_client
477+
monkeypatch.setattr(backends, "CloudBackend", mock.MagicMock(return_value=cloud_backend))
478+
monkeypatch.setattr(cloud, "LocalSourceCodeDir", mock.MagicMock())
479+
monkeypatch.setattr(cloud, "_prepare_lightning_wheels_and_requirements", mock.MagicMock())
480+
app = mock.MagicMock()
481+
app.is_headless = False
482+
app.flows = []
483+
app.frontend = {}
484+
cloud_runtime = cloud.CloudRuntime(app=app, entrypoint_file="entrypoint.py")
485+
cloud_runtime._check_uploaded_folder = mock.MagicMock()
486+
# Set cloud_runtime.enable_basic_auth to be not empty:
487+
cloud_runtime.enable_basic_auth = "username:password"
488+
monkeypatch.setattr(Path, "is_file", lambda *args, **kwargs: False)
489+
monkeypatch.setattr(cloud, "Path", Path)
490+
491+
cloud_runtime.dispatch()
492+
mock_client = cloud_runtime.backend.client
493+
494+
body = Body8(
495+
app_entrypoint_file=mock.ANY,
496+
enable_app_server=True,
497+
is_headless=False,
498+
flow_servers=[],
499+
image_spec=None,
500+
works=[],
501+
local_source=True,
502+
dependency_cache_key=mock.ANY,
503+
user_requested_flow_compute_config=mock.ANY,
504+
)
505+
506+
mock_client.lightningapp_v2_service_create_lightningapp_release.assert_called_once_with(
507+
project_id="test-project-id", app_id=mock.ANY, body=body
508+
)
509+
510+
mock_client.lightningapp_v2_service_create_lightningapp_release_instance.assert_called_once_with(
511+
project_id="test-project-id",
512+
app_id=mock.ANY,
513+
id=mock.ANY,
514+
body=Body9(
515+
desired_state=mock.ANY,
516+
name=mock.ANY,
517+
env=mock.ANY,
518+
queue_server_type=mock.ANY,
519+
auth=V1LightningAuth(basic=V1LightningBasicAuth(username="username", password="password")),
520+
),
521+
)
522+
462523
@mock.patch("lightning_app.runners.backends.cloud.LightningClient", mock.MagicMock())
463524
def test_no_cache(self, monkeypatch):
464525
mock_client = mock.MagicMock()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import Dict
2+
3+
import pytest
4+
5+
from lightning_app.utilities.auth import _credential_string_to_basic_auth_params
6+
7+
8+
@pytest.mark.parametrize(
9+
"credential_string, expected_parsed, exception_message",
10+
[
11+
("", None, "Credential string must follow the format username:password; the provided one ('') does not."),
12+
(":", None, "Username cannot be empty."),
13+
(":pass", None, "Username cannot be empty."),
14+
("user:", None, "Password cannot be empty."),
15+
("user:pass", {"username": "user", "password": "pass"}, ""),
16+
],
17+
)
18+
def test__credential_string_to_basic_auth_params(
19+
credential_string: str, expected_parsed: Dict[str, str], exception_message: str
20+
):
21+
if expected_parsed is not None:
22+
assert _credential_string_to_basic_auth_params(credential_string) == expected_parsed
23+
else:
24+
with pytest.raises(ValueError) as exception:
25+
_credential_string_to_basic_auth_params(credential_string)
26+
assert exception_message == str(exception.value)

0 commit comments

Comments
 (0)