Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Let singleuser server select a free random port to listen on #448

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,6 @@ docs/source/build/

# PyBuilder
target/

# Pipenv
Pipfile*
2 changes: 2 additions & 0 deletions kubespawner/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
# instead of the more verbose import kubespawner.spawner.KubeSpawner.

from kubespawner.spawner import KubeSpawner
from . import api
from . import autoport

__version__ = '0.14.2.dev'
__all__ = [KubeSpawner]
28 changes: 28 additions & 0 deletions kubespawner/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import json
from tornado import web
from jupyterhub.apihandlers import APIHandler, default_handlers

class KubeSpawnerAPIHandler(APIHandler):
@web.authenticated
def post(self):
"""POST set user spawner data"""
if hasattr(self, 'current_user'):
# Jupyterhub compatability, (september 2018, d79a99323ef1d)
user = self.current_user
else:
# Previous jupyterhub, 0.9.4 and before.
user = self.get_current_user()
token = self.get_auth_token()
spawner = None
for s in user.spawners.values():
if s.api_token == token:
spawner = s
break
data = self.get_json_body()
for key, value in data.items():
if hasattr(spawner, key):
setattr(spawner, key, value)
self.finish(json.dumps({"message": "KubeSpawner data configured"}))
self.set_status(201)

default_handlers.append((r"/api/kubespawner", KubeSpawnerAPIHandler))
24 changes: 24 additions & 0 deletions kubespawner/autoport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import os
import sys

from runpy import run_path
from shutil import which

from jupyterhub.utils import random_port, url_path_join
from jupyterhub.services.auth import HubAuth

def main(argv=None):
port = random_port()
hub_auth = HubAuth()
hub_auth.client_ca = os.environ.get('JUPYTERHUB_SSL_CLIENT_CA', '')
hub_auth.certfile = os.environ.get('JUPYTERHUB_SSL_CERTFILE', '')
hub_auth.keyfile = os.environ.get('JUPYTERHUB_SSL_KEYFILE', '')
hub_auth._api_request(method='POST',
url=url_path_join(hub_auth.api_url, 'kubespawner'),
json={'port' : port})
cmd_path = which(sys.argv[1])
sys.argv = sys.argv[1:] + ['--port={}'.format(port)]
run_path(cmd_path, run_name="__main__")

if __name__ == "__main__":
main()
66 changes: 41 additions & 25 deletions kubespawner/objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,26 @@
import re
from urllib.parse import urlparse

from kubernetes.client.models import (V1Affinity, V1Container, V1ContainerPort,
V1EndpointAddress, V1EndpointPort,
V1Endpoints, V1EndpointSubset, V1EnvVar,
V1Lifecycle, V1LocalObjectReference,
V1NodeAffinity, V1NodeSelector,
V1NodeSelectorRequirement,
V1NodeSelectorTerm, V1ObjectMeta,
V1PersistentVolumeClaim,
V1PersistentVolumeClaimSpec, V1Pod,
V1PodAffinity, V1PodAffinityTerm,
V1PodAntiAffinity, V1PodSecurityContext,
V1PodSpec, V1PreferredSchedulingTerm,
V1ResourceRequirements,
V1SecurityContext, V1Service,
V1ServicePort, V1ServiceSpec,
V1Toleration, V1Volume, V1VolumeMount,
V1WeightedPodAffinityTerm)

from kubespawner.utils import get_k8s_model, update_k8s_model

from kubernetes.client.models import (
V1Pod, V1PodSpec, V1PodSecurityContext,
V1ObjectMeta,
V1LocalObjectReference,
V1Volume, V1VolumeMount,
V1Container, V1ContainerPort, V1SecurityContext, V1EnvVar, V1ResourceRequirements, V1Lifecycle,
V1PersistentVolumeClaim, V1PersistentVolumeClaimSpec,
V1Endpoints, V1EndpointSubset, V1EndpointAddress, V1EndpointPort,
V1Service, V1ServiceSpec, V1ServicePort,
V1Toleration,
V1Affinity,
V1NodeAffinity, V1NodeSelector, V1NodeSelectorTerm, V1PreferredSchedulingTerm, V1NodeSelectorRequirement,
V1PodAffinity, V1PodAntiAffinity, V1WeightedPodAffinityTerm, V1PodAffinityTerm,
)

