Skip to content

Commit 53f5b05

Browse files
add load-balanced spawner
* Custom Jupyter spawner for starting Jupyter Servers on remote platforms over SSH.
1 parent a4609dc commit 53f5b05

File tree

3 files changed

+208
-3
lines changed

3 files changed

+208
-3
lines changed

cylc/uiserver/config_defaults.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323

2424
# the command the hub should spawn (i.e. the cylc uiserver itself)
25-
c.Spawner.cmd = ['cylc', 'uiserver']
25+
c.Spawner.cmd = ['cylc', 'uis']
2626

2727
# the spawner to invoke this command
2828
c.JupyterHub.spawner_class = 'jupyterhub.spawner.LocalProcessSpawner'
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/usr/bin/env python3
2+
# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
import logging
17+
from subprocess import Popen, PIPE, DEVNULL
18+
import sys
19+
from textwrap import dedent
20+
from typing import List, Tuple
21+
22+
from traitlets import (
23+
DottedObjectName,
24+
List as TList,
25+
Unicode,
26+
default,
27+
)
28+
from jupyterhub.spawner import Spawner
29+
30+
from cylc.flow import __version__ as CYLC_VERSION
31+
from cylc.flow.host_select import select_host
32+
33+
34+
logger = logging.getLogger(__name__)
35+
36+
37+
class DottedObject(DottedObjectName):
38+
"""Like DottedObjectName, only it actually imports the thing."""
39+
40+
def validate(self, obj, value):
41+
"""Import and return bar given the string foo.bar."""
42+
package = '.'.join(value.split('.')[0:-1])
43+
obj = value.split('.')[-1]
44+
try:
45+
if package:
46+
module = __import__(package, fromlist=[obj])
47+
return module.__dict__[obj]
48+
else:
49+
return __import__(obj)
50+
except ImportError:
51+
self.error(obj, value)
52+
53+
54+
class DistributedSpawner(Spawner):
55+
"""A simple SSH Spawner with load balancing capability.
56+
57+
Runs as the user, no elevated privileges required.
58+
59+
Requires both passphraseless SSH and a shared filesystem between the
60+
hub server and all configured hosts.
61+
"""
62+
63+
hosts = TList(
64+
trait=Unicode(),
65+
config=True,
66+
help='''
67+
List of host names to choose from.
68+
'''
69+
)
70+
71+
ranking = Unicode(
72+
config=True,
73+
help='''
74+
Ranking to use for load balancing purposes.
75+
76+
If unspecified a host is chosen at random.
77+
78+
These rankings can be used to pick the host with the most available
79+
memory or filter out hosts with high server load.
80+
81+
These rankings are provided in the same format as
82+
:cylc:conf`global.cylc[scheduler][run hosts]ranking`.
83+
'''
84+
)
85+
86+
ssh_cmd = TList(
87+
trait=Unicode(),
88+
config=True,
89+
help='''
90+
The SSH command to use for connecting to the remote hosts.
91+
92+
E.G: ``['ssh']`` (default)
93+
'''
94+
)
95+
96+
get_ip_from_hostname = DottedObject(
97+
config=True,
98+
help='''
99+
Function for obtaining the IP address from a hostname.
100+
101+
E.G: ``socket.gethostbyname`` (default)
102+
'''
103+
)
104+
105+
@default('get_ip_from_hostname')
106+
def default_ip_from_hostname_command(self):
107+
return 'socket.gethostbyname'
108+
109+
@default('ssh_cmd')
110+
def default_ssh_command(self):
111+
return ['ssh']
112+
113+
def __init__(self, *args, **kwargs):
114+
Spawner.__init__(self, *args, **kwargs)
115+
self._proc = None
116+
117+
def choose_host(self):
118+
return select_host(self.hosts, self.ranking)[1]
119+
120+
def get_env(self):
121+
return {
122+
**Spawner.get_env(self),
123+
'CYLC_VERSION': CYLC_VERSION,
124+
'JUPYTERHUB_SERVICE_PREFIX': '/user/osanders/'
125+
}
126+
127+
def get_env_cmd(self) -> List[str]:
128+
"""Return the spawner environment as an ``env`` command.
129+
130+
Example output: ``['env', 'FOO=bar']``
131+
"""
132+
env = self.get_env()
133+
if not env:
134+
return []
135+
return [
136+
'env'
137+
] + [
138+
f'{key}={value}'
139+
for key, value in self.get_env().items()
140+
]
141+
142+
def get_remote_port(self) -> int:
143+
"""Find an open port to spawn the app onto.
144+
145+
Invokes Python over SSH to call a JupyterHub utility function on the
146+
remote host.
147+
"""
148+
cmd = [
149+
*self.ssh_cmd,
150+
self._host,
151+
sys.executable,
152+
]
153+
logger.debug('$ ' + ' '.join(cmd))
154+
proc = Popen(
155+
cmd,
156+
stdout=PIPE,
157+
stdin=PIPE,
158+
text=True
159+
)
160+
proc.communicate(dedent('''
161+
from jupyterhub.utils import random_port
162+
print(random_port())
163+
'''))
164+
if proc.returncode:
165+
raise Exception('remote proc failed')
166+
stdout, _ = proc.communicate()
167+
try:
168+
port = int(stdout)
169+
except Exception:
170+
raise Exception(f'invalid stdout: {stdout}')
171+
return port
172+
173+
async def start(self) -> Tuple[str, str]:
174+
self._host = self.choose_host()
175+
port = self.get_remote_port()
176+
cmd = [
177+
*self.ssh_cmd,
178+
self._host,
179+
*self.get_env_cmd(),
180+
*self.cmd,
181+
*self.get_args(),
182+
# NOTE: selg.get_args may set --port, however, we override it
183+
f'--port={port}',
184+
]
185+
logger.info('$ ' + ' '.join(cmd))
186+
self._proc = Popen(
187+
cmd,
188+
stderr=PIPE,
189+
stdin=DEVNULL,
190+
text=True
191+
)
192+
193+
ip = self.get_ip_from_hostname(self._host)
194+
return (ip, port)
195+
196+
async def stop(self, now=False):
197+
if self._proc:
198+
self._proc.kill()
199+
self._proc = None
200+
201+
async def poll(self):
202+
if self._proc:
203+
return self._proc.poll()

cylc/uiserver/main.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from functools import partial
2424
from logging.config import dictConfig
2525
from pathlib import Path, PurePath
26+
from socket import gethostname
2627
import sys
2728
from typing import Any, Tuple, Type, List
2829

@@ -420,11 +421,12 @@ def _make_app(self, debug: bool):
420421

421422
def start(self, debug: bool):
422423
logger.info("Starting Cylc UI Server")
424+
logger.info(f"Running on: {gethostname()}")
425+
logger.info(f"Listening on port: {self._port}")
426+
logger.info(f'Serving UI from: {self.ui_path}')
423427
logger.info(
424428
f"JupyterHub Service Prefix: {self._jupyter_hub_service_prefix}"
425429
)
426-
logger.info(f"Listening on port: {self._port}")
427-
logger.info(f'Serving UI from: {self.ui_path}')
428430
app = self._make_app(debug)
429431
signal.signal(signal.SIGINT, app.signal_handler)
430432
app.listen(self._port)

0 commit comments

Comments
 (0)