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

(cherry picked from commit 32cf1fa)
  • Loading branch information
ethanwharris authored and Borda committed Dec 6, 2022
1 parent 3376e27 commit 5559b31
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
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
@@ -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
Expand Up @@ -13,11 +13,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
- Added the CLI command `lightning delete app` to delete a lightning app on the cloud ([#15783](https://github.com/Lightning-AI/lightning/pull/15783))
- Added a CloudMultiProcessBackend which enables running a child App from within the Flow in the cloud ([#15800](https://github.com/Lightning-AI/lightning/pull/15800))

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


### 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))

- 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))
- 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))
Expand Down
19 changes: 3 additions & 16 deletions src/lightning_app/cli/lightning_cli.py
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 requests.exceptions import ConnectionError

Expand Down Expand Up @@ -47,15 +47,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 @@ -268,10 +259,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 @@ -283,7 +270,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
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
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
@@ -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
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
@@ -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
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

0 comments on commit 5559b31

Please sign in to comment.