Skip to content

Commit a1c22d4

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 9cb56d0 commit a1c22d4

File tree

10 files changed

+159
-63
lines changed

10 files changed

+159
-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.2.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,
@@ -298,11 +302,17 @@ def make_pod(
298302
prepared_env.append(get_k8s_model(V1EnvVar, v))
299303
else:
300304
prepared_env.append(V1EnvVar(name=k, value=v))
305+
# port == 0: do not create a port object
306+
if port == 0:
307+
ports = []
308+
else:
309+
ports=[V1ContainerPort(name='notebook-port', container_port=port)]
310+
301311
notebook_container = V1Container(
302312
name='notebook',
303313
image=image,
304314
working_dir=working_dir,
305-
ports=[V1ContainerPort(name='notebook-port', container_port=port)],
315+
ports=ports,
306316
env=prepared_env,
307317
args=cmd,
308318
image_pull_policy=image_pull_policy,
@@ -502,18 +512,24 @@ def make_ingress(
502512

503513
try:
504514
from kubernetes.client.models import (
505-
ExtensionsV1beta1Ingress, ExtensionsV1beta1IngressSpec, ExtensionsV1beta1IngressRule,
506-
ExtensionsV1beta1HTTPIngressRuleValue, ExtensionsV1beta1HTTPIngressPath,
507-
ExtensionsV1beta1IngressBackend,
508-
)
515+
ExtensionsV1beta1HTTPIngressPath,
516+
ExtensionsV1beta1HTTPIngressRuleValue, ExtensionsV1beta1Ingress,
517+
ExtensionsV1beta1IngressBackend, ExtensionsV1beta1IngressRule,
518+
ExtensionsV1beta1IngressSpec)
509519
except ImportError:
510-
from kubernetes.client.models import (
511-
V1beta1Ingress as ExtensionsV1beta1Ingress, V1beta1IngressSpec as ExtensionsV1beta1IngressSpec,
512-
V1beta1IngressRule as ExtensionsV1beta1IngressRule,
513-
V1beta1HTTPIngressRuleValue as ExtensionsV1beta1HTTPIngressRuleValue,
514-
V1beta1HTTPIngressPath as ExtensionsV1beta1HTTPIngressPath,
520+
from kubernetes.client.models import \
521+
V1beta1HTTPIngressPath as ExtensionsV1beta1HTTPIngressPath
522+
from kubernetes.client.models import \
523+
V1beta1HTTPIngressRuleValue as \
524+
ExtensionsV1beta1HTTPIngressRuleValue
525+
from kubernetes.client.models import \
526+
V1beta1Ingress as ExtensionsV1beta1Ingress
527+
from kubernetes.client.models import \
515528
V1beta1IngressBackend as ExtensionsV1beta1IngressBackend
516-
)
529+
from kubernetes.client.models import \
530+
V1beta1IngressRule as ExtensionsV1beta1IngressRule
531+
from kubernetes.client.models import \
532+
V1beta1IngressSpec as ExtensionsV1beta1IngressSpec
517533

518534
meta = V1ObjectMeta(
519535
name=name,

kubespawner/spawner.py

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,39 @@
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 asyncio import sleep
16+
from concurrent.futures import ThreadPoolExecutor
17+
from datetime import datetime, timedelta
18+
from functools import partial # noqa
1819

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-
)
20+
import escapism
21+
from async_generator import async_generator, yield_
22+
from jinja2 import BaseLoader, Environment
3423
from jupyterhub.spawner import Spawner
35-
from jupyterhub.utils import exponential_backoff
3624
from jupyterhub.traitlets import Command
37-
from kubernetes.client.rest import ApiException
25+
from jupyterhub.utils import exponential_backoff
3826
from kubernetes import client
39-
import escapism
40-
from jinja2 import Environment, BaseLoader
27+
from kubernetes.client.rest import ApiException
28+
from slugify import slugify
29+
from tornado import gen, web
30+
from tornado.concurrent import run_on_executor
31+
from tornado.ioloop import IOLoop
4132

42-
from .clients import shared_client
43-
from kubespawner.traitlets import Callable
4433
from kubespawner.objects import make_pod, make_pvc
4534
from kubespawner.reflector import NamespacedResourceReflector
46-
from slugify import slugify
35+
from kubespawner.traitlets import Callable
36+
from traitlets import (Bool, Dict, Integer, List, Unicode, Union, default,
37+
observe, validate)
38+
39+
from .clients import shared_client
40+
4741

4842
class PodReflector(NamespacedResourceReflector):
4943
"""
@@ -467,7 +461,7 @@ def _deprecated_changed(self, change):
467461
)
468462

469463
image = Unicode(
470-
'jupyterhub/singleuser:latest',
464+
'jupytertest:local',
471465
config=True,
472466
help="""
473467
Docker image to use for spawning user's containers.
@@ -1482,10 +1476,26 @@ async def get_pod_manifest(self):
14821476
labels = self._build_pod_labels(self._expand_all(self.extra_labels))
14831477
annotations = self._build_common_annotations(self._expand_all(self.extra_annotations))
14841478

1479+
# FIXME: use a real config option instead of an annotation
1480+
if "jupyterhub/port" in self.extra_annotations:
1481+
if self.extra_annotations["jupyterhub/port"] == "auto":
1482+
self.log.info(f"Letting pod {self.pod_name} choose the port itself")
1483+
self.port = 0
1484+
if real_cmd:
1485+
for arg in real_cmd:
1486+
if arg.startswith("--port="):
1487+
self.log.debug(f"Removing '--port' flag from cmd for pod {self.pod_name}, which chooses the port itself")
1488+
real_cmd.remove(arg)
1489+
real_cmd = ["kubespawner-autoport"] + real_cmd # FIXME: add configuration option to specify the path to the executable
1490+
else:
1491+
real_cmd = ["kubespawner-autoport"]
1492+
self.log.debug(f"Full CMD for pod {self.pod_name} is '{real_cmd}'")
1493+
port_selection = self.port
1494+
14851495
return make_pod(
14861496
name=self.pod_name,
14871497
cmd=real_cmd,
1488-
port=self.port,
1498+
port=port_selection,
14891499
image=self.image,
14901500
image_pull_policy=self.image_pull_policy,
14911501
image_pull_secrets=self.image_pull_secrets,
@@ -1955,6 +1965,14 @@ async def _start(self):
19551965
]
19561966
),
19571967
)
1968+
1969+
if self.port == 0:
1970+
self.log.info(f"Pod {self.pod_name} has port set to 0, so we wait for it to set the real port itself")
1971+
while self.port == 0:
1972+
self.log.debug(f"Waiting for {self.pod_name} to send the real port number...")
1973+
yield gen.sleep(1)
1974+
self.log.info(f"Pod {self.pod_name} is listening on port {self.port}")
1975+
19581976
return (pod["status"]["podIP"], self.port)
19591977

19601978
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
@@ -101,6 +101,8 @@ def set_id(spawner):
101101
@pytest.mark.asyncio
102102
async def test_spawn(kube_ns, kube_client, config):
103103
spawner = KubeSpawner(hub=Hub(), user=MockUser(), config=config)
104+
spawner.extra_annotations = {"jupyterhub/port": "auto"}
105+
104106
# empty spawner isn't running
105107
status = await spawner.poll()
106108
assert isinstance(status, int)

tox.ini

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

0 commit comments

Comments
 (0)