Skip to content

Commit d953aa3

Browse files
committed
Use auto-port choosing on singleuser side
... if the kubespawner/port: auto annotation is set Signed-off-by: Thorsten Klein <[email protected]>
1 parent 7f3ae83 commit d953aa3

File tree

10 files changed

+156
-63
lines changed

10 files changed

+156
-63
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,6 @@ docs/source/build/
7575

7676
# PyBuilder
7777
target/
78+
79+
# Pipenv
80+
Pipfile*

kubespawner/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# instead of the more verbose import kubespawner.spawner.KubeSpawner.
1414

1515
from kubespawner.spawner import KubeSpawner
16+
from . import api
17+
from . import autoport
1618

1719
__version__ = '0.14.1.dev'
1820
__all__ = [KubeSpawner]

kubespawner/api.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import json
2+
from tornado import web
3+
from jupyterhub.apihandlers import APIHandler, default_handlers
4+
5+
class KubeSpawnerAPIHandler(APIHandler):
6+
@web.authenticated
7+
def post(self):
8+
"""POST set user spawner data"""
9+
if hasattr(self, 'current_user'):
10+
# Jupyterhub compatability, (september 2018, d79a99323ef1d)
11+
user = self.current_user
12+
else:
13+
# Previous jupyterhub, 0.9.4 and before.
14+
user = self.get_current_user()
15+
token = self.get_auth_token()
16+
spawner = None
17+
for s in user.spawners.values():
18+
if s.api_token == token:
19+
spawner = s
20+
break
21+
data = self.get_json_body()
22+
for key, value in data.items():
23+
if hasattr(spawner, key):
24+
setattr(spawner, key, value)
25+
self.finish(json.dumps({"message": "KubeSpawner data configured"}))
26+
self.set_status(201)
27+
28+
default_handlers.append((r"/api/kubespawner", KubeSpawnerAPIHandler))

kubespawner/autoport.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import os
2+
import sys
3+
4+
from runpy import run_path
5+
from shutil import which
6+
7+
from jupyterhub.utils import random_port, url_path_join
8+
from jupyterhub.services.auth import HubAuth
9+
10+
def main(argv=None):
11+
port = random_port()
12+
hub_auth = HubAuth()
13+
hub_auth.client_ca = os.environ.get('JUPYTERHUB_SSL_CLIENT_CA', '')
14+
hub_auth.certfile = os.environ.get('JUPYTERHUB_SSL_CERTFILE', '')
15+
hub_auth.keyfile = os.environ.get('JUPYTERHUB_SSL_KEYFILE', '')
16+
hub_auth._api_request(method='POST',
17+
url=url_path_join(hub_auth.api_url, 'kubespawner'),
18+
json={'port' : port})
19+
cmd_path = which(sys.argv[1])
20+
sys.argv = sys.argv[1:] + ['--port={}'.format(port)]
21+
run_path(cmd_path, run_name="__main__")
22+
23+
if __name__ == "__main__":
24+
main()

kubespawner/objects.py

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,26 @@
55
import re
66
from urllib.parse import urlparse
77

8+
from kubernetes.client.models import (V1Affinity, V1Container, V1ContainerPort,
9+
V1EndpointAddress, V1EndpointPort,
10+
V1Endpoints, V1EndpointSubset, V1EnvVar,
11+
V1Lifecycle, V1LocalObjectReference,
12+
V1NodeAffinity, V1NodeSelector,
13+
V1NodeSelectorRequirement,
14+
V1NodeSelectorTerm, V1ObjectMeta,
15+
V1PersistentVolumeClaim,
16+
V1PersistentVolumeClaimSpec, V1Pod,
17+
V1PodAffinity, V1PodAffinityTerm,
18+
V1PodAntiAffinity, V1PodSecurityContext,
19+
V1PodSpec, V1PreferredSchedulingTerm,
20+
V1ResourceRequirements,
21+
V1SecurityContext, V1Service,
22+
V1ServicePort, V1ServiceSpec,
23+
V1Toleration, V1Volume, V1VolumeMount,
24+
V1WeightedPodAffinityTerm)
25+
826
from kubespawner.utils import get_k8s_model, update_k8s_model
927

