rpa_shell.py 17 KB

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