launcher.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. import argparse
  2. import asyncio
  3. import datetime
  4. import io
  5. import json
  6. import logging
  7. import os
  8. import re
  9. import string
  10. import subprocess
  11. import sys
  12. import time
  13. import uuid
  14. from random import choice
  15. from wslink import backends
  16. STATUS_OK = 200
  17. STATUS_BAD_REQUEST = 400
  18. STATUS_NOT_FOUND = 404
  19. STATUS_SERVICE_UNAVAILABLE = 503
  20. sample_config_file = """
  21. Here is a sample of what a configuration file could look like:
  22. {
  23. // ===============================
  24. // General launcher configuration
  25. // ===============================
  26. "configuration": {
  27. "host" : "localhost",
  28. "port" : 8080,
  29. "endpoint": "paraview", // SessionManager Endpoint
  30. "content": "/.../www", // Optional: Directory shared over HTTP
  31. "proxy_file" : "/.../proxy-mapping.txt", // Proxy-Mapping file for Apache
  32. "sessionURL" : "ws://${host}:${port}/ws", // ws url used by the client to connect to the started process
  33. "timeout" : 25, // Wait time in second after process start
  34. "log_dir" : "/.../viz-logs", // Directory for log files
  35. "fields" : ["file", "host", "port"] // List of fields that should be send back to client
  36. // include "secret" if you provide it as an --authKey to the app
  37. "sanitize": { // Check information coming from the client
  38. "cmd": {
  39. "type": "inList", // 'cmd' must be one of the strings in 'list'
  40. "list": [
  41. "me", "you", "something/else/altogether", "nothing-to-do"
  42. ],
  43. "default": "nothing-to-do" // If the string doesn't match, replace it with the default.
  44. // Include the default in your list
  45. },
  46. "cmd2": { // 'cmd2' must match the regexp provided, example: not a quote
  47. "type": "regexp",
  48. "regexp": "^[^\"]*$", // Make sure to include '^' and '$' to match the entire string!
  49. "default": "nothing"
  50. }
  51. }
  52. },
  53. // ===============================
  54. // Useful session vars for client
  55. // ===============================
  56. "sessionData" : { "key": "value" }, // Dictionary of values interesting to the client
  57. // ===============================
  58. // Resources list for applications
  59. // ===============================
  60. "resources" : [ { "host" : "localhost", "port_range" : [9001, 9003] } ],
  61. // ===============================
  62. // Set of properties for cmd line
  63. // ===============================
  64. "properties" : {
  65. "vtkpython" : "/.../VTK/build/bin/vtkpython",
  66. "pvpython" : "/.../ParaView/build/bin/pvpython",
  67. "vtk_python_path": "/.../VTK/build/Wrapping/Python/vtk/web",
  68. "pv_python_path": "/.../ParaView/build/lib/site-packages/paraview/web",
  69. "plugins_path": "/.../ParaView/build/lib",
  70. "dataDir": "/.../path/to/data/directory"
  71. },
  72. // ===============================
  73. // Application list with cmd lines
  74. // ===============================
  75. "apps" : {
  76. "cone" : {
  77. "cmd" : [
  78. "${vtkpython}", "${vtk_python_path}/vtk_web_cone.py", "--port", "$port" ],
  79. "ready_line" : "Starting factory"
  80. },
  81. "graph" : {
  82. "cmd" : [
  83. "${vtkpython}", "${vtk_python_path}/vtk_web_graph.py", "--port", "$port",
  84. "--vertices", "${numberOfVertices}", "--edges", "${numberOfEdges}" ],
  85. "ready_line" : "Starting factory"
  86. },
  87. "phylotree" : {
  88. "cmd" : [
  89. "${vtkpython}", "${vtk_python_path}/vtk_web_phylogenetic_tree.py", "--port", "$port",
  90. "--tree", "${dataDir}/visomics/${treeFile}", "--table", "${dataDir}/visomics/${tableFile}" ],
  91. "ready_line" : "Starting factory"
  92. },
  93. "filebrowser" : {
  94. "cmd" : [
  95. "${vtkpython}", "${vtk_python_path}/vtk_web_filebrowser.py",
  96. "--port", "${port}", "--data-dir", "${dataDir}" ],
  97. "ready_line" : "Starting factory"
  98. },
  99. "data_prober": {
  100. "cmd": [
  101. "${pvpython}", "-dr", "${pv_python_path}/pv_web_data_prober.py",
  102. "--port", "${port}", "--data-dir", "${dataDir}", "-f" ],
  103. "ready_line" : "Starting factory"
  104. },
  105. "visualizer": {
  106. "cmd": [
  107. "${pvpython}", "-dr", "${pv_python_path}/pv_web_visualizer.py",
  108. "--plugins", "${plugins_path}/libPointSprite_Plugin.so", "--port", "${port}",
  109. "--data-dir", "${dataDir}", "--load-file", "${dataDir}/${fileToLoad}",
  110. "--authKey", "${secret}", "-f" ], // Use of ${secret} means it needs to be provided to the client, in "fields", above.
  111. "ready_line" : "Starting factory"
  112. },
  113. "loader": {
  114. "cmd": [
  115. "${pvpython}", "-dr", "${pv_python_path}/pv_web_file_loader.py",
  116. "--port", "${port}", "--data-dir", "${dataDir}",
  117. "--load-file", "${dataDir}/${fileToLoad}", "-f" ],
  118. "ready_line" : "Starting factory"
  119. },
  120. "launcher" : {
  121. "cmd": [
  122. "/.../ParaView/Web/Applications/Parallel/server/launcher.sh",
  123. "${port}", "${client}", "${resources}", "${file}" ],
  124. "ready_line" : "Starting factory"
  125. },
  126. "your_app": {
  127. "cmd": [
  128. "your_shell_script.sh", "--resource-host", "${host}", "--resource-port", "${port}",
  129. "--session-id", "${id}", "--generated-password", "${secret}",
  130. "--application-key", "${application}" ],
  131. "ready_line": "Output line from your shell script indicating process is ready"
  132. }
  133. }
  134. """
  135. # =============================================================================
  136. # Helper module methods
  137. # =============================================================================
  138. def remove_comments(json_like):
  139. """
  140. Removes C-style comments from *json_like* and returns the result. Example::
  141. >>> test_json = '''\
  142. {
  143. "foo": "bar", // This is a single-line comment
  144. "baz": "blah" /* Multi-line
  145. Comment */
  146. }'''
  147. >>> remove_comments('{"foo":"bar","baz":"blah",}')
  148. '{\n "foo":"bar",\n "baz":"blah"\n}'
  149. From: https://gist.github.com/liftoff/ee7b81659673eca23cd9fc0d8b8e68b7
  150. """
  151. comments_re = re.compile(
  152. r'//.*?$|/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|"(?:\\.|[^\\"])*"',
  153. re.DOTALL | re.MULTILINE,
  154. )
  155. def replacer(match):
  156. s = match.group(0)
  157. if s[0] == "/":
  158. return ""
  159. return s
  160. return comments_re.sub(replacer, json_like)
  161. def generatePassword():
  162. return "".join(choice(string.ascii_letters + string.digits) for _ in range(16))
  163. # -----------------------------------------------------------------------------
  164. def validateKeySet(obj, expected_keys, object_name):
  165. all_key_found = True
  166. for key in expected_keys:
  167. if not key in obj:
  168. print("ERROR: %s is missing %s key." % (object_name, key))
  169. all_key_found = False
  170. return all_key_found
  171. def checkSanitize(key_pair, sanitize):
  172. if not sanitize:
  173. return
  174. for key in sanitize:
  175. if key in key_pair:
  176. checkItem = sanitize[key]
  177. value = key_pair[key]
  178. if checkItem["type"] == "inList":
  179. if not value in checkItem["list"]:
  180. logging.warning(
  181. "key %s: sanitize %s with default" % (key, key_pair[key])
  182. )
  183. key_pair[key] = checkItem["default"]
  184. elif checkItem["type"] == "regexp":
  185. if not "compiled" in checkItem:
  186. # User is responsible to add begin- and end- string symbols, to make sure entire string is matched.
  187. checkItem["compiled"] = re.compile(checkItem["regexp"])
  188. if checkItem["compiled"].match(value) == None:
  189. logging.warning(
  190. "key %s: sanitize %s with default" % (key, key_pair[key])
  191. )
  192. key_pair[key] = checkItem["default"]
  193. # -----------------------------------------------------------------------------
  194. # guard against malicious clients - make sure substitution is expected, if 'sanitize' is provided
  195. # -----------------------------------------------------------------------------
  196. def replaceVariables(template_str, variable_list, sanitize):
  197. for key_pair in variable_list:
  198. checkSanitize(key_pair, sanitize)
  199. item_template = string.Template(template_str)
  200. template_str = item_template.safe_substitute(key_pair)
  201. if "$" in template_str:
  202. logging.error("Some properties could not be resolved: " + template_str)
  203. return template_str
  204. # -----------------------------------------------------------------------------
  205. def replaceList(template_list, variable_list, sanitize):
  206. result_list = []
  207. for template_str in template_list:
  208. result_list.append(replaceVariables(template_str, variable_list, sanitize))
  209. return result_list
  210. # -----------------------------------------------------------------------------
  211. def filterResponse(obj, public_keys):
  212. public_keys.extend(["id", "sessionURL", "sessionManagerURL"])
  213. filtered_output = {}
  214. for field in obj:
  215. if field in public_keys:
  216. filtered_output[field] = obj[field]
  217. return filtered_output
  218. # -----------------------------------------------------------------------------
  219. def extractSessionId(request):
  220. path = request.path.split("/")
  221. if len(path) < 3:
  222. return None
  223. return str(path[2])
  224. def jsonResponse(payload):
  225. return json.dumps(payload, ensure_ascii=False).encode("utf8")
  226. # =============================================================================
  227. # Session manager
  228. # =============================================================================
  229. class SessionManager(object):
  230. def __init__(self, config, mapping):
  231. self.sessions = {}
  232. self.config = config
  233. self.resources = ResourceManager(config["resources"])
  234. self.mapping = mapping
  235. self.sanitize = config["configuration"]["sanitize"]
  236. def createSession(self, options):
  237. # Assign id and store options
  238. id = str(uuid.uuid1())
  239. # Assign resource to session
  240. host, port = self.resources.getNextResource()
  241. # Do we have resources
  242. if host:
  243. options["id"] = id
  244. options["host"] = host
  245. options["port"] = port
  246. if not "secret" in options:
  247. options["secret"] = generatePassword()
  248. options["sessionURL"] = replaceVariables(
  249. self.config["configuration"]["sessionURL"],
  250. [options, self.config["properties"]],
  251. self.sanitize,
  252. )
  253. options["cmd"] = replaceList(
  254. self.config["apps"][options["application"]]["cmd"],
  255. [options, self.config["properties"]],
  256. self.sanitize,
  257. )
  258. if "sessionData" in self.config:
  259. for key in self.config["sessionData"]:
  260. options[key] = replaceVariables(
  261. self.config["sessionData"][key],
  262. [options, self.config["properties"]],
  263. self.sanitize,
  264. )
  265. self.sessions[id] = options
  266. self.mapping.update(self.sessions)
  267. return options
  268. return None
  269. def deleteSession(self, id):
  270. host = self.sessions[id]["host"]
  271. port = self.sessions[id]["port"]
  272. self.resources.freeResource(host, port)
  273. del self.sessions[id]
  274. self.mapping.update(self.sessions)
  275. def getSession(self, id):
  276. if id in self.sessions:
  277. return self.sessions[id]
  278. return None
  279. # =============================================================================
  280. # Proxy manager
  281. # =============================================================================
  282. class ProxyMappingManager(object):
  283. def update(sessions):
  284. pass
  285. class ProxyMappingManagerTXT(ProxyMappingManager):
  286. def __init__(self, file_path, pattern="%s %s:%d\n"):
  287. self.file_path = file_path
  288. self.pattern = pattern
  289. def update(self, sessions):
  290. with io.open(self.file_path, "w", encoding="utf-8") as map_file:
  291. for id in sessions:
  292. map_file.write(
  293. self.pattern % (id, sessions[id]["host"], sessions[id]["port"])
  294. )
  295. # =============================================================================
  296. # Resource manager
  297. # =============================================================================
  298. class ResourceManager(object):
  299. """
  300. Class that provides methods to keep track on available resources (host/port)
  301. """
  302. def __init__(self, resourceList):
  303. self.resources = {}
  304. for resource in resourceList:
  305. host = resource["host"]
  306. portList = list(
  307. range(resource["port_range"][0], resource["port_range"][1] + 1)
  308. )
  309. if host in self.resources:
  310. self.resources[host]["available"].extend(portList)
  311. else:
  312. self.resources[host] = {"available": portList, "used": []}
  313. def getNextResource(self):
  314. """
  315. Return a (host, port) pair if any available otherwise will return None
  316. """
  317. # find host with max availibility
  318. winner = None
  319. availibilityCount = 0
  320. for host in self.resources:
  321. if availibilityCount < len(self.resources[host]["available"]):
  322. availibilityCount = len(self.resources[host]["available"])
  323. winner = host
  324. if winner:
  325. port = self.resources[winner]["available"].pop()
  326. self.resources[winner]["used"].append(port)
  327. return (winner, port)
  328. return (None, None)
  329. def freeResource(self, host, port):
  330. """
  331. Free a previously reserved resource
  332. """
  333. if host in self.resources and port in self.resources[host]["used"]:
  334. self.resources[host]["used"].remove(port)
  335. self.resources[host]["available"].append(port)
  336. # =============================================================================
  337. # Process manager
  338. # =============================================================================
  339. class ProcessManager(object):
  340. def __init__(self, configuration):
  341. self.config = configuration
  342. self.log_dir = configuration["configuration"]["log_dir"]
  343. self.processes = {}
  344. def __del__(self):
  345. for id in self.processes:
  346. self.processes[id].terminate()
  347. def _getLogFilePath(self, id):
  348. return "%s%s%s.txt" % (self.log_dir, os.sep, id)
  349. def startProcess(self, session):
  350. proc = None
  351. # Create output log file
  352. logFilePath = self._getLogFilePath(session["id"])
  353. with io.open(logFilePath, mode="a+", buffering=1, encoding="utf-8") as log_file:
  354. try:
  355. proc = subprocess.Popen(
  356. session["cmd"], stdout=log_file, stderr=log_file
  357. )
  358. self.processes[session["id"]] = proc
  359. except:
  360. logging.error("The command line failed")
  361. logging.error(" ".join(map(str, session["cmd"])))
  362. return None
  363. return proc
  364. def stopProcess(self, id):
  365. proc = self.processes[id]
  366. del self.processes[id]
  367. try:
  368. proc.terminate()
  369. except:
  370. pass # we tried
  371. def listEndedProcess(self):
  372. session_to_release = []
  373. for id in self.processes:
  374. if self.processes[id].poll() is not None:
  375. session_to_release.append(id)
  376. return session_to_release
  377. def isRunning(self, id):
  378. return self.processes[id].poll() is None
  379. # ========================================================================
  380. # Look for ready line in process output. Return True if found, False
  381. # otherwise. If no ready_line is configured and process is running return
  382. # False. This will then rely on the timout time.
  383. # ========================================================================
  384. def isReady(self, session, count=0):
  385. id = session["id"]
  386. # The process has to be running to be ready!
  387. if not self.isRunning(id) and count < 60:
  388. return False
  389. # Give up after 60 seconds if still not running
  390. if not self.isRunning(id):
  391. return True
  392. application = self.config["apps"][session["application"]]
  393. ready_line = application.get("ready_line", None)
  394. # If no ready_line is configured and the process is running then thats
  395. # enough.
  396. if not ready_line:
  397. return False
  398. ready = False
  399. # Check the output for ready_line
  400. logFilePath = self._getLogFilePath(session["id"])
  401. with io.open(logFilePath, "r", 1, encoding="utf-8") as log_file:
  402. for line in log_file.readlines():
  403. if ready_line in line:
  404. ready = True
  405. break
  406. return ready
  407. # =============================================================================
  408. # Parse config file
  409. # =============================================================================
  410. def parseConfig(options):
  411. # Read values from the configuration file
  412. try:
  413. config_comments = remove_comments(
  414. io.open(options.config[0], encoding="utf-8").read()
  415. )
  416. config = json.loads(config_comments)
  417. except:
  418. message = "ERROR: Unable to read config file.\n"
  419. message += str(sys.exc_info()[1]) + "\n" + str(sys.exc_info()[2])
  420. print(message)
  421. print(sample_config_file)
  422. sys.exit(2)
  423. expected_keys = ["configuration", "apps", "properties", "resources"]
  424. if not validateKeySet(config, expected_keys, "Config file"):
  425. print(sample_config_file)
  426. sys.exit(2)
  427. expected_keys = [
  428. "endpoint",
  429. "host",
  430. "port",
  431. "proxy_file",
  432. "sessionURL",
  433. "timeout",
  434. "log_dir",
  435. "fields",
  436. ]
  437. if not validateKeySet(config["configuration"], expected_keys, "file.configuration"):
  438. print(sample_config_file)
  439. sys.exit(2)
  440. if not "content" in config["configuration"]:
  441. config["configuration"]["content"] = ""
  442. if not "sanitize" in config["configuration"]:
  443. config["configuration"]["sanitize"] = {}
  444. return config
  445. # =============================================================================
  446. # Setup default arguments to be parsed
  447. # -d, --debug
  448. # -t, --proxyFileType Type of proxy file (txt, dbm)
  449. # =============================================================================
  450. def add_arguments(parser):
  451. parser.add_argument(
  452. "config", type=str, nargs=1, help="configuration file for the launcher"
  453. )
  454. parser.add_argument(
  455. "-d", "--debug", help="log debugging messages to stdout", action="store_true"
  456. )
  457. parser.add_argument(
  458. "--backend", help="Server implementation to use (aiohttp)", default="aiohttp"
  459. )
  460. return parser
  461. # =============================================================================
  462. # Parse arguments
  463. # =============================================================================
  464. def start(argv=None, description="wslink Web Launcher"):
  465. parser = argparse.ArgumentParser(
  466. description=description,
  467. formatter_class=argparse.RawDescriptionHelpFormatter,
  468. epilog=sample_config_file,
  469. )
  470. add_arguments(parser)
  471. args = parser.parse_args(argv)
  472. config = parseConfig(args)
  473. backends.launcher_start(args, config, backend=args.backend)
  474. # Used for backward compatibility
  475. def startWebServer(args, config, backend="aiohttp"):
  476. return backends.launcher_start(args, config, backend=backend)
  477. # =============================================================================
  478. # Main
  479. # =============================================================================
  480. if __name__ == "__main__":
  481. start()