10-
from kubernetes.client.models import (
11-
V1Pod, V1PodSpec, V1PodSecurityContext,
12-
V1ObjectMeta,
13-
V1LocalObjectReference,
14-
V1Volume, V1VolumeMount,
15-
V1Container, V1ContainerPort, V1SecurityContext, V1EnvVar, V1ResourceRequirements, V1Lifecycle,
16-
V1PersistentVolumeClaim, V1PersistentVolumeClaimSpec,
17-
V1Endpoints, V1EndpointSubset, V1EndpointAddress, V1EndpointPort,
18-
V1Service, V1ServiceSpec, V1ServicePort,
19-
V1Toleration,
20-
V1Affinity,
21-
V1NodeAffinity, V1NodeSelector, V1NodeSelectorTerm, V1PreferredSchedulingTerm, V1NodeSelectorRequirement,
22-
V1PodAffinity, V1PodAntiAffinity, V1WeightedPodAffinityTerm, V1PodAffinityTerm,
23-
)
2428

2529
def make_pod(
2630
name,
@@ -293,11 +297,17 @@ def make_pod(
293297
prepared_env.append(get_k8s_model(V1EnvVar, v))
294298
else:
295299
prepared_env.append(V1EnvVar(name=k, value=v))
300+
# port == 0: do not create a port object
301+
if port == 0:
302+
ports = []
303+
else:
304+
ports=[V1ContainerPort(name='notebook-port', container_port=port)]
305+
296306
notebook_container = V1Container(
297307
name='notebook',
298308
image=image,
299309
working_dir=working_dir,
300-
ports=[V1ContainerPort(name='notebook-port', container_port=port)],
310+
ports=ports,
301311
env=prepared_env,
302312
args=cmd,
303313
image_pull_policy=image_pull_policy,
@@ -497,18 +507,24 @@ def make_ingress(
497507

498508
try:
499509
from kubernetes.client.models import (
500-
ExtensionsV1beta1Ingress, ExtensionsV1beta1IngressSpec, ExtensionsV1beta1IngressRule,
501-
ExtensionsV1beta1HTTPIngressRuleValue, ExtensionsV1beta1HTTPIngressPath,
502-
ExtensionsV1beta1IngressBackend,
503-
)
510+
ExtensionsV1beta1HTTPIngressPath,
511+
ExtensionsV1beta1HTTPIngressRuleValue, ExtensionsV1beta1Ingress,
512+
ExtensionsV1beta1IngressBackend, ExtensionsV1beta1IngressRule,
513+
ExtensionsV1beta1IngressSpec)
504514
except ImportError:
505-
from kubernetes.client.models import (
506-
V1beta1Ingress as ExtensionsV1beta1Ingress, V1beta1IngressSpec as ExtensionsV1beta1IngressSpec,
507-
V1beta1IngressRule as ExtensionsV1beta1IngressRule,
508-
V1beta1HTTPIngressRuleValue as ExtensionsV1beta1HTTPIngressRuleValue,
509-
V1beta1HTTPIngressPath as ExtensionsV1beta1HTTPIngressPath,
515+
from kubernetes.client.models import \
516+
V1beta1HTTPIngressPath as ExtensionsV1beta1HTTPIngressPath
517+
from kubernetes.client.models import \
518+
V1beta1HTTPIngressRuleValue as \
519+
ExtensionsV1beta1HTTPIngressRuleValue
520+
from kubernetes.client.models import \
521+
V1beta1Ingress as ExtensionsV1beta1Ingress
522+
from kubernetes.client.models import \
510523
V1beta1IngressBackend as ExtensionsV1beta1IngressBackend
511-
)
524+
from kubernetes.client.models import \
525+
V1beta1IngressRule as ExtensionsV1beta1IngressRule
526+
from kubernetes.client.models import \
527+
V1beta1IngressSpec as ExtensionsV1beta1IngressSpec
512528

513529
meta = V1ObjectMeta(
514530
name=name,

kubespawner/spawner.py

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,37 @@
55
implementation that should be used by JupyterHub.
66
"""
77

8-
from functools import partial # noqa
9-
from datetime import datetime, timedelta
108
import asyncio
119
import json
10+
import multiprocessing
1211
import os
13-
import sys
1412
import string
15-
import multiprocessing
16-
from concurrent.futures import ThreadPoolExecutor
13+
import sys
1714
import warnings
15+
from concurrent.futures import ThreadPoolExecutor
16+
from datetime import datetime, timedelta
17+
from functools import partial # noqa
1818

19-
from tornado import gen
20-
from tornado.ioloop import IOLoop
21-
from tornado.concurrent import run_on_executor
22-
from tornado import web
23-
from traitlets import (
24-
Bool,
25-
Dict,
26-
Integer,
27-
List,
28-
Unicode,
29-
Union,
30-
default,
31-
observe,
32-
validate,
33-
)
19+
import escapism
20+
from async_generator import async_generator, yield_
21+
from jinja2 import BaseLoader, Environment
3422
from jupyterhub.spawner import Spawner
35-
from jupyterhub.utils import exponential_backoff
3623
from jupyterhub.traitlets import Command
37-
from kubernetes.client.rest import ApiException
24+
from jupyterhub.utils import exponential_backoff
3825
from kubernetes import client
39-
import escapism
40-
from jinja2 import Environment, BaseLoader
26+
from kubernetes.client.rest import ApiException
27+
from tornado import gen, web
28+
from tornado.concurrent import run_on_executor
29+
from tornado.ioloop import IOLoop
4130

42-
from .clients import shared_client
43-
from kubespawner.traitlets import Callable
4431
from kubespawner.objects import make_pod, make_pvc
4532
from kubespawner.reflector import NamespacedResourceReflector
46-
from slugify import slugify
33+
from kubespawner.traitlets import Callable
34+
from traitlets import (Bool, Dict, Integer, List, Unicode, Union, default,
35+
observe, validate)
36+
37+
from .clients import shared_client
38+
4739

4840
class PodReflector(NamespacedResourceReflector):
4941
"""
@@ -467,7 +459,7 @@ def _deprecated_changed(self, change):
467459
)
468460

469461
image = Unicode(
470-
'jupyterhub/singleuser:latest',
462+
'jupytertest:local',
471463
config=True,
472464
help="""
473465
Docker image to use for spawning user's containers.
@@ -1469,10 +1461,26 @@ async def get_pod_manifest(self):
14691461
labels = self._build_pod_labels(self._expand_all(self.extra_labels))
14701462
annotations = self._build_common_annotations(self._expand_all(self.extra_annotations))
14711463

1464+
# FIXME: use a real config option instead of an annotation
1465+
if "jupyterhub/port" in self.extra_annotations:
1466+
if self.extra_annotations["jupyterhub/port"] == "auto":
1467+
self.log.info(f"Letting pod {self.pod_name} choose the port itself")
1468+
self.port = 0
1469+
if real_cmd:
1470+
for arg in real_cmd:
1471+
if arg.startswith("--port="):
1472+
self.log.debug(f"Removing '--port' flag from cmd for pod {self.pod_name}, which chooses the port itself")
1473+
real_cmd.remove(arg)
1474+
real_cmd = ["kubespawner-autoport"] + real_cmd # FIXME: add configuration option to specify the path to the executable
1475+
else:
1476+
real_cmd = ["kubespawner-autoport"]
1477+
self.log.debug(f"Full CMD for pod {self.pod_name} is '{real_cmd}'")
1478+
port_selection = self.port
1479+
14721480
return make_pod(
14731481
name=self.pod_name,
14741482
cmd=real_cmd,
1475-
port=self.port,
1483+
port=port_selection,
14761484
image=self.image,
14771485
image_pull_policy=self.image_pull_policy,
14781486
image_pull_secrets=self.image_pull_secrets,
@@ -1941,6 +1949,13 @@ async def _start(self):
19411949
]
19421950
),
19431951
)
1952+
1953+
if self.port == 0:
1954+
self.log.info(f"Pod {self.pod_name} has port set to 0, so we wait for it to set the real port itself")
1955+
while self.port == 0:
1956+
self.log.debug(f"Waiting for {self.pod_name} to send the real port number...")
1957+
yield gen.sleep(1)
1958+
self.log.info(f"Pod {self.pod_name} is listening on port {self.port}")
19441959
return (pod["status"]["podIP"], self.port)
19451960

19461961
async def _make_delete_pod_request(self, pod_name, delete_options, grace_seconds, request_timeout):

scripts/kubespawner-autoport

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/usr/bin/env python3
2+
3+
from kubespawner.autoport import main
4+
5+
if __name__ == '__main__':
6+
main()

setup.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from __future__ import print_function
2-
from setuptools import setup, find_packages
2+
3+
import os
34
import sys
5+
from glob import glob
6+
7+
from setuptools import find_packages, setup
48

59
v = sys.version_info
610
if v[:2] < (3, 6):
@@ -20,6 +24,7 @@
2024
'kubernetes>=10.1.0',
2125
'urllib3',
2226
'pyYAML',
27+
'notebook>=4.0'
2328
],
2429
python_requires='>=3.6',
2530
extras_require={

tests/test_spawner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ def set_id(spawner):
9898
@pytest.mark.asyncio
9999
async def test_spawn(kube_ns, kube_client, config):
100100
spawner = KubeSpawner(hub=Hub(), user=MockUser(), config=config)
101+
spawner.extra_annotations = {"jupyterhub/port": "auto"}
102+
101103
# empty spawner isn't running
102104
status = await spawner.poll()
103105
assert isinstance(status, int)

tox.ini

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)