diff --git a/.gitignore b/.gitignore index 7d1f19b..0f8645b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,4 @@ ui-tests/test-results lib/ # Hatch version -_version.py \ No newline at end of file +_version.py diff --git a/README.md b/README.md index 4ca0874..97a826b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Github Actions Status](https://github.com/plasmabio/tljh-repo2docker/workflows/Tests/badge.svg) -TLJH plugin to build and use Docker images as user environments. The Docker images are built using [`repo2docker`](https://repo2docker.readthedocs.io/en/latest/). +TLJH plugin providing a JupyterHub service to build and use Docker images as user environments. The Docker images are built using [`repo2docker`](https://repo2docker.readthedocs.io/en/latest/). ## Requirements @@ -29,37 +29,141 @@ curl https://tljh.jupyter.org/bootstrap.py | sudo python3 - \ --version 1.0.0 \ --admin test:test \ - --plugin git+https://github.com/plasmabio/tljh-repo2docker@master + --plugin tljh-repo2docker ``` Refer to [The Littlest JupyterHub documentation](http://tljh.jupyter.org/en/latest/topic/customizing-installer.html?highlight=plugins#installing-tljh-plugins) for more info on installing TLJH plugins. +## Configuration + +This Python package is designed for deployment as [a service managed by JupyterHub](https://jupyterhub.readthedocs.io/en/stable/reference/services.html#launching-a-hub-managed-service). The service runs its own Tornado server. Requests will be forwarded to it by the JupyterHub internal proxy from the standard URL `https://{my-hub-url}/services/my-service/`. + +The available settings for this service are: + +- `port`: Port of the service; defaults to 6789 +- `ip`: Internal IP of the service; defaults to 127.0.0.1 +- `default_memory_limit`: Default memory limit of a user server; defaults to `None` +- `default_cpu_limit`: Default CPU limit of a user server; defaults to `None` +- `machine_profiles`: Instead of entering directly the CPU and Memory value, `tljh-repo2docker` can be configured with pre-defined machine profiles and users can only choose from the available option; defaults to `[]` + +Here is an example of registering `tljh_repo2docker`'s service with JupyterHub + +```python +# jupyterhub_config.py + +from tljh_repo2docker import TLJH_R2D_ADMIN_SCOPE + +c.JupyterHub.services.extend( + [ + { + "name": "tljh_repo2docker", + "url": "http://127.0.0.1:6789", # URL must match the `ip` and `port` config + "command": [ + sys.executable, + "-m", + "tljh_repo2docker", + "--ip", + "127.0.0.1", + "--port", + "6789" + ], + "oauth_no_confirm": True, + } + ] +) +# Set required scopes for the service and users +c.JupyterHub.load_roles = [ + { + "description": "Role for tljh_repo2docker service", + "name": "tljh-repo2docker-service", + "scopes": ["read:users", "read:servers", "read:roles:users"], + "services": ["tljh_repo2docker"], + }, + { + "name": "user", + "scopes": [ + "self", + # access to the serve page + "access:services!service=tljh_repo2docker", + ], + }, +] +``` + +By default, only users with an admin role can access the environment builder page and APIs, by leveraging the RBAC system of JupyterHub, non-admin users can also be granted the access right. + +Here is an example of the configuration + +```python +# jupyterhub_config.py + +from tljh_repo2docker import TLJH_R2D_ADMIN_SCOPE + +c.JupyterHub.services.extend( + [ + { + "name": "tljh_repo2docker", + "url": "http://127.0.0.1:6789", + "command": [ + sys.executable, + "-m", + "tljh_repo2docker", + "--ip", + "127.0.0.1", + "--port", + "6789" + ], + "oauth_no_confirm": True, + "oauth_client_allowed_scopes": [ + TLJH_R2D_ADMIN_SCOPE, # Allows this service to check if users have its admin scope. + ], + } + ] +) + +c.JupyterHub.custom_scopes = { + TLJH_R2D_ADMIN_SCOPE: { + "description": "Admin access to tljh_repo2docker", + }, +} + +c.JupyterHub.load_roles = [ + ... # Other role settings + { + "name": 'tljh-repo2docker-service-admin', + "users": ["alice"], + "scopes": [TLJH_R2D_ADMIN_SCOPE], + }, +] + +``` + ## Usage ### List the environments The _Environments_ page shows the list of built environments, as well as the ones currently being built: -![environments](https://user-images.githubusercontent.com/591645/80962805-056df500-8e0e-11ea-81ab-6efc1c97432d.png) +![environments](https://raw.githubusercontent.com/plasmabio/tljh-repo2docker/master/ui-tests/tests/ui.test.ts-snapshots/environment-list-linux.png) ### Add a new environment Just like on [Binder](https://mybinder.org), new environments can be added by clicking on the _Add New_ button and providing a URL to the repository. Optional names, memory, and CPU limits can also be set for the environment: -![add-new](https://user-images.githubusercontent.com/591645/80963115-9fce3880-8e0e-11ea-890b-c9b928f7edb1.png) +![add-new](https://raw.githubusercontent.com/plasmabio/tljh-repo2docker/master/ui-tests/tests/ui.test.ts-snapshots/environment-dialog-linux.png) ### Follow the build logs Clicking on the _Logs_ button will open a new dialog with the build logs: -![logs](https://user-images.githubusercontent.com/591645/82306574-86f18580-99bf-11ea-984b-4749ddde15e7.png) +![logs](https://raw.githubusercontent.com/plasmabio/tljh-repo2docker/master/ui-tests/tests/ui.test.ts-snapshots/environment-console-linux.png) ### Select an environment Once ready, the environments can be selected from the JupyterHub spawn page: -![select-env](https://user-images.githubusercontent.com/591645/81152248-10e22d00-8f82-11ea-9b5f-5831d8f7d085.png) +![select-env](https://raw.githubusercontent.com/plasmabio/tljh-repo2docker/master/ui-tests/tests/ui.test.ts-snapshots/servers-dialog-linux.png) ### Private Repositories @@ -67,26 +171,42 @@ Once ready, the environments can be selected from the JupyterHub spawn page: It is possible to provide the `username` and `password` in the `Credentials` section of the form: -![image](https://user-images.githubusercontent.com/591645/107362654-51567480-6ad9-11eb-93be-74d3b1c37828.png) +![image](https://raw.githubusercontent.com/plasmabio/tljh-repo2docker/master/ui-tests/tests/ui.test.ts-snapshots/environment-dialog-linux.png) On GitHub and GitLab, a user might have to first create an access token with `read` access to use as the password: ![image](https://user-images.githubusercontent.com/591645/107350843-39c3bf80-6aca-11eb-8b82-6fa95ba4c7e4.png) -### Set CPU and Memory via machine profiles +### Machine profiles -Instead of entering directly the CPU and Memory value, `tljh-repo2docker` can be configured with pre-defined machine profiles and users can only choose from the available options. The following snippet will add 3 machines with labels `Small`, `Medium` and `Large` to the profile list: +Instead of entering directly the CPU and Memory value, `tljh-repo2docker` can be configured with pre-defined machine profiles and users can only choose from the available options. The following configuration will add 3 machines with labels Small, Medium and Large to the profile list: ```python -from tljh.configurer import apply_config, load_config - -tljh_config = load_config() -tljh_config["limits"]["machine_profiles"] = [ - {"label": "Small", "cpu": 2, "memory": 2}, - {"label": "Medium", "cpu": 4, "memory": 4}, - {"label": "Large", "cpu": 8, "memory": 8}, -] -apply_config(tljh_config, c) +c.JupyterHub.services.extend( + [ + { + "name": "tljh_repo2docker", + "url": "http://127.0.0.1:6789", + "command": [ + sys.executable, + "-m", + "tljh_repo2docker", + "--ip", + "127.0.0.1", + "--port", + "6789", + "--machine_profiles", + '{"label": "Small", "cpu": 2, "memory": 2}', + "--machine_profiles", + '{"label": "Medium", "cpu": 4, "memory": 4}', + "--machine_profiles", + '{"label": "Large", "cpu": 8, "memory": 8}' + + ], + "oauth_no_confirm": True, + } + ] +) ``` ![image](https://github.com/plasmabio/tljh-repo2docker/assets/4451292/c1f0231e-a02d-41dc-85e0-97a97ffa0311) diff --git a/jupyterhub_config.py b/jupyterhub_config.py index 8502436..ec27fd9 100644 --- a/jupyterhub_config.py +++ b/jupyterhub_config.py @@ -3,34 +3,75 @@ and overrides some of the default values from the plugin. """ -import getpass - from jupyterhub.auth import DummyAuthenticator from tljh.configurer import apply_config, load_config -from tljh_repo2docker import tljh_custom_jupyterhub_config - -c.JupyterHub.services = [] +from tljh_repo2docker import tljh_custom_jupyterhub_config, TLJH_R2D_ADMIN_SCOPE +import sys tljh_config = load_config() -# set default limits in the TLJH config in memory -# tljh_config["limits"]["memory"] = "2G" -# tljh_config["limits"]["cpu"] = 2 - -# set CPU and memory based on machine profiles -tljh_config["limits"]["machine_profiles"] = [ - {"label": "Small", "cpu": 2, "memory": 2}, - {"label": "Medium", "cpu": 4, "memory": 4}, - {"label": "Large", "cpu": 8, "memory": 8}, -] - apply_config(tljh_config, c) tljh_custom_jupyterhub_config(c) c.JupyterHub.authenticator_class = DummyAuthenticator -user = getpass.getuser() -c.Authenticator.admin_users = {user, "alice"} c.JupyterHub.allow_named_servers = True c.JupyterHub.ip = "0.0.0.0" + +c.JupyterHub.services.extend( + [ + { + "name": "tljh_repo2docker", + "url": "http://127.0.0.1:6789", + "command": [ + sys.executable, + "-m", + "tljh_repo2docker", + "--ip", + "127.0.0.1", + "--port", + "6789", + "--machine_profiles", + '{"label": "Small", "cpu": 2, "memory": 2}', + "--machine_profiles", + '{"label": "Medium", "cpu": 4, "memory": 4}', + "--machine_profiles", + '{"label": "Large", "cpu": 8, "memory": 8}' + + ], + "oauth_no_confirm": True, + "oauth_client_allowed_scopes": [ + TLJH_R2D_ADMIN_SCOPE, + ], + } + ] +) + +c.JupyterHub.custom_scopes = { + TLJH_R2D_ADMIN_SCOPE: { + "description": "Admin access to myservice", + }, +} + +c.JupyterHub.load_roles = [ + { + "description": "Role for tljh_repo2docker service", + "name": "tljh-repo2docker-service", + "scopes": ["read:users", "read:servers", "read:roles:users"], + "services": ["tljh_repo2docker"], + }, + { + "name": 'tljh-repo2docker-service-admin', + "users": ["alice"], + "scopes": [TLJH_R2D_ADMIN_SCOPE], + }, + { + "name": "user", + "scopes": [ + "self", + # access to the env page + "access:services!service=tljh_repo2docker", + ], + }, +] diff --git a/pyproject.toml b/pyproject.toml index 1c212f6..8a3f29f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "aiodocker~=0.19", "dockerspawner~=12.1", "jupyter_client>=6.1,<8", + "httpx" ] dynamic = ["version"] license = {file = "LICENSE"} diff --git a/src/common/AxiosContext.tsx b/src/common/AxiosContext.tsx index eeae042..7fffa60 100644 --- a/src/common/AxiosContext.tsx +++ b/src/common/AxiosContext.tsx @@ -1,7 +1,10 @@ import { createContext, useContext } from 'react'; import { AxiosClient } from './axiosclient'; -export const AxiosContext = createContext(new AxiosClient({})); +export const AxiosContext = createContext<{ + hubClient: AxiosClient; + serviceClient: AxiosClient; +}>({ hubClient: new AxiosClient({}), serviceClient: new AxiosClient({}) }); export const useAxios = () => { return useContext(AxiosContext); diff --git a/src/common/JupyterhubContext.ts b/src/common/JupyterhubContext.ts index 938df46..45b4dc3 100644 --- a/src/common/JupyterhubContext.ts +++ b/src/common/JupyterhubContext.ts @@ -2,14 +2,16 @@ import { createContext, useContext } from 'react'; export interface IJupyterhubData { baseUrl: string; - prefix: string; + servicePrefix: string; + hubPrefix: string; user: string; adminAccess: boolean; xsrfToken: string; } export const JupyterhubContext = createContext({ baseUrl: '', - prefix: '', + servicePrefix: '', + hubPrefix: '', user: '', adminAccess: false, xsrfToken: '' diff --git a/src/environments/App.tsx b/src/environments/App.tsx index cb8f417..587cfd3 100644 --- a/src/environments/App.tsx +++ b/src/environments/App.tsx @@ -19,14 +19,22 @@ export interface IAppProps { } export default function App(props: IAppProps) { const jhData = useJupyterhub(); - const axios = useMemo(() => { - const baseUrl = jhData.baseUrl; + + const hubClient = useMemo(() => { + const baseUrl = jhData.hubPrefix; + const xsrfToken = jhData.xsrfToken; + return new AxiosClient({ baseUrl, xsrfToken }); + }, [jhData]); + + const serviceClient = useMemo(() => { + const baseUrl = jhData.servicePrefix; const xsrfToken = jhData.xsrfToken; return new AxiosClient({ baseUrl, xsrfToken }); }, [jhData]); + return ( - + { - const response = await axios.request({ + const response = await axios.serviceClient.request({ method: 'delete', prefix: API_PREFIX, path: ENV_PREFIX, diff --git a/src/environments/main.tsx b/src/environments/main.tsx index aae383a..4f2d1f7 100644 --- a/src/environments/main.tsx +++ b/src/environments/main.tsx @@ -22,15 +22,22 @@ if (rootElement) { configData = JSON.parse(dataElement.textContent || '') as IAppProps; } const jhData = (window as any).jhdata; - const { base_url, xsrf_token, user, prefix, admin_access } = jhData; - + const { + base_url, + xsrf_token, + user, + hub_prefix, + service_prefix, + admin_access + } = jhData; root.render( diff --git a/src/servers/App.tsx b/src/servers/App.tsx index 4dd62d6..4eff3c2 100644 --- a/src/servers/App.tsx +++ b/src/servers/App.tsx @@ -21,15 +21,22 @@ export interface IAppProps { } export default function App(props: IAppProps) { const jhData = useJupyterhub(); - const axios = useMemo(() => { - const baseUrl = jhData.baseUrl; + + const hubClient = useMemo(() => { + const baseUrl = jhData.hubPrefix; + const xsrfToken = jhData.xsrfToken; + return new AxiosClient({ baseUrl, xsrfToken }); + }, [jhData]); + + const serviceClient = useMemo(() => { + const baseUrl = jhData.servicePrefix; const xsrfToken = jhData.xsrfToken; return new AxiosClient({ baseUrl, xsrfToken }); }, [jhData]); return ( - + { - const { user, baseUrl, xsrfToken } = jhData; + const { user, hubPrefix, xsrfToken } = jhData; let progressUrl = urlJoin( - baseUrl, + hubPrefix, 'api', 'users', user, @@ -54,7 +54,7 @@ function _OpenServerButton(props: IOpenServerButton) { const data = new FormData(); data.append('image', imageName); try { - await axios.request({ + await axios.hubClient.request({ method: 'post', prefix: SPAWN_PREFIX, path: `${jhData.user}/${props.serverName}`, diff --git a/src/servers/RemoveServerButton.tsx b/src/servers/RemoveServerButton.tsx index 8fbea96..ebc2250 100644 --- a/src/servers/RemoveServerButton.tsx +++ b/src/servers/RemoveServerButton.tsx @@ -22,7 +22,7 @@ function _RemoveServerButton(props: IRemoveServerButton) { path = `users/${jhData.user}/server`; } try { - await axios.request({ + await axios.hubClient.request({ method: 'delete', prefix: API_PREFIX, path, diff --git a/src/servers/main.tsx b/src/servers/main.tsx index 1943b6d..492c286 100644 --- a/src/servers/main.tsx +++ b/src/servers/main.tsx @@ -31,14 +31,22 @@ if (rootElement) { configData = JSON.parse(dataElement.textContent || '') as IAppProps; } const jhData = (window as any).jhdata; - const { base_url, xsrf_token, user, prefix, admin_access } = jhData; + const { + base_url, + xsrf_token, + user, + hub_prefix, + service_prefix, + admin_access + } = jhData; root.render( diff --git a/tljh_repo2docker/__init__.py b/tljh_repo2docker/__init__.py index 0d0e031..cbf6269 100644 --- a/tljh_repo2docker/__init__.py +++ b/tljh_repo2docker/__init__.py @@ -1,30 +1,23 @@ -import os -from typing import Any, Coroutine, Optional - from aiodocker import Docker from dockerspawner import DockerSpawner -from jinja2 import Environment, BaseLoader +from jinja2 import BaseLoader, Environment from jupyter_client.localinterfaces import public_ips -from jupyterhub.handlers.static import CacheControlStaticFilesHandler from jupyterhub.traitlets import ByteSpecification -from tljh.hooks import hookimpl from tljh.configurer import load_config +from tljh.hooks import hookimpl from traitlets import Unicode from traitlets.config import Configurable -from .builder import BuildHandler from .docker import list_images -from .servers import ServersHandler -from .images import ImagesHandler -from .logs import LogsHandler # Default CPU period # See: https://docs.docker.com/config/containers/resource_constraints/#limit-a-containers-access-to-memory#configure-the-default-cfs-scheduler CPU_PERIOD = 100_000 +TLJH_R2D_ADMIN_SCOPE = "custom:tljh_repo2docker:admin" -class SpawnerMixin(Configurable): +class SpawnerMixin(Configurable): """ Mixin for spawners that derive from DockerSpawner, to use local Docker images built with tljh-repo2docker. @@ -135,7 +128,6 @@ async def set_limits(self): imagename = self.user_options.get("image") async with Docker() as docker: image = await docker.images.inspect(imagename) - mem_limit = image["ContainerConfig"]["Labels"].get( "tljh_repo2docker.mem_limit", None ) @@ -175,48 +167,12 @@ def tljh_custom_jupyterhub_config(c): c.JupyterHub.cleanup_servers = False c.JupyterHub.spawner_class = Repo2DockerSpawner - # add extra templates for the service UI - c.JupyterHub.template_paths.insert( - 0, os.path.join(os.path.dirname(__file__), "templates") - ) - # spawner c.DockerSpawner.cmd = ["jupyterhub-singleuser"] c.DockerSpawner.pull_policy = "Never" c.DockerSpawner.remove = True - # fetch limits from the TLJH config - tljh_config = load_config() - limits = tljh_config["limits"] - cpu_limit = limits["cpu"] - mem_limit = limits["memory"] - - c.JupyterHub.tornado_settings.update( - {"default_cpu_limit": cpu_limit, "default_mem_limit": mem_limit} - ) - - machine_profiles = limits.get("machine_profiles", []) - - c.JupyterHub.tornado_settings.update( - {"machine_profiles": machine_profiles} - ) - - # register the handlers to manage the user images - c.JupyterHub.extra_handlers.extend( - [ - (r"servers", ServersHandler), - (r"environments", ImagesHandler), - (r"api/environments", BuildHandler), - (r"api/environments/([^/]+)/logs", LogsHandler), - ( - r"environments-static/(.*)", - CacheControlStaticFilesHandler, - {"path": os.path.join(os.path.dirname(__file__), "static")}, - ), - ] - ) - @hookimpl def tljh_extra_hub_pip_packages(): - return ["dockerspawner~=0.11", "jupyter_client~=6.1", "aiodocker~=0.19"] + return ["dockerspawner~=0.11", "jupyter_client>=6.1,<8", "aiodocker~=0.19"] diff --git a/tljh_repo2docker/__main__.py b/tljh_repo2docker/__main__.py new file mode 100644 index 0000000..981ed24 --- /dev/null +++ b/tljh_repo2docker/__main__.py @@ -0,0 +1,4 @@ +if __name__ == "__main__": + from .app import main + + main() diff --git a/tljh_repo2docker/app.py b/tljh_repo2docker/app.py new file mode 100644 index 0000000..9254cbc --- /dev/null +++ b/tljh_repo2docker/app.py @@ -0,0 +1,225 @@ +import logging +import os +import socket +import typing as tp +from pathlib import Path + +from jinja2 import Environment, PackageLoader +from jupyterhub.app import DATA_FILES_PATH +from jupyterhub.handlers.static import LogoHandler +from jupyterhub.utils import url_path_join +from tornado import ioloop, web +from traitlets import Dict, Int, List, Unicode, default, validate +from traitlets.config.application import Application + +from .builder import BuildHandler +from .environments import EnvironmentsHandler +from .logs import LogsHandler +from .servers import ServersHandler + +if os.environ.get("JUPYTERHUB_API_TOKEN"): + from jupyterhub.services.auth import HubOAuthCallbackHandler +else: + + class HubOAuthCallbackHandler: + def get(self): + pass + + +HERE = Path(__file__).parent + + +class TljhRepo2Docker(Application): + name = Unicode("tljh-repo2docker") + + port = Int(6789, help="Port of the service", config=True) + + base_url = Unicode(help="JupyterHub base URL", config=True) + + @default("base_url") + def _default_base_url(self): + return os.environ.get("JUPYTERHUB_BASE_URL", "/") + + service_prefix = Unicode(help="JupyterHub service prefix", config=True) + + @default("service_prefix") + def _default_api_prefix(self): + return os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") + + ip = Unicode( + "localhost", + config=True, + help="The IP address of the service.", + ) + + @default("ip") + def _default_ip(self): + """Return localhost if available, 127.0.0.1 otherwise.""" + s = socket.socket() + try: + s.bind(("localhost", 0)) + except socket.error as e: + self.log.warning( + "Cannot bind to localhost, using 127.0.0.1 as default ip\n%s", e + ) + return "127.0.0.1" + else: + s.close() + return "localhost" + + @validate("ip") + def _validate_ip(self, proposal): + value = proposal["value"] + if value == "*": + value = "" + return value + + template_paths = List( + trait=Unicode, + default_value=None, + allow_none=True, + help="Paths to search for jinja templates, before using the default templates.", + config=True, + ) + + logo_file = Unicode( + "", + help="Specify path to a logo image to override the Jupyter logo in the banner.", + config=True, + ) + + @default("logo_file") + def _logo_file_default(self): + return str(HERE / "static/images/jupyterhub-80.png") + + tornado_settings = Dict( + {}, + config=True, + help="Extra settings to apply to tornado application, e.g. headers, ssl, etc", + ) + + @default("log_level") + def _default_log_level(self): + return logging.INFO + + machine_profiles = List( + default_value=[], trait=Dict, config=True, help="Pre-defined machine profiles" + ) + + default_cpu_limit = Unicode( + None, config=True, help="Default CPU limit.", allow_none=True + ) + + default_memory_limit = Unicode( + None, + config=True, + help="Default Memory limit.", + allow_none=True, + ) + + aliases = { + "port": "TljhRepo2Docker.port", + "ip": "TljhRepo2Docker.ip", + "default_memory_limit": "TljhRepo2Docker.default_memory_limit", + "default_cpu_limit": "TljhRepo2Docker.default_cpu_limit", + "machine_profiles": "TljhRepo2Docker.machine_profiles", + } + + def init_settings(self) -> tp.Dict: + """Initialize settings for the service application.""" + static_path = DATA_FILES_PATH + "/static/" + static_url_prefix = self.service_prefix + "static/" + env_opt = {"autoescape": True} + + env = Environment( + loader=PackageLoader("tljh_repo2docker"), + **env_opt, + ) + + settings = dict( + log=self.log, + template_path=str(HERE / "templates"), + static_path=static_path, + static_url_prefix=static_url_prefix, + jinja2_env=env, + cookie_secret=os.urandom(32), + base_url=self.base_url, + hub_prefix=url_path_join(self.base_url, "/hub/"), + service_prefix=self.service_prefix, + default_mem_limit=self.default_memory_limit, + default_cpu_limit=self.default_cpu_limit, + machine_profiles=self.machine_profiles, + ) + return settings + + def init_handlers(self) -> tp.List: + """Initialize handlers for service application.""" + handlers = [] + static_path = str(HERE / "static") + server_url = url_path_join(self.service_prefix, r"servers") + handlers.extend( + [ + ( + url_path_join(self.service_prefix, "logo"), + LogoHandler, + {"path": self.logo_file}, + ), + ( + url_path_join(self.service_prefix, r"/service_static/(.*)"), + web.StaticFileHandler, + {"path": static_path}, + ), + ( + url_path_join(self.service_prefix, "oauth_callback"), + HubOAuthCallbackHandler, + ), + (self.service_prefix, web.RedirectHandler, {"url": server_url}), + (server_url, ServersHandler), + ( + url_path_join(self.service_prefix, r"environments"), + EnvironmentsHandler, + ), + (url_path_join(self.service_prefix, r"api/environments"), BuildHandler), + ( + url_path_join( + self.service_prefix, r"api/environments/([^/]+)/logs" + ), + LogsHandler, + ), + ] + ) + + return handlers + + def make_app(self) -> web.Application: + """Create the tornado web application. + Returns: + The tornado web application. + """ + + application = web.Application() + application.listen(self.port, self.ip) + return application + + def start(self): + """Start the server.""" + settings = self.init_settings() + + self.app = web.Application(**settings) + self.app.settings.update(self.tornado_settings) + handlers = self.init_handlers() + self.app.add_handlers(".*$", handlers) + + self.app.listen(self.port, self.ip) + self.ioloop = ioloop.IOLoop.current() + try: + self.log.info( + f"tljh-repo2docker service listening on {self.ip}:{self.port}" + ) + self.log.info("Press Ctrl+C to stop") + self.ioloop.start() + except KeyboardInterrupt: + self.log.info("Stopping...") + + +main = TljhRepo2Docker.launch_instance diff --git a/tljh_repo2docker/base.py b/tljh_repo2docker/base.py new file mode 100644 index 0000000..98fb7da --- /dev/null +++ b/tljh_repo2docker/base.py @@ -0,0 +1,158 @@ +import functools +import json +import os +from http.client import responses + +from httpx import AsyncClient +from jinja2 import Template +from jupyterhub.services.auth import HubOAuthenticated +from jupyterhub.utils import url_path_join +from tornado import web + +from tljh_repo2docker import TLJH_R2D_ADMIN_SCOPE + +from .model import UserModel + + +def require_admin_role(func): + """decorator to require admin role to perform an action""" + + @functools.wraps(func) + async def wrapped_func(self, *args, **kwargs): + user = await self.fetch_user() + if not user.admin: + raise web.HTTPError(status_code=404, reason="Unauthorized.") + else: + return await func(self, *args, **kwargs) + + return wrapped_func + + +class BaseHandler(HubOAuthenticated, web.RequestHandler): + """ + Base handler for tljh_repo2docker service + """ + + _client = None + + @property + def client(self): + if not BaseHandler._client: + api_url = os.environ.get("JUPYTERHUB_API_URL", "") + api_token = os.environ.get("JUPYTERHUB_API_TOKEN", None) + BaseHandler._client = AsyncClient( + base_url=api_url, + headers={f"Authorization": f"Bearer {api_token}"}, + ) + return BaseHandler._client + + async def fetch_user(self) -> UserModel: + user = self.current_user + url = url_path_join("users", user["name"]) + response = await self.client.get(url + "?include_stopped_servers") + user_model: dict = response.json() + user_model.setdefault("name", user["name"]) + user_model.setdefault("servers", {}) + user_model.setdefault("roles", []) + user_model.setdefault("admin", False) + + if not user_model["admin"]: + if "admin" in user_model["roles"] or TLJH_R2D_ADMIN_SCOPE in user["scopes"]: + user_model["admin"] = True + + return UserModel.from_dict(user_model) + + def get_template(self, name: str) -> Template: + """Return the jinja template object for a given name + Args: + name: Template name + Returns: + jinja2.Template object + """ + return self.settings["jinja2_env"].get_template(name) + + async def render_template(self, name: str, **kwargs) -> str: + """Render the given template with the provided arguments + Args: + name: Template name + **kwargs: Template arguments + Returns: + The generated template + """ + user = await self.fetch_user() + base_url = self.settings.get("base_url", "/") + template_ns = dict( + service_prefix=self.settings.get("service_prefix", "/"), + hub_prefix=self.settings.get("hub_prefix", "/"), + base_url=base_url, + logout_url=self.settings.get( + "logout_url", url_path_join(base_url, "logout") + ), + static_url=self.static_url, + xsrf_token=self.xsrf_token.decode("ascii"), + user=user, + admin_access=user.admin, + ) + template_ns.update(kwargs) + template = self.get_template(name) + return template.render(**template_ns) + + def get_json_body(self): + """Return the body of the request as JSON data.""" + if not self.request.body: + return None + body = self.request.body.strip().decode("utf-8") + try: + model = json.loads(body) + except Exception: + self.log.debug("Bad JSON: %r", body) + self.log.error("Couldn't parse JSON", exc_info=True) + raise web.HTTPError(400, "Invalid JSON in body of request") + return model + + def check_xsrf_cookie(self): + """ + Copy from https://github.com/jupyterhub/jupyterhub/blob/main/jupyterhub/apihandlers/base.py#L89 + """ + if not hasattr(self, "_jupyterhub_user"): + return + if self._jupyterhub_user is None and "Origin" not in self.request.headers: + return + if getattr(self, "_token_authenticated", False): + # if token-authenticated, ignore XSRF + return + return super().check_xsrf_cookie() + + def write_error(self, status_code, **kwargs): + """Write JSON errors instead of HTML""" + exc_info = kwargs.get("exc_info") + message = "" + exception = None + status_message = responses.get(status_code, "Unknown Error") + if exc_info: + exception = exc_info[1] + # get the custom message, if defined + try: + message = exception.log_message % exception.args + except Exception: + pass + + # construct the custom reason, if defined + reason = getattr(exception, "reason", "") + if reason: + status_message = reason + + self.set_header("Content-Type", "application/json") + if isinstance(exception, web.HTTPError): + # allow setting headers from exceptions + # since exception handler clears headers + headers = getattr(exception, "headers", None) + if headers: + for key, value in headers.items(): + self.set_header(key, value) + # Content-Length must be recalculated. + self.clear_header("Content-Length") + + self.write( + json.dumps({"status": status_code, "message": message or status_message}) + ) diff --git a/tljh_repo2docker/builder.py b/tljh_repo2docker/builder.py index 9679346..6e12472 100644 --- a/tljh_repo2docker/builder.py +++ b/tljh_repo2docker/builder.py @@ -2,22 +2,21 @@ import re from aiodocker import Docker, DockerError -from jupyterhub.apihandlers import APIHandler -from jupyterhub.scopes import needs_scope from tornado import web +from .base import BaseHandler, require_admin_role from .docker import build_image IMAGE_NAME_RE = r"^[a-z0-9-_]+$" -class BuildHandler(APIHandler): +class BuildHandler(BaseHandler): """ Handle requests to build user environments as Docker images """ @web.authenticated - @needs_scope('admin-ui') + @require_admin_role async def delete(self): data = self.get_json_body() name = data["name"] @@ -28,10 +27,11 @@ async def delete(self): raise web.HTTPError(e.status, e.message) self.set_status(200) + self.set_header("content-type", "application/json") self.finish(json.dumps({"status": "ok"})) @web.authenticated - @needs_scope('admin-ui') + @require_admin_role async def post(self): data = self.get_json_body() repo = data["repo"] @@ -68,12 +68,12 @@ async def post(self): if buildargs: for barg in buildargs.split("\n"): if "=" not in barg: - raise web.HTTPError( - 400, - "Invalid build argument format" - ) + raise web.HTTPError(400, "Invalid build argument format") extra_buildargs.append(barg) - await build_image(repo, ref, name, memory, cpu, username, password, extra_buildargs) + await build_image( + repo, ref, name, memory, cpu, username, password, extra_buildargs + ) self.set_status(200) + self.set_header("content-type", "application/json") self.finish(json.dumps({"status": "ok"})) diff --git a/tljh_repo2docker/docker.py b/tljh_repo2docker/docker.py index c934986..f23a3ac 100644 --- a/tljh_repo2docker/docker.py +++ b/tljh_repo2docker/docker.py @@ -1,5 +1,4 @@ import json - from urllib.parse import urlparse from aiodocker import Docker @@ -55,8 +54,14 @@ async def list_containers(): async def build_image( - repo, ref, name="", memory=None, cpu=None, username=None, password=None, - extra_buildargs=None + repo, + ref, + name="", + memory=None, + cpu=None, + username=None, + password=None, + extra_buildargs=None, ): """ Build an image given a repo, ref and limits @@ -96,16 +101,10 @@ async def build_image( ] for label in labels: - cmd += [ - "--label", - label - ] + cmd += ["--label", label] for barg in extra_buildargs or []: - cmd += [ - "--build-arg", - barg - ] + cmd += ["--build-arg", barg] cmd.append(repo) diff --git a/tljh_repo2docker/images.py b/tljh_repo2docker/environments.py similarity index 82% rename from tljh_repo2docker/images.py rename to tljh_repo2docker/environments.py index 58919d5..27044e2 100644 --- a/tljh_repo2docker/images.py +++ b/tljh_repo2docker/environments.py @@ -1,18 +1,18 @@ from inspect import isawaitable -from jupyterhub.handlers.base import BaseHandler -from jupyterhub.scopes import needs_scope + from tornado import web +from .base import BaseHandler, require_admin_role from .docker import list_containers, list_images -class ImagesHandler(BaseHandler): +class EnvironmentsHandler(BaseHandler): """ Handler to show the list of environments as Docker images """ @web.authenticated - @needs_scope('admin-ui') + @require_admin_role async def get(self): images = await list_images() containers = await list_containers() @@ -21,7 +21,7 @@ async def get(self): images=images + containers, default_mem_limit=self.settings.get("default_mem_limit"), default_cpu_limit=self.settings.get("default_cpu_limit"), - machine_profiles=self.settings.get("machine_profiles"), + machine_profiles=self.settings.get("machine_profiles", []), ) if isawaitable(result): self.write(await result) diff --git a/tljh_repo2docker/logs.py b/tljh_repo2docker/logs.py index 7c87e16..287d65a 100644 --- a/tljh_repo2docker/logs.py +++ b/tljh_repo2docker/logs.py @@ -1,18 +1,19 @@ import json from aiodocker import Docker -from jupyterhub.apihandlers import APIHandler -from jupyterhub.scopes import needs_scope from tornado import web from tornado.iostream import StreamClosedError +from .base import BaseHandler, require_admin_role -class LogsHandler(APIHandler): + +class LogsHandler(BaseHandler): """ Expose a handler to follow the build logs. """ + @web.authenticated - @needs_scope('admin-ui') + @require_admin_role async def get(self, name): self.set_header("Content-Type", "text/event-stream") self.set_header("Cache-Control", "no-cache") diff --git a/tljh_repo2docker/model.py b/tljh_repo2docker/model.py new file mode 100644 index 0000000..ec4f5d6 --- /dev/null +++ b/tljh_repo2docker/model.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, fields + + +@dataclass +class UserModel: + + name: str + admin: bool + servers: dict + roles: list + + @classmethod + def from_dict(self, kwargs_dict: dict): + field_names = set(f.name for f in fields(UserModel)) + new_kwargs = {k: v for k, v in kwargs_dict.items() if k in field_names} + return UserModel(**new_kwargs) + + def all_spawners(self) -> list: + sp = [] + for server in self.servers.values(): + if len(server["name"]) > 0: + sp.append( + { + "name": server.get("name", ""), + "url": server.get("url", ""), + "last_activity": server.get("last_activity", None), + "active": bool( + server.get("pending", None) or server.get("ready", False) + ), + "user_options": server.get("user_options", None), + } + ) + return sp diff --git a/tljh_repo2docker/servers.py b/tljh_repo2docker/servers.py index 76095f3..8a84c83 100644 --- a/tljh_repo2docker/servers.py +++ b/tljh_repo2docker/servers.py @@ -1,9 +1,8 @@ from inspect import isawaitable -from typing import Any, Dict -from jupyterhub.orm import Spawner -from jupyterhub.handlers.base import BaseHandler + from tornado import web +from .base import BaseHandler from .docker import list_images @@ -15,59 +14,22 @@ class ServersHandler(BaseHandler): @web.authenticated async def get(self): images = await list_images() - user = self.current_user - if user.running: - # trigger poll_and_notify event in case of a server that died - await user.spawner.poll_and_notify() - auth_state = await user.get_auth_state() - named_spawners = user.all_spawners(include_default=False) - server_data = [] - for sp in named_spawners: - server_data.append( - self._spawner_to_server_data(sp, user) - ) - try: - named_server_limit = await self.get_current_user_named_server_limit() - except Exception: - named_server_limit = 0 + user_data = await self.fetch_user() + + server_data = user_data.all_spawners() + + named_server_limit = 0 result = self.render_template( "servers.html", images=images, - allow_named_servers=self.allow_named_servers, + allow_named_servers=True, named_server_limit_per_user=named_server_limit, server_data=server_data, - default_server_data=self._spawner_to_server_data(user.spawner, user), - auth_state=auth_state, + default_server_data={}, + user_is_admin=True, ) if isawaitable(result): self.write(await result) else: self.write(result) - - def _spawner_to_server_data(self, sp: Spawner, user: Any) -> Dict: - data = { - "name": sp.name, - } - try: - data["url"] = user.server_url(sp.name) - except Exception: - data["url"] = "" - try: - data["last_activity"] = sp.last_activity.isoformat() + "Z" - except Exception: - data["last_activity"] = "" - - try: - data["active"] = sp.active - except Exception: - data["active"] = False - - try: - if sp.user_options: - data["user_options"] = sp.user_options - else: - data["user_options"] = {} - except Exception: - data["user_options"] = {} - return data diff --git a/tljh_repo2docker/static/images/jupyterhub-80.png b/tljh_repo2docker/static/images/jupyterhub-80.png new file mode 100644 index 0000000..65d231a Binary files /dev/null and b/tljh_repo2docker/static/images/jupyterhub-80.png differ diff --git a/tljh_repo2docker/templates/admin.html b/tljh_repo2docker/templates/admin.html deleted file mode 100644 index c579ed8..0000000 --- a/tljh_repo2docker/templates/admin.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "templates/admin.html" %} - -{% block thead %} -Image -{{ super() }} -{% endblock thead %} - -{% block user_row %} - -{# TODO: move this td after the call to super() so it's at the end of the table #} -{# after this PR is merged and a new version released: https://github.com/jupyterhub/jupyterhub/pull/3015 #} -{{ spawner.image }} -{{ super() }} - -{% endblock user_row %} \ No newline at end of file diff --git a/tljh_repo2docker/templates/images.html b/tljh_repo2docker/templates/images.html index f2763a8..0c54591 100644 --- a/tljh_repo2docker/templates/images.html +++ b/tljh_repo2docker/templates/images.html @@ -3,6 +3,6 @@ - + {% endblock %} diff --git a/tljh_repo2docker/templates/page.html b/tljh_repo2docker/templates/page.html index 7553763..3e2a194 100644 --- a/tljh_repo2docker/templates/page.html +++ b/tljh_repo2docker/templates/page.html @@ -1,15 +1,120 @@ -{% extends "templates/page.html" %} - -{% block nav_bar_left_items %} -
  • Servers
  • -
  • Token
  • -{% if user.admin %} -
  • Admin
  • -
  • Environments
  • -{% if services %} -{% for service in services %} -
  • {{service.name}}
  • -{% endfor %} -{% endif %} -{% endif %} -{% endblock %} \ No newline at end of file + + + + + + {% block title %}JupyterHub{% endblock %} + + + + {% block stylesheet %} {% endblock %} {% + block favicon %} {% endblock %} + + + + {% block meta %} {% endblock %} + + + + + + {% block nav_bar %} + + {% endblock %} {% block main %} {% endblock %} {% block footer %} {% + endblock %} + + \ No newline at end of file diff --git a/tljh_repo2docker/templates/servers.html b/tljh_repo2docker/templates/servers.html index 2ab380b..5a2637b 100644 --- a/tljh_repo2docker/templates/servers.html +++ b/tljh_repo2docker/templates/servers.html @@ -5,6 +5,6 @@ "images": {{ images | tojson }}, "allow_named_servers": {{allow_named_servers | tojson}}, "named_server_limit_per_user": {{named_server_limit_per_user}}, "server_data": {{ server_data| tojson }}, "default_server_data": {{ default_server_data| tojson }} } - + -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/tljh_repo2docker/tests/conftest.py b/tljh_repo2docker/tests/conftest.py index 5b7ee87..602f765 100644 --- a/tljh_repo2docker/tests/conftest.py +++ b/tljh_repo2docker/tests/conftest.py @@ -1,9 +1,11 @@ -import pytest +import sys +import pytest from aiodocker import Docker, DockerError -from tljh_repo2docker import tljh_custom_jupyterhub_config from traitlets.config import Config +from tljh_repo2docker import tljh_custom_jupyterhub_config + async def remove_docker_image(image_name): async with Docker() as docker: @@ -13,22 +15,22 @@ async def remove_docker_image(image_name): pass -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def minimal_repo(): return "https://github.com/plasmabio/tljh-repo2docker-test-binder" -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def minimal_repo_uppercase(): return "https://github.com/plasmabio/TLJH-REPO2DOCKER-TEST-BINDER" -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def generated_image_name(): return "plasmabio-tljh-repo2docker-test-binder:HEAD" -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def image_name(): return "tljh-repo2docker-test:HEAD" @@ -38,6 +40,34 @@ async def app(hub_app): config = Config() tljh_custom_jupyterhub_config(config) + config.JupyterHub.services.extend( + [ + { + "name": "tljh_repo2docker", + "url": "http://127.0.0.1:6789", + "command": [ + sys.executable, + "-m", + "tljh_repo2docker", + "--ip", + "127.0.0.1", + "--port", + "6789", + ], + "oauth_no_confirm": True, + } + ] + ) + + config.JupyterHub.load_roles = [ + { + "description": "Role for tljh_repo2docker service", + "name": "tljh-repo2docker-service", + "scopes": ["read:users", "read:servers", "read:roles:users"], + "services": ["tljh_repo2docker"], + } + ] + app = await hub_app(config=config) return app diff --git a/tljh_repo2docker/tests/test_builder.py b/tljh_repo2docker/tests/test_builder.py index f175834..8bd0114 100644 --- a/tljh_repo2docker/tests/test_builder.py +++ b/tljh_repo2docker/tests/test_builder.py @@ -1,8 +1,7 @@ import pytest - from aiodocker import Docker, DockerError -from .utils import add_environment, wait_for_image, remove_environment +from .utils import add_environment, remove_environment, wait_for_image @pytest.mark.asyncio @@ -43,7 +42,8 @@ async def test_uppercase_repo(app, minimal_repo_uppercase, generated_image_name) assert r.status_code == 200 image = await wait_for_image(image_name=generated_image_name) assert ( - image["ContainerConfig"]["Labels"]["tljh_repo2docker.image_name"] == generated_image_name + image["ContainerConfig"]["Labels"]["tljh_repo2docker.image_name"] + == generated_image_name ) @@ -56,11 +56,17 @@ async def test_no_repo(app, image_name): @pytest.mark.asyncio @pytest.mark.parametrize( - "memory, cpu", [("abcded", ""), ("", "abcde"),], + "memory, cpu", + [ + ("abcded", ""), + ("", "abcde"), + ], ) async def test_wrong_limits(app, minimal_repo, image_name, memory, cpu): name, ref = image_name.split(":") - r = await add_environment(app, repo=minimal_repo, name=name, ref=ref, memory=memory, cpu=cpu) + r = await add_environment( + app, repo=minimal_repo, name=name, ref=ref, memory=memory, cpu=cpu + ) assert r.status_code == 400 assert "must be a number" in r.text diff --git a/tljh_repo2docker/tests/test_images.py b/tljh_repo2docker/tests/test_images.py index ddf38d8..e90aff0 100644 --- a/tljh_repo2docker/tests/test_images.py +++ b/tljh_repo2docker/tests/test_images.py @@ -1,31 +1,40 @@ import pytest - from jupyterhub.tests.utils import get_page -from .utils import add_environment, wait_for_image +from .utils import add_environment, get_service_page, wait_for_image @pytest.mark.asyncio async def test_images_list_admin(app): - cookies = await app.login_user('admin') - r = await get_page('environments', app, cookies=cookies, allow_redirects=False) + cookies = await app.login_user("admin") + r = await get_service_page( + "environments", + app, + cookies=cookies, + allow_redirects=True, + ) r.raise_for_status() - assert '{"images": [], "default_mem_limit": "None", "default_cpu_limit":"None", "machine_profiles": []}' in r.text + assert ( + '{"images": [], "default_mem_limit": "None", "default_cpu_limit":"None", "machine_profiles": []}' + in r.text + ) @pytest.mark.asyncio async def test_images_list_not_admin(app): - cookies = await app.login_user('wash') - r = await get_page('environments', app, cookies=cookies, allow_redirects=False) + cookies = await app.login_user("wash") + r = await get_service_page( + "environments", app, cookies=cookies, allow_redirects=True + ) assert r.status_code == 403 @pytest.mark.asyncio async def test_spawn_page(app, minimal_repo, image_name): - cookies = await app.login_user('admin') + cookies = await app.login_user("admin") # go to the spawn page - r = await get_page('spawn', app, cookies=cookies, allow_redirects=False) + r = await get_page("spawn", app, cookies=cookies, allow_redirects=False) r.raise_for_status() assert minimal_repo not in r.text @@ -36,7 +45,7 @@ async def test_spawn_page(app, minimal_repo, image_name): await wait_for_image(image_name=image_name) # the environment should be on the page - r = await get_page('spawn', app, cookies=cookies, allow_redirects=False) + r = await get_page("spawn", app, cookies=cookies, allow_redirects=False) r.raise_for_status() assert r.status_code == 200 assert minimal_repo in r.text diff --git a/tljh_repo2docker/tests/test_logs.py b/tljh_repo2docker/tests/test_logs.py index e41a784..8e3ab3b 100644 --- a/tljh_repo2docker/tests/test_logs.py +++ b/tljh_repo2docker/tests/test_logs.py @@ -1,10 +1,9 @@ import json import pytest +from jupyterhub.tests.utils import async_requests -from jupyterhub.tests.utils import api_request, async_requests - -from .utils import add_environment, wait_for_image +from .utils import add_environment, api_request, wait_for_image def next_event(it): @@ -39,6 +38,8 @@ async def test_stream_simple(app, minimal_repo, image_name): @pytest.mark.asyncio async def test_no_build(app, image_name, request): - r = await api_request(app, "environments", "image-not-found:12345", "logs", stream=True) + r = await api_request( + app, "environments", "image-not-found:12345", "logs", stream=True + ) request.addfinalizer(r.close) assert r.status_code == 404 diff --git a/tljh_repo2docker/tests/utils.py b/tljh_repo2docker/tests/utils.py index 7a97956..14d9d3e 100644 --- a/tljh_repo2docker/tests/utils.py +++ b/tljh_repo2docker/tests/utils.py @@ -2,19 +2,62 @@ import json from aiodocker import Docker, DockerError -from jupyterhub.tests.utils import api_request +from jupyterhub.tests.utils import (async_requests, auth_header, + check_db_locks, public_host, public_url) +from jupyterhub.utils import url_path_join as ujoin +from tornado.httputil import url_concat -async def add_environment( - app, *, repo, ref="HEAD", name="", memory="", cpu="" -): +@check_db_locks +async def api_request(app, *api_path, method="get", noauth=False, **kwargs): + """Make an API request""" + + base_url = public_url(app, path="services/tljh_repo2docker") + + headers = kwargs.setdefault("headers", {}) + if "Authorization" not in headers and not noauth and "cookies" not in kwargs: + # make a copy to avoid modifying arg in-place + kwargs["headers"] = h = {} + h.update(headers) + h.update(auth_header(app.db, kwargs.pop("name", "admin"))) + + url = ujoin(base_url, "api", *api_path) + if "cookies" in kwargs: + # for cookie-authenticated requests, + # add _xsrf to url params + if "_xsrf" in kwargs["cookies"] and not noauth: + url = url_concat(url, {"_xsrf": kwargs["cookies"]["_xsrf"]}) + + f = getattr(async_requests, method) + if app.internal_ssl: + kwargs["cert"] = (app.internal_ssl_cert, app.internal_ssl_key) + kwargs["verify"] = app.internal_ssl_ca + resp = await f(url, **kwargs) + + return resp + + +def get_service_page(path, app, **kw): + prefix = app.base_url + service_prefix = "services/tljh_repo2docker" + url = ujoin(public_host(app), prefix, service_prefix, path) + return async_requests.get(url, **kw) + + +async def add_environment(app, *, repo, ref="HEAD", name="", memory="", cpu=""): """Use the POST endpoint to add a new environment""" r = await api_request( app, "environments", method="post", data=json.dumps( - {"repo": repo, "ref": ref, "name": name, "memory": memory, "cpu": cpu,} + { + "repo": repo, + "ref": ref, + "name": name, + "memory": memory, + "cpu": cpu, + } ), ) return r @@ -40,6 +83,13 @@ async def wait_for_image(*, image_name): async def remove_environment(app, *, image_name): """Use the DELETE endpoint to remove an environment""" r = await api_request( - app, "environments", method="delete", data=json.dumps({"name": image_name,}), + app, + "environments", + method="delete", + data=json.dumps( + { + "name": image_name, + } + ), ) return r diff --git a/ui-tests/playwright.config.js b/ui-tests/playwright.config.js index 074ebce..e0d50c6 100644 --- a/ui-tests/playwright.config.js +++ b/ui-tests/playwright.config.js @@ -9,7 +9,7 @@ module.exports = { retries: 1, expect: { toMatchSnapshot: { - maxDiffPixelRatio: 0.01 + maxDiffPixelRatio: 0.001 } }, webServer: { diff --git a/ui-tests/tests/ui.test.ts b/ui-tests/tests/ui.test.ts index 15702e5..d4fb46a 100644 --- a/ui-tests/tests/ui.test.ts +++ b/ui-tests/tests/ui.test.ts @@ -25,17 +25,26 @@ test.describe('tljh_repo2docker UI Tests', () => { test('Log in with admin account', async ({ page }) => { await login(page, 'alice'); + await page.getByRole('button', { name: 'Services' }).click(); + await page.getByRole('link', { name: 'tljh_repo2docker' }).click(); + await page.waitForURL('**/servers'); + await page.waitForTimeout(500); expect(await page.screenshot()).toMatchSnapshot('admin.png'); }); test('Log in with user account', async ({ page }) => { await login(page, 'user'); + await page.getByRole('button', { name: 'Services' }).click(); + await page.getByRole('link', { name: 'tljh_repo2docker' }).click(); + await page.waitForURL('**/servers'); + await page.waitForTimeout(500); expect(await page.screenshot()).toMatchSnapshot('user.png'); }); test('Render servers page', async ({ page }) => { await login(page, 'alice'); - await page.getByRole('link', { name: 'Servers' }).click(); + await page.getByRole('button', { name: 'Services' }).click(); + await page.getByRole('link', { name: 'tljh_repo2docker' }).click(); await page.waitForURL('**/servers'); await page.waitForTimeout(500); await page.waitForSelector('div:has-text("No servers are running")', { @@ -46,6 +55,9 @@ test.describe('tljh_repo2docker UI Tests', () => { test('Render environments page', async ({ page }) => { await login(page, 'alice'); + await page.getByRole('button', { name: 'Services' }).click(); + await page.getByRole('link', { name: 'tljh_repo2docker' }).click(); + await page.waitForURL('**/servers'); await page.getByRole('link', { name: 'Environments' }).click(); await page.waitForURL('**/environments'); await page.waitForTimeout(500); @@ -57,8 +69,7 @@ test.describe('tljh_repo2docker UI Tests', () => { test('Render environments dialog', async ({ page }) => { await login(page, 'alice'); - await page.getByRole('link', { name: 'Environments' }).click(); - await page.waitForURL('**/environments'); + await page.goto('/services/tljh_repo2docker/environments'); await page.waitForTimeout(1000); await page.getByRole('button', { name: 'Create new environment' }).click(); await page.waitForTimeout(1000); @@ -68,8 +79,7 @@ test.describe('tljh_repo2docker UI Tests', () => { test('Create new environments', async ({ page }) => { await login(page, 'alice'); - await page.getByRole('link', { name: 'Environments' }).click(); - await page.waitForURL('**/environments'); + await page.goto('/services/tljh_repo2docker/environments'); await page.waitForTimeout(1000); await page.getByRole('button', { name: 'Create new environment' }).click(); await page @@ -102,8 +112,7 @@ test.describe('tljh_repo2docker UI Tests', () => { test('Render servers dialog', async ({ page }) => { await login(page, 'alice'); - await page.getByRole('link', { name: 'Servers' }).click(); - await page.waitForURL('**/servers'); + await page.goto('/services/tljh_repo2docker/servers'); await page.waitForTimeout(500); await page.waitForSelector('div:has-text("No servers are running")', { timeout: 1000 @@ -116,8 +125,7 @@ test.describe('tljh_repo2docker UI Tests', () => { test('Start server', async ({ page }) => { await login(page, 'alice'); - await page.getByRole('link', { name: 'Servers' }).click(); - await page.waitForURL('**/servers'); + await page.goto('/services/tljh_repo2docker/servers'); await page.waitForTimeout(500); await page.waitForSelector('div:has-text("No servers are running")', { timeout: 1000 @@ -144,8 +152,7 @@ test.describe('tljh_repo2docker UI Tests', () => { test('Remove server', async ({ page }) => { await login(page, 'alice'); - await page.getByRole('link', { name: 'Servers' }).click(); - await page.waitForURL('**/servers'); + await page.goto('/services/tljh_repo2docker/servers'); await page.waitForTimeout(1000); await page.getByRole('button', { name: 'Stop Server' }).click(); @@ -163,8 +170,7 @@ test.describe('tljh_repo2docker UI Tests', () => { test('Remove environment', async ({ page }) => { await login(page, 'alice'); - await page.getByRole('link', { name: 'Environments' }).click(); - await page.waitForURL('**/environments'); + await page.goto('/services/tljh_repo2docker/environments'); await page.waitForTimeout(1000); await page.getByRole('button', { name: 'Remove' }).click(); await page.waitForTimeout(500); diff --git a/ui-tests/tests/ui.test.ts-snapshots/admin-linux.png b/ui-tests/tests/ui.test.ts-snapshots/admin-linux.png index 0a5ec2d..f1bd625 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/admin-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/admin-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/environment-console-linux.png b/ui-tests/tests/ui.test.ts-snapshots/environment-console-linux.png index 64a7005..d517fcf 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/environment-console-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/environment-console-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/environment-dialog-linux.png b/ui-tests/tests/ui.test.ts-snapshots/environment-dialog-linux.png index a4344db..9b4a5d7 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/environment-dialog-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/environment-dialog-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/environment-list-linux.png b/ui-tests/tests/ui.test.ts-snapshots/environment-list-linux.png index e600099..966517a 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/environment-list-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/environment-list-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/environment-remove-confirm-linux.png b/ui-tests/tests/ui.test.ts-snapshots/environment-remove-confirm-linux.png index 50f5028..2d9a6f5 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/environment-remove-confirm-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/environment-remove-confirm-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/environment-removed-linux.png b/ui-tests/tests/ui.test.ts-snapshots/environment-removed-linux.png index 149ba83..185a854 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/environment-removed-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/environment-removed-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/environments-page-linux.png b/ui-tests/tests/ui.test.ts-snapshots/environments-page-linux.png index a55ab21..185a854 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/environments-page-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/environments-page-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/login-page-linux.png b/ui-tests/tests/ui.test.ts-snapshots/login-page-linux.png index 10bc901..17033c5 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/login-page-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/login-page-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/running-servers-linux.png b/ui-tests/tests/ui.test.ts-snapshots/running-servers-linux.png index aa8a53e..c1d35b0 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/running-servers-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/running-servers-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/server-remove-confirm-linux.png b/ui-tests/tests/ui.test.ts-snapshots/server-remove-confirm-linux.png index 17d3f61..083a256 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/server-remove-confirm-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/server-remove-confirm-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/server-removed-linux.png b/ui-tests/tests/ui.test.ts-snapshots/server-removed-linux.png index 488a24c..a4ea831 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/server-removed-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/server-removed-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/servers-dialog-linux.png b/ui-tests/tests/ui.test.ts-snapshots/servers-dialog-linux.png index 0cba803..474517f 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/servers-dialog-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/servers-dialog-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/servers-page-linux.png b/ui-tests/tests/ui.test.ts-snapshots/servers-page-linux.png index 73628f0..f1bd625 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/servers-page-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/servers-page-linux.png differ diff --git a/ui-tests/tests/ui.test.ts-snapshots/user-linux.png b/ui-tests/tests/ui.test.ts-snapshots/user-linux.png index d263762..0ba2fe8 100644 Binary files a/ui-tests/tests/ui.test.ts-snapshots/user-linux.png and b/ui-tests/tests/ui.test.ts-snapshots/user-linux.png differ