From 02aa466e061ca45806b1549aab3223585822e673 Mon Sep 17 00:00:00 2001 From: trungleduc Date: Sat, 9 Mar 2024 22:34:15 +0100 Subject: [PATCH] [wip] Add service handlers --- dev-requirements.txt | 4 +- tljh_repo2docker/__init__.py | 1 + tljh_repo2docker/app.py | 33 +++- tljh_repo2docker/handlers/base.py | 19 ++- tljh_repo2docker/handlers/docker.py | 146 ++++++++++++++++++ tljh_repo2docker/handlers/servers.py | 6 +- tljh_repo2docker/servers.py | 2 + tljh_repo2docker/service_templates/admin.html | 15 ++ .../service_templates/images.html | 8 + tljh_repo2docker/service_templates/page.html | 117 ++++++++++++++ .../service_templates/servers.html | 10 ++ .../static/images/jupyterhub-80.png | Bin 0 -> 11453 bytes tljh_repo2docker/templates/page.html | 13 +- 13 files changed, 354 insertions(+), 20 deletions(-) create mode 100644 tljh_repo2docker/handlers/docker.py create mode 100644 tljh_repo2docker/service_templates/admin.html create mode 100644 tljh_repo2docker/service_templates/images.html create mode 100644 tljh_repo2docker/service_templates/page.html create mode 100644 tljh_repo2docker/service_templates/servers.html create mode 100644 tljh_repo2docker/static/images/jupyterhub-80.png diff --git a/dev-requirements.txt b/dev-requirements.txt index 4a0a7e6..1b1c7df 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,6 +1,6 @@ git+https://github.com/jupyterhub/the-littlest-jupyterhub -jupyterhub~=1.5 -notebook<7 +# jupyterhub~=1.5 +# notebook<7 pytest pytest-aiohttp pytest-asyncio diff --git a/tljh_repo2docker/__init__.py b/tljh_repo2docker/__init__.py index b5a0fc8..d789706 100644 --- a/tljh_repo2docker/__init__.py +++ b/tljh_repo2docker/__init__.py @@ -229,6 +229,7 @@ def tljh_custom_jupyterhub_config(c): "6789", ], "oauth_no_confirm": True, + 'admin': True, } ] ) diff --git a/tljh_repo2docker/app.py b/tljh_repo2docker/app.py index 92229b6..b0b1d25 100644 --- a/tljh_repo2docker/app.py +++ b/tljh_repo2docker/app.py @@ -13,6 +13,8 @@ from traitlets import Dict, Int, List, Unicode, default, validate from traitlets.config.application import Application from jupyterhub.utils import url_path_join +from jupyterhub.app import DATA_FILES_PATH +from jupyterhub.handlers.static import LogoHandler from .builder import BuildHandler from .docker import list_images from .handlers.servers import ServersHandler @@ -32,7 +34,6 @@ def get(self): class TljhRepo2Docker(Application): - name = Unicode("tljh-repo2docker") version = "1.0.0" @@ -87,6 +88,16 @@ def _validate_ip(self, proposal): 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, @@ -99,24 +110,25 @@ def _default_log_level(self): def init_settings(self) -> tp.Dict: """Initialize settings for the service application.""" - static_path = str(HERE / "static") + static_path = DATA_FILES_PATH + "/static/" static_url_prefix = self.service_prefix + "static/" env_opt = {"autoescape": True} env = Environment( - loader=PackageLoader("tljh_repo2docker"), + loader=PackageLoader("tljh_repo2docker", package_path="service_templates"), **env_opt, ) settings = dict( log=self.log, - template_path=str(HERE / "templates"), + template_path=str(HERE / "service_templates"), static_path=static_path, static_url_prefix=static_url_prefix, jinja2_env=env, cookie_secret=os.urandom(32), base_url=self.base_url, - service_prefix=self.service_prefix + hub_prefix=url_path_join(self.base_url, "/hub/"), + service_prefix=self.service_prefix, ) return settings @@ -124,10 +136,16 @@ 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, r"/static/(.*)"), + 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}, ), @@ -135,7 +153,8 @@ def init_handlers(self) -> tp.List: url_path_join(self.service_prefix, "oauth_callback"), HubOAuthCallbackHandler, ), - (url_path_join(self.service_prefix, r"servers"), ServersHandler), + (self.service_prefix, web.RedirectHandler, {"url": server_url}), + (server_url, ServersHandler), # (url_path_join(self.service_prefix, r"environments"), ImagesHandler), # (url_path_join(self.service_prefix, r"api/environments"), BuildHandler), # ( diff --git a/tljh_repo2docker/handlers/base.py b/tljh_repo2docker/handlers/base.py index f638c0c..a1029fb 100644 --- a/tljh_repo2docker/handlers/base.py +++ b/tljh_repo2docker/handlers/base.py @@ -4,19 +4,22 @@ from jupyterhub.utils import url_path_join from jupyterhub.services.auth import HubOAuthenticated from jinja2 import Template - +import requests JUPYTERHUB_API_URL = os.environ.get("JUPYTERHUB_API_URL", None) - +API_TOKEN = os.environ.get("JUPYTERHUB_API_TOKEN", None) class BaseHandler(HubOAuthenticated, web.RequestHandler): """ Base handler for tljh_repo2docker service """ - def fetch_user(self) -> Dict: + async def fetch_user(self) -> Dict: user = self.current_user - print("AAAAAAAAAAAAA", user) - return user + url = url_path_join(JUPYTERHUB_API_URL, 'users', user['name']) + user_model = requests.get(url + '?include_stopped_servers', headers={"Authorization": "token " + API_TOKEN}) + print("AAAAAAAAAAAAA", user_model.json()) + + return user_model def get_template(self, name: str) -> Template: """Return the jinja template object for a given name @@ -39,9 +42,15 @@ def render_template(self, name: str, **kwargs) -> str: Returns: The generated template """ + user = self.current_user template_ns = dict( service_prefix=self.settings.get("service_prefix", "/"), + hub_prefix=self.settings.get("hub_prefix", "/"), + base_url=self.settings.get("base_url", "/"), 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) diff --git a/tljh_repo2docker/handlers/docker.py b/tljh_repo2docker/handlers/docker.py new file mode 100644 index 0000000..c934986 --- /dev/null +++ b/tljh_repo2docker/handlers/docker.py @@ -0,0 +1,146 @@ +import json + +from urllib.parse import urlparse + +from aiodocker import Docker + + +async def list_images(): + """ + Retrieve local images built by repo2docker + """ + async with Docker() as docker: + r2d_images = await docker.images.list( + filters=json.dumps({"dangling": ["false"], "label": ["repo2docker.ref"]}) + ) + images = [ + { + "repo": image["Labels"]["repo2docker.repo"], + "ref": image["Labels"]["repo2docker.ref"], + "image_name": image["Labels"]["tljh_repo2docker.image_name"], + "display_name": image["Labels"]["tljh_repo2docker.display_name"], + "mem_limit": image["Labels"]["tljh_repo2docker.mem_limit"], + "cpu_limit": image["Labels"]["tljh_repo2docker.cpu_limit"], + "status": "built", + } + for image in r2d_images + if "tljh_repo2docker.image_name" in image["Labels"] + ] + return images + + +async def list_containers(): + """ + Retrieve the list of local images being built by repo2docker. + Images are built in a Docker container. + """ + async with Docker() as docker: + r2d_containers = await docker.containers.list( + filters=json.dumps({"label": ["repo2docker.ref"]}) + ) + containers = [ + { + "repo": container["Labels"]["repo2docker.repo"], + "ref": container["Labels"]["repo2docker.ref"], + "image_name": container["Labels"]["repo2docker.build"], + "display_name": container["Labels"]["tljh_repo2docker.display_name"], + "mem_limit": container["Labels"]["tljh_repo2docker.mem_limit"], + "cpu_limit": container["Labels"]["tljh_repo2docker.cpu_limit"], + "status": "building", + } + for container in r2d_containers + if "repo2docker.build" in container["Labels"] + ] + return containers + + +async def build_image( + repo, ref, name="", memory=None, cpu=None, username=None, password=None, + extra_buildargs=None +): + """ + Build an image given a repo, ref and limits + """ + ref = ref or "HEAD" + if len(ref) >= 40: + ref = ref[:7] + + # default to the repo name if no name specified + # and sanitize the name of the docker image + name = name or urlparse(repo).path.strip("/") + name = name.lower().replace("/", "-") + image_name = f"{name}:{ref}" + + # memory is specified in GB + memory = f"{memory}G" if memory else "" + cpu = cpu or "" + + # add extra labels to set additional image properties + labels = [ + f"tljh_repo2docker.display_name={name}", + f"tljh_repo2docker.image_name={image_name}", + f"tljh_repo2docker.mem_limit={memory}", + f"tljh_repo2docker.cpu_limit={cpu}", + ] + cmd = [ + "jupyter-repo2docker", + "--ref", + ref, + "--user-name", + "jovyan", + "--user-id", + "1100", + "--no-run", + "--image-name", + image_name, + ] + + for label in labels: + cmd += [ + "--label", + label + ] + + for barg in extra_buildargs or []: + cmd += [ + "--build-arg", + barg + ] + + cmd.append(repo) + + config = { + "Cmd": cmd, + "Image": "quay.io/jupyterhub/repo2docker:main", + "Labels": { + "repo2docker.repo": repo, + "repo2docker.ref": ref, + "repo2docker.build": image_name, + "tljh_repo2docker.display_name": name, + "tljh_repo2docker.mem_limit": memory, + "tljh_repo2docker.cpu_limit": cpu, + }, + "Volumes": { + "/var/run/docker.sock": { + "bind": "/var/run/docker.sock", + "mode": "rw", + } + }, + "HostConfig": { + "Binds": ["/var/run/docker.sock:/var/run/docker.sock"], + }, + "Tty": False, + "AttachStdout": False, + "AttachStderr": False, + "OpenStdin": False, + } + + if username and password: + config.update( + { + "Env": [f"GIT_CREDENTIAL_ENV=username={username}\npassword={password}"], + } + ) + + async with Docker() as docker: + await docker.containers.run(config=config) diff --git a/tljh_repo2docker/handlers/servers.py b/tljh_repo2docker/handlers/servers.py index 271e543..b412079 100644 --- a/tljh_repo2docker/handlers/servers.py +++ b/tljh_repo2docker/handlers/servers.py @@ -4,7 +4,7 @@ from .base import BaseHandler from tornado import web -# from .docker import list_images +from .docker import list_images class ServersHandler(BaseHandler): @@ -14,8 +14,8 @@ class ServersHandler(BaseHandler): @web.authenticated async def get(self): - images = []#await list_images() - user = self.current_user + images = await list_images() + user = await self.fetch_user() # if user.running: # # trigger poll_and_notify event in case of a server that died # await user.spawner.poll_and_notify() diff --git a/tljh_repo2docker/servers.py b/tljh_repo2docker/servers.py index 76095f3..0e4b0ac 100644 --- a/tljh_repo2docker/servers.py +++ b/tljh_repo2docker/servers.py @@ -23,9 +23,11 @@ async def get(self): named_spawners = user.all_spawners(include_default=False) server_data = [] for sp in named_spawners: + print('#####', user, sp) server_data.append( self._spawner_to_server_data(sp, user) ) + print('AAAAAAa', server_data) try: named_server_limit = await self.get_current_user_named_server_limit() except Exception: diff --git a/tljh_repo2docker/service_templates/admin.html b/tljh_repo2docker/service_templates/admin.html new file mode 100644 index 0000000..c579ed8 --- /dev/null +++ b/tljh_repo2docker/service_templates/admin.html @@ -0,0 +1,15 @@ +{% 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/service_templates/images.html b/tljh_repo2docker/service_templates/images.html new file mode 100644 index 0000000..2befdcf --- /dev/null +++ b/tljh_repo2docker/service_templates/images.html @@ -0,0 +1,8 @@ +{% extends "page.html" %} {% block main %} +
+ + +
+{% endblock %} diff --git a/tljh_repo2docker/service_templates/page.html b/tljh_repo2docker/service_templates/page.html new file mode 100644 index 0000000..1fea4ca --- /dev/null +++ b/tljh_repo2docker/service_templates/page.html @@ -0,0 +1,117 @@ + + + + + + {% block title %}JupyterHub{% endblock %} + + + + {% block stylesheet %} {% endblock %} {% + block favicon %} {% endblock %} + + + + {% block meta %} {% endblock %} + + + + + + {% block nav_bar %} + + {% endblock %} {% block main %} {% endblock %} {% block footer %} {% + endblock %} + + diff --git a/tljh_repo2docker/service_templates/servers.html b/tljh_repo2docker/service_templates/servers.html new file mode 100644 index 0000000..1e6d28a --- /dev/null +++ b/tljh_repo2docker/service_templates/servers.html @@ -0,0 +1,10 @@ +{% extends "page.html" %} {% block main %} +
+ + +
+{% endblock %} diff --git a/tljh_repo2docker/static/images/jupyterhub-80.png b/tljh_repo2docker/static/images/jupyterhub-80.png new file mode 100644 index 0000000000000000000000000000000000000000..65d231abc97e17a48cdd9ac6321e50252f75bddf GIT binary patch literal 11453 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 fbf-H}oY-{J^%j)KL#M@ literal 0 HcmV?d00001 diff --git a/tljh_repo2docker/templates/page.html b/tljh_repo2docker/templates/page.html index 7553763..63b47ee 100644 --- a/tljh_repo2docker/templates/page.html +++ b/tljh_repo2docker/templates/page.html @@ -7,9 +7,16 @@
  • Admin
  • Environments
  • {% if services %} -{% for service in services %} -
  • {{service.name}}
  • -{% endfor %} + {% endif %} {% endif %} {% endblock %} \ No newline at end of file