Skip to content

Commit f77fa5b

Browse files
.
1 parent 1ae2196 commit f77fa5b

File tree

2 files changed

+243
-0
lines changed

2 files changed

+243
-0
lines changed

cylc/uiserver/app.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,18 @@ def initialize_settings(self):
403403
"""
404404
super().initialize_settings()
405405
self.log.info("Starting Cylc UI Server")
406+
from socket import gethostname
407+
self.log.info(f"Host: {gethostname()}")
408+
self.log.info(f"Port: {self.serverapp.port}")
409+
410+
# ['_get_urlparts', '_update_base_url', 'base_url', 'connection_url', 'custom_display_url', 'default_url', 'display_url', 'file_url_prefix', 'local_url', 'public_url', 'websocket_url']
411+
412+
self.log.info(f"base_url: {self.serverapp.base_url}")
413+
self.log.info(f"connection_url: {self.serverapp.connection_url}")
414+
self.log.info(f"display_url: {self.serverapp.display_url}")
415+
self.log.info(f"public_url: {self.serverapp.public_url}")
416+
self.log.info(f"local_url: {self.serverapp.local_url}")
417+
406418
self.log.info(f'Serving UI from: {self.ui_path}')
407419
self.log.debug(
408420
'CylcUIServer config:\n' + '\n'.join(
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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+
17+
from contextlib import suppress
18+
import logging
19+
from subprocess import Popen, PIPE, DEVNULL
20+
import sys
21+
from textwrap import dedent
22+
from typing import List, Tuple
23+
24+
from jupyterhub.spawner import Spawner
25+
from psutil import Process, NoSuchProcess
26+
from traitlets import (
27+
DottedObjectName,
28+
List as TList,
29+
Unicode,
30+
default,
31+
)
32+
33+
from cylc.flow import __version__ as CYLC_VERSION
34+
from cylc.flow.host_select import select_host
35+
36+
37+
logger = logging.getLogger(__name__)
38+
39+
40+
class DottedObject(DottedObjectName):
41+
"""Like DottedObjectName, only it actually imports the thing."""
42+
43+
def validate(self, obj, value):
44+
"""Import and return bar given the string foo.bar."""
45+
package = '.'.join(value.split('.')[0:-1])
46+
obj = value.split('.')[-1]
47+
try:
48+
if package:
49+
module = __import__(package, fromlist=[obj])
50+
return module.__dict__[obj]
51+
else:
52+
return __import__(obj)
53+
except ImportError:
54+
self.error(obj, value)
55+
56+
57+
class DistributedSpawner(Spawner):
58+
"""A simple SSH Spawner with load balancing capability.
59+
60+
Runs as the user, no elevated privileges required.
61+
62+
Requires both passphraseless SSH and a shared filesystem between the
63+
hub server and all configured hosts.
64+
"""
65+
66+
hosts = TList(
67+
trait=Unicode(),
68+
config=True,
69+
help='''
70+
List of host names to choose from.
71+
'''
72+
)
73+
74+
ranking = Unicode(
75+
config=True,
76+
help='''
77+
Ranking to use for load balancing purposes.
78+
79+
If unspecified a host is chosen at random.
80+
81+
These rankings can be used to pick the host with the most available
82+
memory or filter out hosts with high server load.
83+
84+
These rankings are provided in the same format as
85+
:cylc:conf`global.cylc[scheduler][run hosts]ranking`.
86+
'''
87+
)
88+
89+
ssh_cmd = TList(
90+
trait=Unicode(),
91+
config=True,
92+
help='''
93+
The SSH command to use for connecting to the remote hosts.
94+
95+
E.G: ``['ssh']`` (default)
96+
'''
97+
)
98+
99+
get_ip_from_hostname = DottedObject(
100+
config=True,
101+
help='''
102+
Function for obtaining the IP address from a hostname.
103+
104+
E.G: ``socket.gethostbyname`` (default)
105+
'''
106+
)
107+
108+
@default('get_ip_from_hostname')
109+
def default_ip_from_hostname_command(self):
110+
return 'socket.gethostbyname'
111+
112+
@default('ssh_cmd')
113+
def default_ssh_command(self):
114+
return ['ssh']
115+
116+
def __init__(self, *args, **kwargs):
117+
print('# INIT')
118+
Spawner.__init__(self, *args, **kwargs)
119+
self.pid = None
120+
print('# /INIT')
121+
122+
def choose_host(self):
123+
print('# SELECT')
124+
return select_host(self.hosts, self.ranking)[1]
125+
126+
def get_env(self):
127+
return {
128+
**Spawner.get_env(self),
129+
'CYLC_VERSION': CYLC_VERSION,
130+
'JUPYTERHUB_SERVICE_PREFIX': '/user/osanders/'
131+
}
132+
133+
def get_env_cmd(self) -> List[str]:
134+
"""Return the spawner environment as an ``env`` command.
135+
136+
Example output: ``['env', 'FOO=bar']``
137+
"""
138+
env = self.get_env()
139+
if not env:
140+
return []
141+
return [
142+
'env'
143+
] + [
144+
f'{key}={value}'
145+
for key, value in self.get_env().items()
146+
]
147+
148+
def get_remote_port(self) -> int:
149+
"""Find an open port to spawn the app onto.
150+
151+
Invokes Python over SSH to call a JupyterHub utility function on the
152+
remote host.
153+
"""
154+
print('# GET_REMOTE_PORT')
155+
cmd = [
156+
*self.ssh_cmd,
157+
self._host,
158+
sys.executable,
159+
]
160+
logger.debug('$ ' + ' '.join(cmd))
161+
proc = Popen(
162+
cmd,
163+
stdout=PIPE,
164+
stdin=PIPE,
165+
text=True
166+
)
167+
proc.communicate(dedent('''
168+
from jupyterhub.utils import random_port
169+
print(random_port())
170+
'''))
171+
if proc.returncode:
172+
raise Exception('remote proc failed')
173+
stdout, _ = proc.communicate()
174+
try:
175+
port = int(stdout)
176+
except Exception:
177+
raise Exception(f'invalid stdout: {stdout}')
178+
print('# /GET_REMOTE_PORT', port)
179+
return port
180+
181+
async def start(self) -> Tuple[str, str]:
182+
print('# START')
183+
self._host = self.choose_host()
184+
port = self.get_remote_port()
185+
cmd = [
186+
*self.ssh_cmd,
187+
self._host,
188+
*self.get_env_cmd(),
189+
*self.cmd,
190+
*self.get_args(),
191+
# NOTE: selg.get_args may set --port, however, we override it
192+
f'--port={port}',
193+
]
194+
logger.info('$ ' + ' '.join(cmd))
195+
print('$ ' + ' '.join(cmd))
196+
self.pid = Popen(
197+
cmd,
198+
stderr=PIPE,
199+
stdin=DEVNULL,
200+
text=True
201+
).pid
202+
203+
# TODO
204+
# The server launches on the host
205+
# The URL is accessible from the host
206+
# The server appears to be hub-aware in that it adds a hub redirect thinggy
207+
# But not from elsewhere on the network
208+
209+
# Need to pass through the Hub URL somehow???
210+
211+
ip = self.get_ip_from_hostname(self._host)
212+
print('# /START', ip, port)
213+
return (ip, port)
214+
215+
async def stop(self, now=False):
216+
if self.pid:
217+
with suppress(NoSuchProcess):
218+
Process(self.pid).kill()
219+
220+
async def poll(self):
221+
print('# POLL')
222+
if self.pid:
223+
try:
224+
Process(self.pid)
225+
print('# /POLL', None)
226+
return None # running
227+
except NoSuchProcess:
228+
print('# /POLL', 1)
229+
return 1 # stopped
230+
print('# /POLL', 0)
231+
return 0 # not yet started

0 commit comments

Comments
 (0)