diff --git a/.github/workflows/webviz.yml b/.github/workflows/webviz.yml index 739b9950a..c3dae2f46 100644 --- a/.github/workflows/webviz.yml +++ b/.github/workflows/webviz.yml @@ -57,7 +57,7 @@ jobs: - name: 🕵️ Check auto-generated frontend code is in sync with backend run: | docker build -f backend.Dockerfile -t backend:latest . - CONTAINER_ID=$(docker run --detach -p 5000:5000 --env WEBVIZ_CLIENT_SECRET=0 backend:latest) + CONTAINER_ID=$(docker run --detach -p 5000:5000 --env UVICORN_PORT=5000 --env UVICORN_ENTRYPOINT=src.backend.primary.main:app --env WEBVIZ_CLIENT_SECRET=0 backend:latest) sleep 5 # Ensure the backend server is up and running exposing /openapi.json npm run generate-api --prefix ./frontend docker stop $CONTAINER_ID diff --git a/README.md b/README.md index 58e39ef21..3634b3e4c 100644 --- a/README.md +++ b/README.md @@ -47,25 +47,3 @@ in the Python backend. In order to update the auto-generated code you can either In both cases the backend needs to already be running (e.g. using `docker-compose` as stated above). - - -# Update `poetry.lock` through Docker - -If you do not want to install the correct Python version and/or `poetry` on your host -machine, you can update `pyproject.toml` and `poetry.lock` through `docker`. -As an example, if you wanted to add the Python package `httpx`: - -```bash -# Start container. This assumes you have previously ran docker-compose -CONTAINER_ID=$(docker run --detach --env WEBVIZ_CLIENT_SECRET=0 webviz_backend) -# Copy pyproject.toml and poetry.lock from host to container in case they have changed since it was built: -docker cp ./backend/pyproject.toml $CONTAINER_ID:/home/appuser/backend/ -docker cp ./backend/poetry.lock $CONTAINER_ID:/home/appuser/backend/ -# Run your poetry commands: -docker exec -it $CONTAINER_ID sh -c "poetry add httpx" -# Copy updated pyproject.toml and poetry.lock from container back to host: -docker cp $CONTAINER_ID:/home/appuser/backend/pyproject.toml ./backend/ -docker cp $CONTAINER_ID:/home/appuser/backend/poetry.lock ./backend/ -# Stop container -docker stop $CONTAINER_ID -``` diff --git a/backend.Dockerfile b/backend.Dockerfile index cea2fa6e5..b27bdedf7 100644 --- a/backend.Dockerfile +++ b/backend.Dockerfile @@ -14,4 +14,4 @@ RUN pip install poetry \ && poetry export --without-hashes -f requirements.txt -o requirements.txt \ && pip install -r requirements.txt -CMD ["uvicorn", "--proxy-headers", "--host=0.0.0.0", "--port=5000", "src.fastapi_app.main:app"] +CMD exec uvicorn --proxy-headers --host=0.0.0.0 $UVICORN_ENTRYPOINT diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 000000000..e89eb41a3 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,91 @@ +# Update `poetry.lock` through Docker + +If you do not want to install the correct Python version and/or `poetry` on your host +machine, you can update `pyproject.toml` and `poetry.lock` through `docker`. +As an example, if you wanted to add the Python package `httpx`: + +```bash +# Start container. This assumes you have previously ran docker-compose +CONTAINER_ID=$(docker run --detach --env WEBVIZ_CLIENT_SECRET=0 --env UVICORN_ENTRYPOINT=src.backend.primary.main:app webviz_backend-primary) +# Copy pyproject.toml and poetry.lock from host to container in case they have changed since it was built: +docker cp ./pyproject.toml $CONTAINER_ID:/home/appuser/backend/ +docker cp ./poetry.lock $CONTAINER_ID:/home/appuser/backend/ +docker exec -u root -it $CONTAINER_ID chown appuser:appuser /home/appuser/backend/{pyproject.toml,poetry.lock} +# Run your poetry commands: +docker exec -it $CONTAINER_ID sh -c "poetry add httpx" +# Copy updated pyproject.toml and poetry.lock from container back to host: +docker cp $CONTAINER_ID:/home/appuser/backend/pyproject.toml . +docker cp $CONTAINER_ID:/home/appuser/backend/poetry.lock . +# Stop container +docker stop $CONTAINER_ID +``` + + +# Cache data in memory + +Sometimes large data sets must to be loaded from external sources. If the user interacts +with this data through a series of requests to the backend, it is inefficient to load +the same data every time. Instead the recommended pattern is to load these large data sets +using a separate job container instance bound to the user where it can then easily be cached. + +Technically this is done like this: +1) The frontend makes a requests to the (primary) backend as usual. +2) The "data demanding endpoints" in the primary backend proxies the request to a separate + job container runnings its own server (also using `FastAPI` as framework). +3) If the user does not already have a job container bound to her/his user ID, the + cloud infrastructure will spin it up (takes some seconds). The job container will + have single-user scope and automatically stop when it has not seen any new requests + for some time. Since the job container has lifetime and scope of a user session, + the Python code can keep large data sets in memory for its lifetime and be sure + it is always the same user accessing it. + +Locally during development (single user scenario) there is a single job container +continuously running, started automatically by `docker-compose`. +Except from starting at the same time as the primary backend, not stopping after user +inactivity, and being limited by the developer machine resources (CPU / memory), +this job container during development behave similar to the on demand started job containers in cloud. + +On route level this is implemented like the following: + +**In `src/backend/primary`:** +```python +from fastapi import Depends, Request + +from src.services.utils.authenticated_user import AuthenticatedUser +from src.backend.auth.auth_helper import AuthHelper +from src.backend.primary.user_session_proxy import proxy_to_user_session + +... + +@router.get("/some_endpoint") +async def my_function( + request: Request, + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +): + return await proxy_to_user_session(request, authenticated_user) +``` + +**In `src/backend/user_session:** +```python +from functools import lru_cache + +from fastapi import Depends + +from src.services.utils.authenticated_user import AuthenticatedUser +from src.backend.auth.auth_helper import AuthHelper + +... + +@router.get("/some_endpoint") +async def my_function( + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +): + return {"data": load_some_large_data_set(authenticated_user)} + +@lru_cache +def load_some_large_data_set(authenticated_user): + ... +``` + +The endpoint should have the same path as shown here +in both primary backend and the job backend. diff --git a/backend/poetry.lock b/backend/poetry.lock index 290daa06f..3b7694baf 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -801,6 +801,52 @@ h5py = "*" [package.extras] dev = ["sphinx", "sphinx-rtd-theme"] +[[package]] +name = "httpcore" +version = "0.17.0" +description = "A minimal low-level HTTP client." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "httpcore-0.17.0-py3-none-any.whl", hash = "sha256:0fdfea45e94f0c9fd96eab9286077f9ff788dd186635ae61b312693e4d943599"}, + {file = "httpcore-0.17.0.tar.gz", hash = "sha256:cc045a3241afbf60ce056202301b4d8b6af08845e3294055eb26b09913ef903c"}, +] + +[package.dependencies] +anyio = ">=3.0,<5.0" +certifi = "*" +h11 = ">=0.13,<0.15" +sniffio = ">=1.0.0,<2.0.0" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + +[[package]] +name = "httpx" +version = "0.24.0" +description = "The next generation HTTP client." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "httpx-0.24.0-py3-none-any.whl", hash = "sha256:447556b50c1921c351ea54b4fe79d91b724ed2b027462ab9a329465d147d5a4e"}, + {file = "httpx-0.24.0.tar.gz", hash = "sha256:507d676fc3e26110d41df7d35ebd8b3b8585052450f4097401c9be59d928c63e"}, +] + +[package.dependencies] +certifi = "*" +httpcore = ">=0.15.0,<0.18.0" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + [[package]] name = "idna" version = "3.4" @@ -1636,6 +1682,33 @@ docs = ["sphinx (>=1.7.1)"] redis = ["redis"] tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)"] +[[package]] +name = "psutil" +version = "5.9.5" +description = "Cross-platform lib for process and system monitoring in Python." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "psutil-5.9.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:be8929ce4313f9f8146caad4272f6abb8bf99fc6cf59344a3167ecd74f4f203f"}, + {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ab8ed1a1d77c95453db1ae00a3f9c50227ebd955437bcf2a574ba8adbf6a74d5"}, + {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:4aef137f3345082a3d3232187aeb4ac4ef959ba3d7c10c33dd73763fbc063da4"}, + {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ea8518d152174e1249c4f2a1c89e3e6065941df2fa13a1ab45327716a23c2b48"}, + {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:acf2aef9391710afded549ff602b5887d7a2349831ae4c26be7c807c0a39fac4"}, + {file = "psutil-5.9.5-cp27-none-win32.whl", hash = "sha256:5b9b8cb93f507e8dbaf22af6a2fd0ccbe8244bf30b1baad6b3954e935157ae3f"}, + {file = "psutil-5.9.5-cp27-none-win_amd64.whl", hash = "sha256:8c5f7c5a052d1d567db4ddd231a9d27a74e8e4a9c3f44b1032762bd7b9fdcd42"}, + {file = "psutil-5.9.5-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3c6f686f4225553615612f6d9bc21f1c0e305f75d7d8454f9b46e901778e7217"}, + {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a7dd9997128a0d928ed4fb2c2d57e5102bb6089027939f3b722f3a210f9a8da"}, + {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89518112647f1276b03ca97b65cc7f64ca587b1eb0278383017c2a0dcc26cbe4"}, + {file = "psutil-5.9.5-cp36-abi3-win32.whl", hash = "sha256:104a5cc0e31baa2bcf67900be36acde157756b9c44017b86b2c049f11957887d"}, + {file = "psutil-5.9.5-cp36-abi3-win_amd64.whl", hash = "sha256:b258c0c1c9d145a1d5ceffab1134441c4c5113b2417fafff7315a917a026c3c9"}, + {file = "psutil-5.9.5-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c607bb3b57dc779d55e1554846352b4e358c10fff3abf3514a7a6601beebdb30"}, + {file = "psutil-5.9.5.tar.gz", hash = "sha256:5410638e4df39c54d957fc51ce03048acd8e6d60abc0f5107af51e5fb566eb3c"}, +] + +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + [[package]] name = "py-cpuinfo" version = "9.0.0" @@ -2563,4 +2636,4 @@ tests = ["hypothesis", "pytest", "pytest-benchmark", "pytest-mock", "pytest-snap [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "423f1efdbed0c65603f5d734f15c6279e81fbcca801d1734d624dcaddace4338" +content-hash = "b58ef6b1158612356c8c5489c06f9be1d6d2587054ac42a3c53c78b444c1ce64" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 60be5d633..0077d62d5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,6 +22,8 @@ sumo-wrapper-python = {git = "https://github.com/equinor/sumo-wrapper-python.git fmu-sumo = {git = "https://github.com/equinor/fmu-sumo.git", branch = "main"} orjson = "^3.8.10" pandas = "<2" +httpx = "^0.24.0" +psutil = "^5.9.5" [tool.poetry.group.dev.dependencies] diff --git a/backend/src/fastapi_app/__init__.py b/backend/src/backend/auth/__init__.py similarity index 100% rename from backend/src/fastapi_app/__init__.py rename to backend/src/backend/auth/__init__.py diff --git a/backend/src/fastapi_app/auth/auth_helper.py b/backend/src/backend/auth/auth_helper.py similarity index 99% rename from backend/src/fastapi_app/auth/auth_helper.py rename to backend/src/backend/auth/auth_helper.py index 9055fdd6d..d37c67f37 100644 --- a/backend/src/fastapi_app/auth/auth_helper.py +++ b/backend/src/backend/auth/auth_helper.py @@ -10,7 +10,7 @@ from src.services.utils.authenticated_user import AuthenticatedUser from src.services.utils.perf_timer import PerfTimer -from src.fastapi_app import config +from src.backend import config class AuthHelper: diff --git a/backend/src/fastapi_app/auth/enforce_logged_in_middleware.py b/backend/src/backend/auth/enforce_logged_in_middleware.py similarity index 98% rename from backend/src/fastapi_app/auth/enforce_logged_in_middleware.py rename to backend/src/backend/auth/enforce_logged_in_middleware.py index 02a553188..dc4477f83 100644 --- a/backend/src/fastapi_app/auth/enforce_logged_in_middleware.py +++ b/backend/src/backend/auth/enforce_logged_in_middleware.py @@ -6,7 +6,7 @@ from fastapi.responses import PlainTextResponse, RedirectResponse from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint -from .auth_helper import AuthHelper +from src.backend.auth.auth_helper import AuthHelper class EnforceLoggedInMiddleware(BaseHTTPMiddleware): diff --git a/backend/src/fastapi_app/config.py b/backend/src/backend/config.py similarity index 100% rename from backend/src/fastapi_app/config.py rename to backend/src/backend/config.py diff --git a/backend/src/fastapi_app/auth/__init__.py b/backend/src/backend/primary/__init__.py similarity index 100% rename from backend/src/fastapi_app/auth/__init__.py rename to backend/src/backend/primary/__init__.py diff --git a/backend/src/fastapi_app/main.py b/backend/src/backend/primary/main.py similarity index 73% rename from backend/src/fastapi_app/main.py rename to backend/src/backend/primary/main.py index 949fdbfe1..baedaab81 100644 --- a/backend/src/fastapi_app/main.py +++ b/backend/src/backend/primary/main.py @@ -3,13 +3,10 @@ from fastapi import FastAPI from fastapi.routing import APIRoute -from starsessions import SessionMiddleware -from starsessions.stores.redis import RedisStore from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware -from . import config -from .auth.auth_helper import AuthHelper -from .auth.enforce_logged_in_middleware import EnforceLoggedInMiddleware +from src.backend.shared_middleware import add_shared_middlewares +from src.backend.auth.auth_helper import AuthHelper from .routers.explore import router as explore_router from .routers.general import router as general_router from .routers.inplace_volumetrics.router import router as inplace_volumetrics_router @@ -49,20 +46,9 @@ def custom_generate_unique_id(route: APIRoute) -> str: app.include_router(authHelper.router) app.include_router(general_router) -# Add out custom middleware to enforce that user is logged in -# Also redirects to /login endpoint for some select paths -unprotected_paths = ["/logged_in_user", "/alive", "/openapi.json"] -paths_redirected_to_login = ["/", "/alive_protected"] -app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*") -app.add_middleware( - EnforceLoggedInMiddleware, - unprotected_paths=unprotected_paths, - paths_redirected_to_login=paths_redirected_to_login, -) +add_shared_middlewares(app) -session_store = RedisStore(config.REDIS_URL) - -app.add_middleware(SessionMiddleware, store=session_store) +app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*") @app.get("/") diff --git a/backend/src/fastapi_app/routers/__init__.py b/backend/src/backend/primary/routers/__init__.py similarity index 100% rename from backend/src/fastapi_app/routers/__init__.py rename to backend/src/backend/primary/routers/__init__.py diff --git a/backend/src/fastapi_app/routers/correlations/__init__.py b/backend/src/backend/primary/routers/correlations/__init__.py similarity index 100% rename from backend/src/fastapi_app/routers/correlations/__init__.py rename to backend/src/backend/primary/routers/correlations/__init__.py diff --git a/backend/src/fastapi_app/routers/correlations/router.py b/backend/src/backend/primary/routers/correlations/router.py similarity index 91% rename from backend/src/fastapi_app/routers/correlations/router.py rename to backend/src/backend/primary/routers/correlations/router.py index c70168305..1b775f242 100644 --- a/backend/src/fastapi_app/routers/correlations/router.py +++ b/backend/src/backend/primary/routers/correlations/router.py @@ -11,7 +11,7 @@ InplaceVolumetricsAccess, ) from src.services.utils.authenticated_user import AuthenticatedUser -from src.fastapi_app.auth.auth_helper import AuthHelper +from src.backend.auth.auth_helper import AuthHelper LOGGER = logging.getLogger(__name__) @@ -35,7 +35,9 @@ def correlate_parameters_with_timeseries( summary_access = SummaryAccess(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name) parameter_access = ParameterAccess( - authenticated_user.get_sumo_access_token(), case_uuid=case_uuid, iteration_name=ensemble_name + authenticated_user.get_sumo_access_token(), + case_uuid=case_uuid, + iteration_name=ensemble_name, ) ensemble_response = summary_access.get_vector_values_at_timestep( @@ -63,7 +65,9 @@ def correlate_parameters_with_inplace_volumes( inplace_access = InplaceVolumetricsAccess(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name) parameter_access = ParameterAccess( - authenticated_user.get_sumo_access_token(), case_uuid=case_uuid, iteration_name=ensemble_name + authenticated_user.get_sumo_access_token(), + case_uuid=case_uuid, + iteration_name=ensemble_name, ) ensemble_response = inplace_access.get_response( table_name, response_name, categorical_filters=None, realizations=None diff --git a/backend/src/fastapi_app/routers/explore.py b/backend/src/backend/primary/routers/explore.py similarity index 97% rename from backend/src/fastapi_app/routers/explore.py rename to backend/src/backend/primary/routers/explore.py index febc99227..142120318 100644 --- a/backend/src/fastapi_app/routers/explore.py +++ b/backend/src/backend/primary/routers/explore.py @@ -5,7 +5,7 @@ from src.services.sumo_access.sumo_explore import SumoExplore from src.services.utils.authenticated_user import AuthenticatedUser -from src.fastapi_app.auth.auth_helper import AuthHelper +from src.backend.auth.auth_helper import AuthHelper router = APIRouter() diff --git a/backend/src/fastapi_app/routers/general.py b/backend/src/backend/primary/routers/general.py similarity index 68% rename from backend/src/fastapi_app/routers/general.py rename to backend/src/backend/primary/routers/general.py index 5d59de2b3..304c0d3ee 100644 --- a/backend/src/fastapi_app/routers/general.py +++ b/backend/src/backend/primary/routers/general.py @@ -2,10 +2,12 @@ import logging import starsessions -from fastapi import APIRouter, HTTPException, Request, status +from starlette.responses import StreamingResponse +from fastapi import APIRouter, HTTPException, Request, status, Depends from pydantic import BaseModel -from src.fastapi_app.auth.auth_helper import AuthHelper +from src.backend.auth.auth_helper import AuthHelper, AuthenticatedUser +from src.backend.primary.user_session_proxy import proxy_to_user_session LOGGER = logging.getLogger(__name__) @@ -50,3 +52,11 @@ async def logged_in_user(request: Request) -> UserInfo: ) return user_info + + +@router.get("/user_session_container") +async def user_session_container( + request: Request, authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> StreamingResponse: + """Get information about user session container (note that one is started if not already running).""" + return await proxy_to_user_session(request, authenticated_user) diff --git a/backend/src/fastapi_app/routers/inplace_volumetrics/__init__.py b/backend/src/backend/primary/routers/inplace_volumetrics/__init__.py similarity index 100% rename from backend/src/fastapi_app/routers/inplace_volumetrics/__init__.py rename to backend/src/backend/primary/routers/inplace_volumetrics/__init__.py diff --git a/backend/src/fastapi_app/routers/inplace_volumetrics/router.py b/backend/src/backend/primary/routers/inplace_volumetrics/router.py similarity index 98% rename from backend/src/fastapi_app/routers/inplace_volumetrics/router.py rename to backend/src/backend/primary/routers/inplace_volumetrics/router.py index 4fa46f51f..4aed03b9c 100644 --- a/backend/src/fastapi_app/routers/inplace_volumetrics/router.py +++ b/backend/src/backend/primary/routers/inplace_volumetrics/router.py @@ -10,7 +10,7 @@ from src.services.sumo_access.generic_types import EnsembleScalarResponse from src.services.utils.authenticated_user import AuthenticatedUser -from src.fastapi_app.auth.auth_helper import AuthHelper +from src.backend.auth.auth_helper import AuthHelper router = APIRouter() diff --git a/backend/src/fastapi_app/routers/parameters/__init__,py b/backend/src/backend/primary/routers/parameters/__init__,py similarity index 100% rename from backend/src/fastapi_app/routers/parameters/__init__,py rename to backend/src/backend/primary/routers/parameters/__init__,py diff --git a/backend/src/fastapi_app/routers/parameters/router.py b/backend/src/backend/primary/routers/parameters/router.py similarity index 98% rename from backend/src/fastapi_app/routers/parameters/router.py rename to backend/src/backend/primary/routers/parameters/router.py index f40421cf7..f028e1060 100644 --- a/backend/src/fastapi_app/routers/parameters/router.py +++ b/backend/src/backend/primary/routers/parameters/router.py @@ -12,7 +12,7 @@ ) from src.services.utils.authenticated_user import AuthenticatedUser from src.services.utils.perf_timer import PerfTimer -from src.fastapi_app.auth.auth_helper import AuthHelper +from src.backend.auth.auth_helper import AuthHelper from . import schemas LOGGER = logging.getLogger(__name__) diff --git a/backend/src/fastapi_app/routers/parameters/schemas.py b/backend/src/backend/primary/routers/parameters/schemas.py similarity index 100% rename from backend/src/fastapi_app/routers/parameters/schemas.py rename to backend/src/backend/primary/routers/parameters/schemas.py diff --git a/backend/src/fastapi_app/routers/surface/converters.py b/backend/src/backend/primary/routers/surface/converters.py similarity index 100% rename from backend/src/fastapi_app/routers/surface/converters.py rename to backend/src/backend/primary/routers/surface/converters.py diff --git a/backend/src/fastapi_app/routers/surface/router.py b/backend/src/backend/primary/routers/surface/router.py similarity index 97% rename from backend/src/fastapi_app/routers/surface/router.py rename to backend/src/backend/primary/routers/surface/router.py index 423634edc..b6d0052ef 100644 --- a/backend/src/fastapi_app/routers/surface/router.py +++ b/backend/src/backend/primary/routers/surface/router.py @@ -7,7 +7,7 @@ from src.services.sumo_access.surface_types import StatisticFunction from src.services.utils.authenticated_user import AuthenticatedUser from src.services.utils.perf_timer import PerfTimer -from src.fastapi_app.auth.auth_helper import AuthHelper +from src.backend.auth.auth_helper import AuthHelper from . import converters from . import schemas @@ -162,7 +162,9 @@ def get_statistical_static_surface_data( service_stat_func_to_compute = StatisticFunction.from_string_value(statistic_function) if service_stat_func_to_compute is not None: xtgeo_surf = access.get_statistical_static_surf( - statistic_function=service_stat_func_to_compute, name=name, attribute=attribute + statistic_function=service_stat_func_to_compute, + name=name, + attribute=attribute, ) if not xtgeo_surf: diff --git a/backend/src/fastapi_app/routers/surface/schemas.py b/backend/src/backend/primary/routers/surface/schemas.py similarity index 100% rename from backend/src/fastapi_app/routers/surface/schemas.py rename to backend/src/backend/primary/routers/surface/schemas.py diff --git a/backend/src/fastapi_app/routers/timeseries/converters.py b/backend/src/backend/primary/routers/timeseries/converters.py similarity index 100% rename from backend/src/fastapi_app/routers/timeseries/converters.py rename to backend/src/backend/primary/routers/timeseries/converters.py diff --git a/backend/src/fastapi_app/routers/timeseries/router.py b/backend/src/backend/primary/routers/timeseries/router.py similarity index 99% rename from backend/src/fastapi_app/routers/timeseries/router.py rename to backend/src/backend/primary/routers/timeseries/router.py index b22444628..d51a06930 100644 --- a/backend/src/fastapi_app/routers/timeseries/router.py +++ b/backend/src/backend/primary/routers/timeseries/router.py @@ -8,7 +8,7 @@ from src.services.sumo_access.summary_access import Frequency, SummaryAccess from src.services.utils.authenticated_user import AuthenticatedUser from src.services.sumo_access.generic_types import EnsembleScalarResponse -from src.fastapi_app.auth.auth_helper import AuthHelper +from src.backend.auth.auth_helper import AuthHelper from . import converters from . import schemas diff --git a/backend/src/fastapi_app/routers/timeseries/schemas.py b/backend/src/backend/primary/routers/timeseries/schemas.py similarity index 100% rename from backend/src/fastapi_app/routers/timeseries/schemas.py rename to backend/src/backend/primary/routers/timeseries/schemas.py diff --git a/backend/src/backend/primary/user_session_proxy.py b/backend/src/backend/primary/user_session_proxy.py new file mode 100644 index 000000000..4086dd44e --- /dev/null +++ b/backend/src/backend/primary/user_session_proxy.py @@ -0,0 +1,123 @@ +import os +import asyncio +from typing import Dict, Any + +import httpx +from starlette.requests import Request +from starlette.responses import StreamingResponse +from starlette.background import BackgroundTask + +from src.services.utils.authenticated_user import AuthenticatedUser + +LOCALHOST_DEVELOPMENT = os.environ.get("UVICORN_RELOAD") == "true" + + +class RadixJobScheduler: + """Utility class to help with spawning Radix jobs on demand, + and provide correct URL to communicate with running Radix jobs""" + + def __init__(self, name: str, port: int) -> None: + self._name = name + self._port = port + + # TODO: This should be moved to Redis + # key: user_id, value: name of Radix job instance + self._existing_job_names: Dict[str, str] = {} + + async def _active_running_job(self, user_id: str) -> bool: + """Returns true if there already is a running job for logged in user.""" + + existing_job_name = self._existing_job_names.get(user_id) + if not existing_job_name: + return False + elif LOCALHOST_DEVELOPMENT: + return True + + async with httpx.AsyncClient() as client: + r = await client.get(f"http://{self._name}:{self._port}/api/v1/jobs/{existing_job_name}") + + job = r.json() + + if job.get("status") != "Running" or not job.get("started"): + return False + + try: + httpx.get(f"http://{existing_job_name}:{self._port}/") + except (ConnectionRefusedError, httpx.ConnectError, httpx.ConnectTimeout): + print("User container server not yet up") + return False + + return True + + async def _create_new_job(self, user_id: str) -> None: + """Create a new Radix job by sending request to Radix job scheduler. + If localhost development, simply return already running container with + same name.""" + + if LOCALHOST_DEVELOPMENT: + self._existing_job_names[user_id] = self._name + else: + async with httpx.AsyncClient() as client: + r = await client.post( + f"http://{self._name}:{self._port}/api/v1/jobs", + # Maximum limits in "resources" for a Radix job is as of May 2023 + # the specs of a single Standard_E16as_v4 node, i.e.: + # * vCPU: 16 + # * memory: 128 GiB + # * temp storage (SSD): 256 GiB + # + # As of now our CPU/memory requests are hardcoded below, but in the future maybe + # these could be dynamic based on e.g. the selected ensemble sizess by the user. + json={ + "resources": { + "limits": {"memory": "32GiB", "cpu": "2"}, + "requests": {"memory": "32GiB", "cpu": "1"}, + } + }, + ) + self._existing_job_names[user_id] = r.json()["name"] + + while not await self._active_running_job(user_id): + # It takes a couple of seconds before Radix job uvicorn process has + # started and begins to listen at the end point. + await asyncio.sleep(1) + + async def get_base_url(self, user_id: str) -> str: + """Input is ID of logged in user. Returned value is base URL towards the correct + Radix job""" + if not await self._active_running_job(user_id): + await self._create_new_job(user_id) + + job_name = self._existing_job_names[user_id] + + return f"http://{job_name}:{self._port}" + + +# For now we only have one type of job: +RADIX_JOB_SCHEDULER_INSTANCE = RadixJobScheduler("backend-user-session", 8000) + + +async def proxy_to_user_session(request: Request, authenticated_user: AuthenticatedUser) -> Any: + # Ideally this function should probably be a starlette/FastAPI middleware, but it appears that + # it is not yet possible to put middleware on single routes through decorator like in express.js. + + base_url = await RADIX_JOB_SCHEDULER_INSTANCE.get_base_url(authenticated_user._user_id) + + # See https://github.com/tiangolo/fastapi/discussions/7382: + + client = httpx.AsyncClient(base_url=base_url) + + url = httpx.URL( + path=request.url.path.removeprefix("/api").rstrip("/"), + query=request.url.query.encode("utf-8"), + ) + + job_req = client.build_request(request.method, url, headers=request.headers.raw, content=request.stream()) + job_resp = await client.send(job_req, stream=True) + + return StreamingResponse( + job_resp.aiter_raw(), + status_code=job_resp.status_code, + headers=job_resp.headers, + background=BackgroundTask(job_resp.aclose), + ) diff --git a/backend/src/backend/shared_middleware.py b/backend/src/backend/shared_middleware.py new file mode 100644 index 000000000..a36cf975a --- /dev/null +++ b/backend/src/backend/shared_middleware.py @@ -0,0 +1,24 @@ +from fastapi import FastAPI +from starsessions import SessionMiddleware +from starsessions.stores.redis import RedisStore + +from src.backend import config +from src.backend.auth.enforce_logged_in_middleware import ( + EnforceLoggedInMiddleware, +) + + +def add_shared_middlewares(app: FastAPI) -> None: + + # Add out custom middleware to enforce that user is logged in + # Also redirects to /login endpoint for some select paths + unprotected_paths = ["/logged_in_user", "/alive", "/openapi.json"] + paths_redirected_to_login = ["/", "/alive_protected"] + app.add_middleware( + EnforceLoggedInMiddleware, + unprotected_paths=unprotected_paths, + paths_redirected_to_login=paths_redirected_to_login, + ) + + session_store = RedisStore(config.REDIS_URL) + app.add_middleware(SessionMiddleware, store=session_store) diff --git a/backend/src/backend/user_session/inactivity_shutdown.py b/backend/src/backend/user_session/inactivity_shutdown.py new file mode 100644 index 000000000..a86f49a64 --- /dev/null +++ b/backend/src/backend/user_session/inactivity_shutdown.py @@ -0,0 +1,28 @@ +import os +import time +from threading import Timer +from typing import Callable, Any + +from fastapi import Request, FastAPI + +LOCALHOST_DEVELOPMENT = os.environ.get("UVICORN_RELOAD") == "true" + + +class InactivityShutdown: + def __init__(self, app: FastAPI, inactivity_limit_minutes: int) -> None: + self._time_last_request: float = time.time() + self._inactivity_limit_seconds: int = inactivity_limit_minutes * 60 + + @app.middleware("http") + async def _update_time_last_request(request: Request, call_next: Callable) -> Any: + self._time_last_request = time.time() + return await call_next(request) + + if not LOCALHOST_DEVELOPMENT: + Timer(60.0, self.check_inactivity_threshold).start() + + def check_inactivity_threshold(self) -> None: + if time.time() > self._time_last_request + self._inactivity_limit_seconds: + os._exit(0) + else: + Timer(60.0, self.check_inactivity_threshold).start() diff --git a/backend/src/backend/user_session/main.py b/backend/src/backend/user_session/main.py new file mode 100644 index 000000000..1e9eb364f --- /dev/null +++ b/backend/src/backend/user_session/main.py @@ -0,0 +1,15 @@ +from fastapi import FastAPI + +from src.backend.shared_middleware import add_shared_middlewares +from .inactivity_shutdown import InactivityShutdown +from .routers.general import router as general_router + +app = FastAPI() + +app.include_router(general_router) + +add_shared_middlewares(app) + +# We shut down the user session container after some +# minutes without receiving any new requests: +InactivityShutdown(app, inactivity_limit_minutes=30) diff --git a/backend/src/backend/user_session/routers/general.py b/backend/src/backend/user_session/routers/general.py new file mode 100644 index 000000000..eff3b68eb --- /dev/null +++ b/backend/src/backend/user_session/routers/general.py @@ -0,0 +1,41 @@ +import datetime +from typing import Dict, Union, NamedTuple +import psutil +from fastapi import APIRouter, Depends + +from src.backend.auth.auth_helper import AuthHelper, AuthenticatedUser + +router = APIRouter() + +START_TIME_CONTAINER = datetime.datetime.now() + + +def human_readable(psutil_object: NamedTuple) -> Dict[str, Union[str, Dict[str, str]]]: + return { + key: f"{getattr(psutil_object, key):.1f} %" + if key == "percent" + else f"{getattr(psutil_object, key) / (1024**3):.2f} GiB" + for key in psutil_object._fields + } + + +@router.get("/user_session_container") +async def user_session_container( + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +) -> dict: + """Get information about user session container, like when it was started + together with memory and disk usage. NB! Note that a session container is started + if one is not already running when accessing this endpoint. + + For explanation of the different memory metrics, see e.g. psutil documentation like + * https://psutil.readthedocs.io/en/latest/index.html?highlight=Process()#psutil.virtual_memory + * https://psutil.readthedocs.io/en/latest/index.html?highlight=Process()#psutil.Process + """ + + return { + "username": authenticated_user.get_username(), + "start_time_container": START_TIME_CONTAINER, + "root_disk_system": human_readable(psutil.disk_usage("/")), + "memory_system": human_readable(psutil.virtual_memory()), + "memory_python_process": human_readable(psutil.Process().memory_info()), + } diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 41e73a9a1..5d3ac5b14 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -5,20 +5,49 @@ version: '3.6' services: - frontend: + frontend-prod: build: context: . dockerfile: frontend-prod.Dockerfile ports: - 8080:8080 - backend: + backend-primary: build: context: . dockerfile: backend.Dockerfile ports: - 5000:5000 environment: - - UVICORN_WORKERS=4 + - UVICORN_PORT=5000 + - UVICORN_ENTRYPOINT=src.backend.user_session.main:app - WEBVIZ_CLIENT_SECRET - WEBVIZ_SMDA_RESOURCE_SCOPE + + backend-user-session: + build: + context: . + dockerfile: backend.Dockerfile + ports: + - 8000:8000 + environment: + - UVICORN_PORT=8000 + - UVICORN_ENTRYPOINT=src.backend.user_session.main:app + - WEBVIZ_CLIENT_SECRET + - WEBVIZ_SMDA_RESOURCE_SCOPE + volumes: + - ./backend/src:/home/appuser/backend/src + + redis: + image: "bitnami/redis:6.2.10@sha256:bd42fcdab5959ce2b21b6ea8410d4b3ee87ecb2e320260326ec731ecfcffbd0e" + expose: + - "6379" + environment: + - ALLOW_EMPTY_PASSWORD=yes + volumes: + - redis-data:/data + - redis-conf:/usr/local/etc/redis/redis.conf + +volumes: + redis-data: + redis-conf: diff --git a/docker-compose.yml b/docker-compose.yml index 76ce29dff..8679d9f05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,14 +17,31 @@ services: - ./frontend/theme:/usr/src/app/frontend/theme - ./frontend/index.html:/usr/src/app/frontend/index.html - backend: + backend-primary: build: context: . dockerfile: backend.Dockerfile ports: - 5000:5000 environment: + - UVICORN_PORT=5000 - UVICORN_RELOAD=true + - UVICORN_ENTRYPOINT=src.backend.primary.main:app + - WEBVIZ_CLIENT_SECRET + - WEBVIZ_SMDA_RESOURCE_SCOPE + volumes: + - ./backend/src:/home/appuser/backend/src + + backend-user-session: + build: + context: . + dockerfile: backend.Dockerfile + ports: + - 8000:8000 + environment: + - UVICORN_PORT=8000 + - UVICORN_RELOAD=true + - UVICORN_ENTRYPOINT=src.backend.user_session.main:app - WEBVIZ_CLIENT_SECRET - WEBVIZ_SMDA_RESOURCE_SCOPE volumes: diff --git a/frontend/src/api/services/DefaultService.ts b/frontend/src/api/services/DefaultService.ts index 51965b4a7..8ddbd52c7 100644 --- a/frontend/src/api/services/DefaultService.ts +++ b/frontend/src/api/services/DefaultService.ts @@ -79,6 +79,19 @@ export class DefaultService { }); } + /** + * User Session Container + * Get information about user session container (note that one is started if not already running). + * @returns any Successful Response + * @throws ApiError + */ + public userSessionContainer(): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/user_session_container', + }); + } + /** * Root * @returns string Successful Response diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 88327bb6c..9861957bc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -50,7 +50,7 @@ export default defineConfig(({ mode }) => { port: 8080, proxy: { "/api": { - target: "http://backend:5000", + target: "http://backend-primary:5000", rewrite: (path) => path.replace(/^\/api/, ""), }, }, diff --git a/nginx.conf b/nginx.conf index 98b2a9ed7..0c1c081c1 100644 --- a/nginx.conf +++ b/nginx.conf @@ -48,7 +48,7 @@ http { } location /api/ { - proxy_pass http://backend:5000/; + proxy_pass http://backend-primary:5000/; proxy_set_header Host $host; } diff --git a/radixconfig.yml b/radixconfig.yml index d2bd95b85..99625bac3 100644 --- a/radixconfig.yml +++ b/radixconfig.yml @@ -17,7 +17,7 @@ spec: port: 8080 environmentConfig: - environment: prod - - name: backend + - name: backend-primary image: ghcr.io/equinor/webviz_backend:latest alwaysPullImageOnDeploy: true ports: @@ -33,6 +33,9 @@ spec: envVar: WEBVIZ_SMDA_RESOURCE_SCOPE environmentConfig: - environment: prod + variables: + UVICORN_PORT: 5000 + UVICORN_ENTRYPOINT: src.backend.primary.main:app - name: redis image: bitnami/redis:6.2.10@sha256:bd42fcdab5959ce2b21b6ea8410d4b3ee87ecb2e320260326ec731ecfcffbd0e ports: @@ -42,6 +45,26 @@ spec: ALLOW_EMPTY_PASSWORD: yes environmentConfig: - environment: prod + jobs: + - name: backend-user-session + image: ghcr.io/equinor/webviz_backend:latest + schedulerPort: 8000 + ports: + - name: http + port: 8000 + secretRefs: + azureKeyVaults: + - name: webviz + items: + - name: WEBVIZ-CLIENT-SECRET + envVar: WEBVIZ_CLIENT_SECRET + - name: WEBVIZ-SMDA-RESOURCE-SCOPE + envVar: WEBVIZ_SMDA_RESOURCE_SCOPE + environmentConfig: + - environment: prod + variables: + UVICORN_PORT: 8000 + UVICORN_ENTRYPOINT: src.backend.user_session.main:app dnsAppAlias: environment: prod component: frontend