rpa_shell.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. #!/usr/bin/env python3
  2. import os
  3. import subprocess
  4. import yaml
  5. import threading
  6. from docopt import docopt
  7. from datetime import datetime
  8. from time import sleep
  9. import psutil
  10. import signal
  11. import sys
  12. import fcntl
  13. class RPAConnection:
  14. def __init__(self):
  15. self._host = None
  16. self._deadline = None
  17. self._streams = {}
  18. self._rpa_server = None
  19. self._username = None
  20. self._identity = None
  21. @property
  22. def Host(self):
  23. return self._host
  24. @Host.setter
  25. def Host(self, value):
  26. self._host = value
  27. @property
  28. def Username(self):
  29. return self._username
  30. @Username.setter
  31. def Username(self, value):
  32. self._username = value
  33. @property
  34. def Identity(self):
  35. return self._identity
  36. @Identity.setter
  37. def Identity(self, value):
  38. self._identity = value
  39. @property
  40. def RPAServer(self):
  41. return self._rpa_server
  42. @RPAServer.setter
  43. def RPAServer(self, value):
  44. self._rpa_server = value
  45. @property
  46. def Deadline(self):
  47. return self._deadline
  48. @Deadline.setter
  49. def Deadline(self, value):
  50. self._deadline = value
  51. @property
  52. def Streams(self):
  53. return self._streams
  54. def LaodFromDict(self, dct):
  55. if ("identity" in dct):
  56. self.Identity = dct["identity"]
  57. if ("username" in dct):
  58. self.Username = dct["username"]
  59. if ("rpa_server" in dct):
  60. self.RPAServer = dct["rpa_server"]
  61. self.Host = dct["host"]
  62. self.Deadline = dct["deadline"]
  63. for name,url in dct["videostreams"].items():
  64. if("not available" in url):
  65. continue
  66. self.Streams[name] = url
  67. def DumpToDict(self):
  68. data = {}
  69. data["username"] = self.Username
  70. if(self.Identity != None):
  71. data["identity"] = self.Identity
  72. data["rpa_server"] = self.RPAServer
  73. data["deadline"] = self.Deadline
  74. data["host"] = self.Host
  75. data["videostreams"] = self.Streams
  76. return data
  77. def CreateSSHCommand(self, arguments):
  78. id_arg = ""
  79. if(self.Identity != None):
  80. id_arg = "-i" + self.Identity + " "
  81. cmd = "ssh IDARG -tt -o ProxyCommand=\"ssh IDARG -W %h:%p USERNAME@RPASERVER\" USERNAME@HOST".replace("IDARG", id_arg)
  82. cmd = cmd.replace("USERNAME", self.Username).replace("HOST", self.Host).replace("RPASERVER", self.RPAServer) + " " + arguments + " "
  83. #print(cmd)
  84. return cmd
  85. def CopyFileToHost(self, src, dest):
  86. id_arg = ""
  87. if(self.Identity != None):
  88. id_arg = "-i " + self.Identity + " "
  89. scp_cmd = "scp IDARG -oProxyCommand=\"ssh IDARG -W %h:%p USERNAME@RPASERVER\" " + src + " USERNAME@HOST:" + dest
  90. scp_cmd = scp_cmd.replace("IDARG", id_arg)
  91. scp_cmd = scp_cmd.replace("USERNAME", self.Username)
  92. scp_cmd = scp_cmd.replace("RPASERVER", self.RPAServer)
  93. scp_cmd = scp_cmd.replace("HOST", self.Host)
  94. subprocess.run(scp_cmd, shell=True)
  95. def RunCommand(self, cmd, ssh_args=""):
  96. ssh_cmd = self.CreateSSHCommand(ssh_args + " \"" + cmd + " \"")
  97. subprocess.run(ssh_cmd, shell=True)
  98. def RunShell(self):
  99. subprocess.run(self.CreateSSHCommand("-YC -tt"), shell=True)
  100. class RPAClient:
  101. UNABLE_TO_CONNECT_MSG = """\
  102. Error: Unable to connect to RPA server SERVER!
  103. Did you add an SSH key to your TILab account?\
  104. """
  105. def __init__(self, rpa_server, username, identity=None):
  106. self._rpa_server = rpa_server
  107. self._username = username
  108. self._identity = identity
  109. self._lock = threading.Lock()
  110. self._lock.acquire(blocking=False)
  111. self._connection = None
  112. self._proc = None
  113. def _ssh_command(self):
  114. cmd = ["ssh", "-tt"]
  115. if(self._identity != None):
  116. cmd += ["-i", self._identity]
  117. cmd += [self._username+"@"+self._rpa_server]
  118. return cmd
  119. def CopyFileToServer(self, src, dest):
  120. id_arg = ""
  121. if(self._identity != None):
  122. id_arg = "-i " + self._identity + " "
  123. scp_cmd = "scp IDARG " + src + " USERNAME@RPASERVER:" + dest
  124. scp_cmd = scp_cmd.replace("IDARG", id_arg)
  125. scp_cmd = scp_cmd.replace("USERNAME", self._username)
  126. scp_cmd = scp_cmd.replace("RPASERVER", self._rpa_server)
  127. subprocess.run(scp_cmd, shell=True)
  128. def ServerStatus(self):
  129. try:
  130. rpa_status_cmd = self._ssh_command() + ["rpa status"]
  131. r = subprocess.run(rpa_status_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8")
  132. except Exception as ex:
  133. return RPAClient.UNABLE_TO_CONNECT_MSG.replace("SERVER", self._rpa_server)
  134. return r.stdout.strip()
  135. def RequestHost(self, host=None):
  136. def RequestHostThread(host=None):
  137. rpa_cmd = "rpa -V MESSAGE-SET=vlsi-yaml "
  138. if (host != None):
  139. rpa_cmd += "want-host " + host
  140. else:
  141. rpa_cmd += "lock"
  142. ssh_cmd = self._ssh_command() + [rpa_cmd]
  143. try:
  144. proc = subprocess.Popen(ssh_cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8")
  145. print("connected to " + self._rpa_server)
  146. self._proc = proc
  147. except Exception as ex:
  148. #print(ex)
  149. print(RPAClient.UNABLE_TO_CONNECT_MSG.replace("SERVER", self._rpa_server))
  150. self._lock.release()
  151. return
  152. try:
  153. yml_str = ""
  154. while(True):
  155. line = proc.stdout.readline()
  156. #print(line.rstrip())
  157. if (line == ""):
  158. #print("slot expired ... exiting")
  159. break
  160. elif (line.rstrip() == "---"):
  161. dct = yaml.load(yml_str, Loader=yaml.SafeLoader)
  162. if("status" not in dct):
  163. print("Invalid respone from server!")
  164. exit(1)
  165. if(dct["status"] == "ASSIGNED"):
  166. self._connection = RPAConnection()
  167. self._connection.LaodFromDict(dct)
  168. self._connection.RPAServer = self._rpa_server
  169. self._connection.Username = self._username
  170. self._connection.Identity = self._identity
  171. self._lock.release()
  172. elif(dct["status"] == "WAITING"):
  173. print("No free host available, you have been added to the waiting queue!")
  174. elif(dct["status"] == "EXTENDED"):
  175. self._connection.Deadline = dct["deadline"]
  176. elif(dct["status"] == "INFO"):
  177. print(dct["reason"])
  178. else:
  179. print(dct)
  180. yml_str = ""
  181. else:
  182. yml_str += line.rstrip() + "\n"
  183. except:
  184. pass
  185. self._checkout_thread = threading.Thread(target = RequestHostThread, args=(host,))
  186. self._checkout_thread.start()
  187. def ReleaseHost(self):
  188. try:
  189. self._proc.terminate()
  190. except:
  191. pass
  192. def WaitForHost(self):
  193. self._lock.acquire()
  194. if(self._connection == None):
  195. raise Exception("Unable to acquire remote host")
  196. @property
  197. def Connection(self):
  198. if(self._connection == None):
  199. raise Exception("No host assigned yet")
  200. return self._connection
  201. class ConnectionLockFile:
  202. def __init__(self, path):
  203. self._path = path
  204. self._lock_file = None
  205. def Exists(self):
  206. return os.path.exists(connection_lock_file_path)
  207. def Create(self):
  208. self._lock_file = open(self._path, "x")
  209. def OpenRead(self):
  210. self._lock_file = open(self._path, "r")
  211. def Write(self, connection=None):
  212. if (connection == None):
  213. lock_data = {}
  214. else:
  215. lock_data = connection.DumpToDict()
  216. lock_data["pid"] = os.getpid()
  217. fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX)
  218. self._lock_file.seek(0)
  219. self._lock_file.write(yaml.dump(lock_data))
  220. self._lock_file.flush()
  221. fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
  222. def ReadConnection(self):
  223. delelte_msg = "I will delete the lock file now. You can try rerunning this command."
  224. fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX)
  225. dct = yaml.safe_load(self._lock_file.read())
  226. fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
  227. #empty file, maybe just a bad coincidence --> try again
  228. if (dct == None):
  229. sleep(0.5)
  230. fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX)
  231. dct = yaml.safe_load(self._lock_file.read())
  232. fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
  233. if (dct == None):
  234. print("Error: Something went wrong! The connection lock file is empty!")
  235. print(delelte_msg)
  236. self.Remove()
  237. exit(1)
  238. if ("pid" not in dct):
  239. print("Error: Something went wrong! Invalid connection lock file is empty!")
  240. print(delelte_msg)
  241. self.Remove()
  242. exit(1)
  243. pid = dct["pid"]
  244. if (not psutil.pid_exists(pid)):
  245. print("Error: Master process seems to be dead!")
  246. print(delelte_msg)
  247. self.Remove()
  248. exit(1)
  249. if (not "host" in dct):
  250. print("No host assigned yet ... exiting")
  251. exit()
  252. connection = RPAConnection()
  253. connection.LaodFromDict(dct)
  254. return connection
  255. def Close(self):
  256. self._lock_file.close()
  257. def Remove(self):
  258. os.remove(self._path)
  259. usage_msg = """
  260. This tool simplifies the access to the remote lab environment in the TILab used
  261. in the DDCA lab course. The first call to rpa_shell (master process) automat-
  262. ically acquires a lab PC slot and optionally opens the video streams, programs
  263. the FPGA, executes a command or opens an interactive shell. Subsequent execu-
  264. tions of rpa_shell will use the same connection as long as the lab PC is
  265. assigned to you or until you terminate the master process.
  266. If neither -n nor a command (<CMD>) is specified, rpa_shell opens an interactive
  267. shell by default. If -n is supplied to the master process a simple menu will be
  268. shown, that waits for user input.
  269. To access the TILab computers you have to specify your username. You can do this
  270. via the -u argument or using a config file named 'rpa_cfg.yml' which must be
  271. placed in the same directory as the rpa_shell script itself. To create this file
  272. simply execute rpa_shell without a username and follow the instructions.
  273. Optionally you can also specify which identity file (i.e., private key file) the
  274. rpa_shell tools should use to establish the SSH connection (-i argument passed
  275. to the ssh command). You can do this via the -i command line option or using the
  276. (optional) identity entry in the config file. If you don't know what this
  277. feature is for, you will probably not need it. To specify an identity add the
  278. following line to the config file:
  279. identity: PATH_TO_YOUR_IDENTITY_FILE
  280. The config file may also contain an (optional) entry named 'stream_cmd' to
  281. precisely specify the command that should be used to open the streams. The
  282. command is:
  283. ffplay -fflags nobuffer -flags low_delay -framedrop -hide_banner \\
  284. -loglevel error -autoexit
  285. Usage:
  286. rpa_shell.py [-c HOST -p SOF -u USER -i ID -d] [-a | -s STREAM] [-n | <CMD>]
  287. rpa_shell.py [-u USER -i ID -t]
  288. rpa_shell.py --scp [-u USER -i ID] <LOCAL_SRC> [<REMOTE_DEST>]
  289. rpa_shell.py -h | -v
  290. Options:
  291. -h --help Show this help screen
  292. -v --version Show version information
  293. -n --no-shell Don't open a shell.
  294. -c HOST Request access to a specific host.
  295. -t Show status information about the rpa system, i.e., available
  296. hosts usage, etc. (executes rpa status and shows the result).
  297. -a Open all video streams
  298. -s STREAM Open one particular stream (target, signal or oszi)
  299. -p SOF Download the specified SOF_FILE file to the FPGA board.
  300. -u USER The username for the SSH connection. If omitted the username
  301. must be contained in the rpa_cfg.yml config file.
  302. -i ID The identity file to use for the SSH connection.
  303. -d Video stream debug mode (don't redirect the stream player's
  304. output to /dev/null)
  305. --scp Copies the file specified by <LOCAL_SRC> to the lab, at the
  306. location specified by <REMOTE_DEST>. If <REMOTE_DEST> is
  307. omitted the file will be placed in your home directory.
  308. """
  309. stream_debug = False
  310. default_stream_cmd = "ffplay -fflags nobuffer -flags low_delay -framedrop -hide_banner -loglevel error -autoexit"
  311. stream_cmd = default_stream_cmd
  312. def cfg_streaming(dbg, cmd=None):
  313. global stream_ffplay, stream_debug, stream_cmd
  314. stream_debug = dbg
  315. if(cmd != None):
  316. stream_cmd = cmd
  317. stream_ffplay = stream_cmd
  318. def open_stream(url):
  319. global stream_ffplay, stream_debug, stream_cmd
  320. cmd = stream_cmd
  321. cmd += " " + url
  322. if (stream_debug == False):
  323. cmd += " 2>/dev/null 1>/dev/null "
  324. cmd += "&"
  325. os.system(cmd)
  326. rpa_server = "ssh.tilab.tuwien.ac.at"
  327. script_dir = os.path.dirname(os.path.realpath(__file__))
  328. connection_lock_file_name = ".connection.rpa"
  329. connection_lock_file_path = script_dir + "/" + connection_lock_file_name
  330. connnection_lock_file = None
  331. cfg_file_name = "rpa_cfg.yml"
  332. cfg_file_path = script_dir + "/" + cfg_file_name
  333. def signal_handler(sig, frame):
  334. global is_master_process, client, lock
  335. if(client != None):
  336. client.ReleaseHost()
  337. if (lock != None):
  338. lock.Close()
  339. lock.Remove()
  340. try:
  341. os.remove(connection_lock_file_path)
  342. except:
  343. pass
  344. sys.exit(0)
  345. def load_cfg(path):
  346. cfg = {"username": None, "identity": None, "stream_cmd":None}
  347. try:
  348. with open(path, "r") as f:
  349. cfg.update(yaml.load(f.read(), Loader=yaml.SafeLoader))
  350. except Exception as ex:
  351. return cfg
  352. return cfg
  353. def interactive_ui(connection):
  354. action = None
  355. stream_msgs = []
  356. streams_seq = ["target", "signal", "oszi"]
  357. stream_key_map = {}
  358. idx = 1
  359. for s in streams_seq:
  360. if(s in connection.Streams):
  361. stream_msgs += [" " + str(idx) + ": open video stream '" + s + "'"]
  362. stream_key_map[str(idx)] = s
  363. idx += 1
  364. for s in connection.Streams.keys():
  365. if (s not in streams_seq):
  366. stream_msg += [" " + str(idx) + ": open video stream '" + s + "'"]
  367. stream_key_map[str(idx)] = s
  368. idx += 1
  369. stream_msg = "\n".join(stream_msgs)
  370. #stream_map = {}
  371. #for name,url in connection.Streams:
  372. while (True):
  373. os.system("clear")
  374. msg = """\
  375. This is the master process for your connection to HOST.
  376. Terminating this process will terminate ALL open connections to this host.
  377. Your lock expires at DEADLINE.
  378. Available commands:
  379. i: open interactive shell
  380. STREAMS
  381. q: quit (terminates all open connections)\
  382. """
  383. msg = msg.replace("HOST", connection.Host)
  384. msg = msg.replace("DEADLINE", connection.Deadline)
  385. msg = msg.replace("STREAMS", stream_msg)
  386. print(msg)
  387. print("Enter command >> ", end="", flush=True)
  388. r = subprocess.run("read -t 1 -N 1; echo $REPLY", shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
  389. action = r.stdout.decode("utf-8").strip()
  390. if(action == "q"):
  391. print()
  392. break
  393. if(action == "i"):
  394. print()
  395. connection.RunShell()
  396. if (action in stream_key_map):
  397. stream_name = stream_key_map[action]
  398. url = connection.Streams[stream_name]
  399. print("opening stream " + stream_name + ": " + url)
  400. open_stream(url)
  401. #if(action == "")
  402. is_master_process = False
  403. client = None
  404. lock = None
  405. def main():
  406. global is_master_process, client, lock
  407. options = docopt(usage_msg, version="1.1.1")
  408. #print(options)
  409. cfg = load_cfg(cfg_file_path)
  410. cfg_streaming(dbg=options["-d"],cmd=cfg["stream_cmd"])
  411. if (options["-u"] != None):
  412. cfg["username"] = options["-u"]
  413. username = cfg["username"]
  414. if (username == None):
  415. print("You did not specify a TILab username!")
  416. username = input("Enter your username: ")
  417. while(True):
  418. response = input("Do you want me to create a config file and add this username? [y/n] ")
  419. response = response.strip().lower()
  420. if (response in ["y", "n"]):
  421. if (response == "y"):
  422. with open(cfg_file_path, "x") as f:
  423. f.write("username: " + username + "\n")
  424. f.write("stream_cmd: " + default_stream_cmd)
  425. break
  426. print("Invalid response, type 'y' or 'n'!")
  427. if (options["-i"] != None):
  428. cfg["identity"] = options["-i"]
  429. if(options["-t"]):
  430. client = RPAClient(rpa_server, username, identity=cfg["identity"])
  431. print(client.ServerStatus())
  432. exit(0)
  433. if (options["--scp"]):
  434. client = RPAClient(rpa_server, username, identity=cfg["identity"])
  435. dest = options["<REMOTE_DEST>"]
  436. if (dest == None):
  437. dest = ""
  438. client.CopyFileToServer(options["<LOCAL_SRC>"], dest)
  439. exit(0)
  440. is_master_process = False
  441. connection = None
  442. lock = ConnectionLockFile(connection_lock_file_path)
  443. if (lock.Exists()):
  444. lock.OpenRead()
  445. connection = lock.ReadConnection()
  446. if (options["-c"] != None):
  447. if (not connection.Host.startswith(options["-c"])):
  448. print("Error: There already exists a connection to " +
  449. connection.Host +
  450. ". If you want to connect to " +
  451. options["-c"] +
  452. " close this connection first.")
  453. exit(1)
  454. if (username != connection.Username):
  455. print("Error: There already exists a connection using a different username '" + connection.Username + "'")
  456. else:
  457. is_master_process = True
  458. signal.signal(signal.SIGINT, signal_handler)
  459. try:
  460. lock.Create()
  461. except:
  462. print("Unable to create connection lock file")
  463. exit(1)
  464. lock.Write()
  465. client = RPAClient(rpa_server, username, identity=cfg["identity"])
  466. client.RequestHost(options["-c"])
  467. try:
  468. client.WaitForHost()
  469. except:
  470. try: lock.Remove()
  471. except: pass
  472. exit(1)
  473. connection = client.Connection
  474. print(">>> Acquired lock on host " + connection.Host + " <<<")
  475. lock.Write(connection)
  476. if (options["-p"] != None):
  477. sof_file = os.path.basename(options["-p"])
  478. connection.RunCommand("mkdir -p ~/.rpa_shell && rm -f ~/.rpa_shell/*.sof")
  479. connection.CopyFileToHost(options["-p"], ".rpa_shell/")
  480. connection.RunCommand("remote.py -p .rpa_shell/"+sof_file)
  481. if (options["-a"]):
  482. sleep(0.5)
  483. for name, url in connection.Streams.items():
  484. print("opening stream " + name + ": " + url)
  485. open_stream(url)
  486. if (options["-s"] != None):
  487. name = options["-s"]
  488. url = connection.Streams.get(name, None)
  489. if(url == None):
  490. print(name + " does not identify a stream")
  491. else:
  492. print("opening stream " + name + ": " + url)
  493. open_stream(url)
  494. if (options["<CMD>"] != None):
  495. connection.RunCommand(options["<CMD>"], ssh_args="-tt")
  496. elif (not options["--no-shell"]):
  497. if(is_master_process):
  498. print(
  499. """\
  500. >>> Close the shell using Ctrl+D or by executing 'exit'. <<<
  501. >>> CAUTION: This is the master process! <<<
  502. >>> Closing this shell will terminate all open connections! <<<\
  503. """)
  504. else:
  505. print(">>> Close the shell using Ctrl+D or by executing 'exit' <<<")
  506. connection.RunShell()
  507. if (options["--no-shell"] and is_master_process):
  508. interactive_ui(connection)
  509. if(is_master_process):
  510. try:
  511. client.ReleaseHost()
  512. lock.Close()
  513. lock.Remove()
  514. except: pass
  515. if(__name__ == "__main__"):
  516. main()