Skip to content

Commit

Permalink
[App] Support for headless apps (#15875)
Browse files Browse the repository at this point in the history
* Add `is_headless` when dispatching in the cloud

* Bump cloud version

* Add tests

* Dont open app page for headless apps locally

* Refactor

* Update CHANGELOG.md

* Support dynamic UIs at runtime

* Comments

* Fix

* Updates

* Fixes and cleanup

* Fix tests

* Dont open view page for headless apps

* Fix test, resolve URL the right way

* Remove launch

* Clean

* Cleanup tests

* Fixes

* Updates

* Add test

* Increase app cloud tests timeout

* Increase timeout

* Wait for running

* Revert timeouts

* Clean

* Dont update if it hasnt changed

* Increase timeout
  • Loading branch information
ethanwharris authored Dec 5, 2022
1 parent b4d99e3 commit 32cf1fa
Show file tree
Hide file tree
Showing 19 changed files with 338 additions and 86 deletions.
2 changes: 1 addition & 1 deletion .azure/app-cloud-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
'App: custom_work_dependencies':
name: "custom_work_dependencies"
dir: "local"
timeoutInMinutes: "10"
timeoutInMinutes: "15"
cancelTimeoutInMinutes: "1"
# values: https://docs.microsoft.com/en-us/azure/devops/pipelines/process/phases?view=azure-devops&tabs=yaml#workspace
workspace:
Expand Down
2 changes: 1 addition & 1 deletion requirements/app/base.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
lightning-cloud>=0.5.11
lightning-cloud>=0.5.12
packaging
typing-extensions>=4.0.0, <=4.4.0
deepdiff>=5.7.0, <=5.8.1
Expand Down
5 changes: 5 additions & 0 deletions src/lightning_app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
### Changed

- The `MultiNode` components now warn the user when running with `num_nodes > 1` locally ([#15806](https://github.com/Lightning-AI/lightning/pull/15806))

- Cluster creation and deletion now waits by default [#15458](https://github.com/Lightning-AI/lightning/pull/15458)

- Running an app without a UI locally no longer opens the browser ([#15875](https://github.com/Lightning-AI/lightning/pull/15875))

- Apps without UIs no longer activate the "Open App" button when running in the cloud ([#15875](https://github.com/Lightning-AI/lightning/pull/15875))


### Deprecated

Expand Down
19 changes: 3 additions & 16 deletions src/lightning_app/cli/lightning_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
import shutil
import sys
from pathlib import Path
from typing import Any, Tuple, Union
from typing import Tuple, Union

import arrow
import click
import inquirer
import rich
from lightning_cloud.openapi import Externalv1LightningappInstance, V1LightningappInstanceState, V1LightningworkState
from lightning_cloud.openapi import V1LightningappInstanceState, V1LightningworkState
from lightning_cloud.openapi.rest import ApiException
from lightning_utilities.core.imports import RequirementCache
from requests.exceptions import ConnectionError
Expand Down Expand Up @@ -48,15 +48,6 @@
logger = Logger(__name__)


def get_app_url(runtime_type: RuntimeType, *args: Any, need_credits: bool = False) -> str:
if runtime_type == RuntimeType.CLOUD:
lit_app: Externalv1LightningappInstance = args[0]
action = "?action=add_credits" if need_credits else ""
return f"{get_lightning_cloud_url()}/me/apps/{lit_app.id}{action}"
else:
return os.getenv("APP_SERVER_HOST", "http://127.0.0.1:7501/view")


def main() -> None:
# Check environment and versions if not in the cloud
if "LIGHTNING_APP_STATE_URL" not in os.environ:
Expand Down Expand Up @@ -269,10 +260,6 @@ def _run_app(

secrets = _format_input_env_variables(secret)

def on_before_run(*args: Any, **kwargs: Any) -> None:
if open_ui and not without_server:
click.launch(get_app_url(runtime_type, *args, **kwargs))

click.echo("Your Lightning App is starting. This won't take long.")

# TODO: Fixme when Grid utilities are available.
Expand All @@ -284,7 +271,7 @@ def on_before_run(*args: Any, **kwargs: Any) -> None:
start_server=not without_server,
no_cache=no_cache,
blocking=blocking,
on_before_run=on_before_run,
open_ui=open_ui,
name=name,
env_vars=env_vars,
secrets=secrets,
Expand Down
15 changes: 15 additions & 0 deletions src/lightning_app/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
from lightning_app.utilities import frontend
from lightning_app.utilities.app_helpers import (
_delta_to_app_state_delta,
_handle_is_headless,
_is_headless,
_LightningAppRef,
_should_dispatch_app,
Logger,
Expand Down Expand Up @@ -148,6 +150,8 @@ def __init__(

self._update_layout()

self.is_headless: Optional[bool] = None

self._original_state = None
self._last_state = self.state
self.state_accumulate_wait = STATE_ACCUMULATE_WAIT
Expand Down Expand Up @@ -412,6 +416,7 @@ def run_once(self):
self.backend.update_work_statuses(self.works)

self._update_layout()
self._update_is_headless()
self.maybe_apply_changes()

if self.checkpointing and self._should_snapshot():
Expand Down Expand Up @@ -510,6 +515,16 @@ def _update_layout(self) -> None:
layout = _collect_layout(self, component)
component._layout = layout

def _update_is_headless(self) -> None:
is_headless = _is_headless(self)

# If `is_headless` changed, handle it.
# This ensures support for apps which dynamically add a UI at runtime.
if self.is_headless != is_headless:
self.is_headless = is_headless

_handle_is_headless(self)

def _apply_restarting(self) -> bool:
self._reset_original_state()
# apply stage after restoring the original state.
Expand Down
20 changes: 14 additions & 6 deletions src/lightning_app/runners/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
from dataclasses import dataclass
from pathlib import Path
from textwrap import dedent
from typing import Any, Callable, List, Optional, Union
from typing import Any, List, Optional, Union

import click
from lightning_cloud.openapi import (
Body3,
Body4,
Expand Down Expand Up @@ -56,12 +57,13 @@
ENABLE_MULTIPLE_WORKS_IN_NON_DEFAULT_CONTAINER,
ENABLE_PULLING_STATE_ENDPOINT,
ENABLE_PUSHING_STATE_ENDPOINT,
get_lightning_cloud_url,
)
from lightning_app.runners.backends.cloud import CloudBackend
from lightning_app.runners.runtime import Runtime
from lightning_app.source_code import LocalSourceCodeDir
from lightning_app.storage import Drive, Mount
from lightning_app.utilities.app_helpers import Logger
from lightning_app.utilities.app_helpers import _is_headless, Logger
from lightning_app.utilities.cloud import _get_project
from lightning_app.utilities.dependency_caching import get_hash
from lightning_app.utilities.load_app import load_app_from_file
Expand Down Expand Up @@ -192,9 +194,9 @@ class CloudRuntime(Runtime):

def dispatch(
self,
on_before_run: Optional[Callable] = None,
name: str = "",
cluster_id: str = None,
open_ui: bool = True,
**kwargs: Any,
) -> None:
"""Method to dispatch and run the :class:`~lightning_app.core.app.LightningApp` in the cloud."""
Expand Down Expand Up @@ -405,6 +407,7 @@ def dispatch(
local_source=True,
dependency_cache_key=app_spec.dependency_cache_key,
user_requested_flow_compute_config=app_spec.user_requested_flow_compute_config,
is_headless=_is_headless(self.app),
)

# create / upload the new app release
Expand Down Expand Up @@ -464,12 +467,12 @@ def dispatch(
logger.error(e.body)
sys.exit(1)

if on_before_run:
on_before_run(lightning_app_instance, need_credits=not has_sufficient_credits)

if lightning_app_instance.status.phase == V1LightningappInstanceState.FAILED:
raise RuntimeError("Failed to create the application. Cannot upload the source code.")

if open_ui:
click.launch(self._get_app_url(lightning_app_instance, not has_sufficient_credits))

if cleanup_handle:
cleanup_handle()

Expand Down Expand Up @@ -538,6 +541,11 @@ def load_app_from_file(cls, filepath: str) -> "LightningApp":
app = LightningApp(EmptyFlow())
return app

@staticmethod
def _get_app_url(lightning_app_instance: Externalv1LightningappInstance, need_credits: bool = False) -> str:
action = "?action=add_credits" if need_credits else ""
return f"{get_lightning_cloud_url()}/me/apps/{lightning_app_instance.id}{action}"


def _create_mount_drive_spec(work_name: str, mount: Mount) -> V1LightningworkDrives:
if mount.protocol == "s3://":
Expand Down
16 changes: 11 additions & 5 deletions src/lightning_app/runners/multiprocess.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import multiprocessing
import os
from dataclasses import dataclass
from typing import Any, Callable, Optional, Union
from typing import Any, Union

import click

from lightning_app.api.http_methods import _add_tags_to_api, _validate_api
from lightning_app.core.api import start_server
from lightning_app.core.constants import APP_SERVER_IN_CLOUD
from lightning_app.runners.backends import Backend
from lightning_app.runners.runtime import Runtime
from lightning_app.storage.orchestrator import StorageOrchestrator
from lightning_app.utilities.app_helpers import is_overridden
from lightning_app.utilities.app_helpers import _is_headless, is_overridden
from lightning_app.utilities.commands.base import _commands_to_api, _prepare_commands
from lightning_app.utilities.component import _set_flow_context, _set_frontend_context
from lightning_app.utilities.load_app import extract_metadata_from_app
Expand All @@ -29,7 +31,7 @@ class MultiProcessRuntime(Runtime):
backend: Union[str, Backend] = "multiprocessing"
_has_triggered_termination: bool = False

def dispatch(self, *args: Any, on_before_run: Optional[Callable] = None, **kwargs: Any):
def dispatch(self, *args: Any, open_ui: bool = True, **kwargs: Any):
"""Method to dispatch and run the LightningApp."""
try:
_set_flow_context()
Expand Down Expand Up @@ -101,8 +103,8 @@ def dispatch(self, *args: Any, on_before_run: Optional[Callable] = None, **kwarg
# wait for server to be ready
has_started_queue.get()

if on_before_run:
on_before_run(self, self.app)
if open_ui and not _is_headless(self.app):
click.launch(self._get_app_url())

# Connect the runtime to the application.
self.app.connect(self)
Expand All @@ -125,3 +127,7 @@ def terminate(self):
for port in ports:
disable_port(port)
super().terminate()

@staticmethod
def _get_app_url() -> str:
return os.getenv("APP_SERVER_HOST", "http://127.0.0.1:7501/view")
8 changes: 4 additions & 4 deletions src/lightning_app/runners/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from dataclasses import dataclass, field
from pathlib import Path
from threading import Thread
from typing import Any, Callable, Dict, List, Optional, Type, TYPE_CHECKING, Union
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING, Union

from lightning_app import LightningApp, LightningFlow
from lightning_app.core.constants import APP_SERVER_HOST, APP_SERVER_PORT
Expand All @@ -28,7 +28,7 @@ def dispatch(
host: str = APP_SERVER_HOST,
port: int = APP_SERVER_PORT,
blocking: bool = True,
on_before_run: Optional[Callable] = None,
open_ui: bool = True,
name: str = "",
env_vars: Dict[str, str] = None,
secrets: Dict[str, str] = None,
Expand All @@ -45,7 +45,7 @@ def dispatch(
host: Server host address
port: Server port
blocking: Whether for the wait for the UI to start running.
on_before_run: Callable to be executed before run.
open_ui: Whether to open the UI in the browser.
name: Name of app execution
env_vars: Dict of env variables to be set on the app
secrets: Dict of secrets to be passed as environment variables to the app
Expand Down Expand Up @@ -82,7 +82,7 @@ def dispatch(
)
# a cloud dispatcher will return the result while local
# dispatchers will be running the app in the main process
return runtime.dispatch(on_before_run=on_before_run, name=name, no_cache=no_cache, cluster_id=cluster_id)
return runtime.dispatch(open_ui=open_ui, name=name, no_cache=no_cache, cluster_id=cluster_id)


@dataclass
Expand Down
16 changes: 12 additions & 4 deletions src/lightning_app/runners/singleprocess.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import multiprocessing as mp
from typing import Any, Callable, Optional
import os
from typing import Any

import click

from lightning_app.core.api import start_server
from lightning_app.core.queues import QueuingSystem
from lightning_app.runners.runtime import Runtime
from lightning_app.utilities.app_helpers import _is_headless
from lightning_app.utilities.load_app import extract_metadata_from_app


Expand All @@ -13,7 +17,7 @@ class SingleProcessRuntime(Runtime):
def __post_init__(self):
pass

def dispatch(self, *args, on_before_run: Optional[Callable] = None, **kwargs: Any):
def dispatch(self, *args, open_ui: bool = True, **kwargs: Any):
"""Method to dispatch and run the LightningApp."""
queue = QueuingSystem.SINGLEPROCESS

Expand Down Expand Up @@ -42,8 +46,8 @@ def dispatch(self, *args, on_before_run: Optional[Callable] = None, **kwargs: An
# wait for server to be ready.
has_started_queue.get()

if on_before_run:
on_before_run()
if open_ui and not _is_headless(self.app):
click.launch(self._get_app_url())

try:
self.app._run()
Expand All @@ -52,3 +56,7 @@ def dispatch(self, *args, on_before_run: Optional[Callable] = None, **kwargs: An
raise
finally:
self.terminate()

@staticmethod
def _get_app_url() -> str:
return os.getenv("APP_SERVER_HOST", "http://127.0.0.1:7501/view")
38 changes: 29 additions & 9 deletions src/lightning_app/testing/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import Any, Callable, Dict, Generator, List, Optional, Type

import requests
from lightning_cloud.openapi import V1LightningappInstanceState
from lightning_cloud.openapi.rest import ApiException
from requests import Session
from rich import print
Expand Down Expand Up @@ -394,15 +395,34 @@ def run_app_in_cloud(
process = Process(target=_print_logs, kwargs={"app_id": app_id})
process.start()

while True:
try:
with admin_page.context.expect_page() as page_catcher:
admin_page.locator('[data-cy="open"]').click()
view_page = page_catcher.value
view_page.wait_for_load_state(timeout=0)
break
except (playwright._impl._api_types.Error, playwright._impl._api_types.TimeoutError):
pass
if not app.spec.is_headless:
while True:
try:
with admin_page.context.expect_page() as page_catcher:
admin_page.locator('[data-cy="open"]').click()
view_page = page_catcher.value
view_page.wait_for_load_state(timeout=0)
break
except (playwright._impl._api_types.Error, playwright._impl._api_types.TimeoutError):
pass
else:
view_page = None

# Wait until the app is running
while True:
sleep(1)

lit_apps = [
app
for app in client.lightningapp_instance_service_list_lightningapp_instances(
project_id=project.project_id
).lightningapps
if app.name == name
]
app = lit_apps[0]

if app.status.phase == V1LightningappInstanceState.RUNNING:
break

# TODO: is re-creating this redundant?
lit_apps = [
Expand Down
Loading

0 comments on commit 32cf1fa

Please sign in to comment.