def make_pod(
name,
Expand Down Expand Up @@ -298,11 +302,17 @@ def make_pod(
prepared_env.append(get_k8s_model(V1EnvVar, v))
else:
prepared_env.append(V1EnvVar(name=k, value=v))
# port == 0: do not create a port object
if port == 0:
ports = []
else:
ports=[V1ContainerPort(name='notebook-port', container_port=port)]

notebook_container = V1Container(
name='notebook',
image=image,
working_dir=working_dir,
ports=[V1ContainerPort(name='notebook-port', container_port=port)],
ports=ports,
env=prepared_env,
args=cmd,
image_pull_policy=image_pull_policy,
Expand Down Expand Up @@ -502,18 +512,24 @@ def make_ingress(

try:
from kubernetes.client.models import (
ExtensionsV1beta1Ingress, ExtensionsV1beta1IngressSpec, ExtensionsV1beta1IngressRule,
ExtensionsV1beta1HTTPIngressRuleValue, ExtensionsV1beta1HTTPIngressPath,
ExtensionsV1beta1IngressBackend,
)
ExtensionsV1beta1HTTPIngressPath,
ExtensionsV1beta1HTTPIngressRuleValue, ExtensionsV1beta1Ingress,
ExtensionsV1beta1IngressBackend, ExtensionsV1beta1IngressRule,
ExtensionsV1beta1IngressSpec)
except ImportError:
from kubernetes.client.models import (
V1beta1Ingress as ExtensionsV1beta1Ingress, V1beta1IngressSpec as ExtensionsV1beta1IngressSpec,
V1beta1IngressRule as ExtensionsV1beta1IngressRule,
V1beta1HTTPIngressRuleValue as ExtensionsV1beta1HTTPIngressRuleValue,
V1beta1HTTPIngressPath as ExtensionsV1beta1HTTPIngressPath,
from kubernetes.client.models import \
V1beta1HTTPIngressPath as ExtensionsV1beta1HTTPIngressPath
from kubernetes.client.models import \
V1beta1HTTPIngressRuleValue as \
ExtensionsV1beta1HTTPIngressRuleValue
from kubernetes.client.models import \
V1beta1Ingress as ExtensionsV1beta1Ingress
from kubernetes.client.models import \
V1beta1IngressBackend as ExtensionsV1beta1IngressBackend
)
from kubernetes.client.models import \
V1beta1IngressRule as ExtensionsV1beta1IngressRule
from kubernetes.client.models import \
V1beta1IngressSpec as ExtensionsV1beta1IngressSpec

meta = V1ObjectMeta(
name=name,
Expand Down
76 changes: 47 additions & 29 deletions kubespawner/spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,39 @@
implementation that should be used by JupyterHub.
"""

from functools import partial # noqa
from datetime import datetime, timedelta
import asyncio
import json
import multiprocessing
import os
import sys
import string
import multiprocessing
from concurrent.futures import ThreadPoolExecutor
import sys
import warnings
from asyncio import sleep
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from functools import partial # noqa

from tornado import gen
from tornado.ioloop import IOLoop
from tornado.concurrent import run_on_executor
from tornado import web
from traitlets import (
Bool,
Dict,
Integer,
List,
Unicode,
Union,
default,
observe,
validate,
)
import escapism
from async_generator import async_generator, yield_
from jinja2 import BaseLoader, Environment
from jupyterhub.spawner import Spawner
from jupyterhub.utils import exponential_backoff
from jupyterhub.traitlets import Command
from kubernetes.client.rest import ApiException
from jupyterhub.utils import exponential_backoff
from kubernetes import client
import escapism
from jinja2 import Environment, BaseLoader
from kubernetes.client.rest import ApiException
from slugify import slugify
from tornado import gen, web
from tornado.concurrent import run_on_executor
from tornado.ioloop import IOLoop

from .clients import shared_client
from kubespawner.traitlets import Callable
from kubespawner.objects import make_pod, make_pvc
from kubespawner.reflector import NamespacedResourceReflector
from slugify import slugify
from kubespawner.traitlets import Callable
from traitlets import (Bool, Dict, Integer, List, Unicode, Union, default,
observe, validate)

from .clients import shared_client


class PodReflector(NamespacedResourceReflector):
"""
Expand Down Expand Up @@ -467,7 +461,7 @@ def _deprecated_changed(self, change):
)

image = Unicode(
'jupyterhub/singleuser:latest',
'jupytertest:local',
config=True,
help="""
Docker image to use for spawning user's containers.
Expand Down Expand Up @@ -1482,10 +1476,26 @@ async def get_pod_manifest(self):
labels = self._build_pod_labels(self._expand_all(self.extra_labels))
annotations = self._build_common_annotations(self._expand_all(self.extra_annotations))

# FIXME: use a real config option instead of an annotation
if "jupyterhub/port" in self.extra_annotations:
if self.extra_annotations["jupyterhub/port"] == "auto":
self.log.info(f"Letting pod {self.pod_name} choose the port itself")
self.port = 0
if real_cmd:
for arg in real_cmd:
if arg.startswith("--port="):
self.log.debug(f"Removing '--port' flag from cmd for pod {self.pod_name}, which chooses the port itself")
real_cmd.remove(arg)
real_cmd = ["kubespawner-autoport"] + real_cmd # FIXME: add configuration option to specify the path to the executable
else:
real_cmd = ["kubespawner-autoport"]
self.log.debug(f"Full CMD for pod {self.pod_name} is '{real_cmd}'")
port_selection = self.port

return make_pod(
name=self.pod_name,
cmd=real_cmd,
port=self.port,
port=port_selection,
image=self.image,
image_pull_policy=self.image_pull_policy,
image_pull_secrets=self.image_pull_secrets,
Expand Down Expand Up @@ -1955,6 +1965,14 @@ async def _start(self):
]
),
)

if self.port == 0:
self.log.info(f"Pod {self.pod_name} has port set to 0, so we wait for it to set the real port itself")
while self.port == 0:
self.log.debug(f"Waiting for {self.pod_name} to send the real port number...")
yield gen.sleep(1)
self.log.info(f"Pod {self.pod_name} is listening on port {self.port}")

return (pod["status"]["podIP"], self.port)

async def _make_delete_pod_request(self, pod_name, delete_options, grace_seconds, request_timeout):
Expand Down
6 changes: 6 additions & 0 deletions scripts/kubespawner-autoport
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env python3

from kubespawner.autoport import main

if __name__ == '__main__':
main()
7 changes: 6 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from __future__ import print_function
from setuptools import setup, find_packages

import os
import sys
from glob import glob

from setuptools import find_packages, setup

v = sys.version_info
if v[:2] < (3, 6):
Expand All @@ -20,6 +24,7 @@
'kubernetes>=10.1.0',
'urllib3',
'pyYAML',
'notebook>=4.0'
],
python_requires='>=3.6',
extras_require={
Expand Down
2 changes: 2 additions & 0 deletions tests/test_spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ def set_id(spawner):
@pytest.mark.asyncio
async def test_spawn(kube_ns, kube_client, config):
spawner = KubeSpawner(hub=Hub(), user=MockUser(), config=config)
spawner.extra_annotations = {"jupyterhub/port": "auto"}

# empty spawner isn't running
status = await spawner.poll()
assert isinstance(status, int)
Expand Down
8 changes: 0 additions & 8 deletions tox.ini

This file was deleted.