boinc_client.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. #
  4. # client.py - Somewhat higher-level GUI_RPC API for BOINC core client
  5. #
  6. # Copyright (C) 2013 Rodrigo Silva (MestreLion) <linux@rodrigosilva.com>
  7. # Copyright (C) 2017 Austin S. Hemmelgarn
  8. #
  9. # SPDX-License-Identifier: GPL-3.0
  10. # Based on client/boinc_cmd.cpp
  11. import hashlib
  12. import socket
  13. import sys
  14. import time
  15. from functools import total_ordering
  16. from xml.etree import ElementTree
  17. GUI_RPC_PASSWD_FILE = "/var/lib/boinc/gui_rpc_auth.cfg"
  18. GUI_RPC_HOSTNAME = None # localhost
  19. GUI_RPC_PORT = 31416
  20. GUI_RPC_TIMEOUT = 1
  21. class Rpc(object):
  22. ''' Class to perform GUI RPC calls to a BOINC core client.
  23. Usage in a context manager ('with' block) is recommended to ensure
  24. disconnect() is called. Using the same instance for all calls is also
  25. recommended so it reuses the same socket connection
  26. '''
  27. def __init__(self, hostname="", port=0, timeout=0, text_output=False):
  28. self.hostname = hostname
  29. self.port = port
  30. self.timeout = timeout
  31. self.sock = None
  32. self.text_output = text_output
  33. @property
  34. def sockargs(self):
  35. return (self.hostname, self.port, self.timeout)
  36. def __enter__(self): self.connect(*self.sockargs); return self
  37. def __exit__(self, *args): self.disconnect()
  38. def connect(self, hostname="", port=0, timeout=0):
  39. ''' Connect to (hostname, port) with timeout in seconds.
  40. Hostname defaults to None (localhost), and port to 31416
  41. Calling multiple times will disconnect previous connection (if any),
  42. and (re-)connect to host.
  43. '''
  44. if self.sock:
  45. self.disconnect()
  46. self.hostname = hostname or GUI_RPC_HOSTNAME
  47. self.port = port or GUI_RPC_PORT
  48. self.timeout = timeout or GUI_RPC_TIMEOUT
  49. self.sock = socket.create_connection(self.sockargs[0:2], self.sockargs[2])
  50. def disconnect(self):
  51. ''' Disconnect from host. Calling multiple times is OK (idempotent)
  52. '''
  53. if self.sock:
  54. self.sock.close()
  55. self.sock = None
  56. def call(self, request, text_output=None):
  57. ''' Do an RPC call. Pack and send the XML request and return the
  58. unpacked reply. request can be either plain XML text or a
  59. xml.etree.ElementTree.Element object. Return ElementTree.Element
  60. or XML text according to text_output flag.
  61. Will auto-connect if not connected.
  62. '''
  63. if text_output is None:
  64. text_output = self.text_output
  65. if not self.sock:
  66. self.connect(*self.sockargs)
  67. if not isinstance(request, ElementTree.Element):
  68. request = ElementTree.fromstring(request)
  69. # pack request
  70. end = '\003'
  71. if sys.version_info[0] < 3:
  72. req = "<boinc_gui_rpc_request>\n{0}\n</boinc_gui_rpc_request>\n{1}".format(ElementTree.tostring(request).replace(' />', '/>'), end)
  73. else:
  74. req = "<boinc_gui_rpc_request>\n{0}\n</boinc_gui_rpc_request>\n{1}".format(ElementTree.tostring(request, encoding='unicode').replace(' />', '/>'), end).encode()
  75. try:
  76. self.sock.sendall(req)
  77. except (socket.error, socket.herror, socket.gaierror, socket.timeout):
  78. raise
  79. req = ""
  80. while True:
  81. try:
  82. buf = self.sock.recv(8192)
  83. if not buf:
  84. raise socket.error("No data from socket")
  85. if sys.version_info[0] >= 3:
  86. buf = buf.decode()
  87. except socket.error:
  88. raise
  89. n = buf.find(end)
  90. if not n == -1: break
  91. req += buf
  92. req += buf[:n]
  93. # unpack reply (remove root tag, ie: first and last lines)
  94. req = '\n'.join(req.strip().rsplit('\n')[1:-1])
  95. if text_output:
  96. return req
  97. else:
  98. return ElementTree.fromstring(req)
  99. def setattrs_from_xml(obj, xml, attrfuncdict={}):
  100. ''' Helper to set values for attributes of a class instance by mapping
  101. matching tags from a XML file.
  102. attrfuncdict is a dict of functions to customize value data type of
  103. each attribute. It falls back to simple int/float/bool/str detection
  104. based on values defined in __init__(). This would not be needed if
  105. Boinc used standard RPC protocol, which includes data type in XML.
  106. '''
  107. if not isinstance(xml, ElementTree.Element):
  108. xml = ElementTree.fromstring(xml)
  109. for e in list(xml):
  110. if hasattr(obj, e.tag):
  111. attr = getattr(obj, e.tag)
  112. attrfunc = attrfuncdict.get(e.tag, None)
  113. if attrfunc is None:
  114. if isinstance(attr, bool): attrfunc = parse_bool
  115. elif isinstance(attr, int): attrfunc = parse_int
  116. elif isinstance(attr, float): attrfunc = parse_float
  117. elif isinstance(attr, str): attrfunc = parse_str
  118. elif isinstance(attr, list): attrfunc = parse_list
  119. else: attrfunc = lambda x: x
  120. setattr(obj, e.tag, attrfunc(e))
  121. else:
  122. pass
  123. #print "class missing attribute '%s': %r" % (e.tag, obj)
  124. return obj
  125. def parse_bool(e):
  126. ''' Helper to convert ElementTree.Element.text to boolean.
  127. Treat '<foo/>' (and '<foo>[[:blank:]]</foo>') as True
  128. Treat '0' and 'false' as False
  129. '''
  130. if e.text is None:
  131. return True
  132. else:
  133. return bool(e.text) and not e.text.strip().lower() in ('0', 'false')
  134. def parse_int(e):
  135. ''' Helper to convert ElementTree.Element.text to integer.
  136. Treat '<foo/>' (and '<foo></foo>') as 0
  137. '''
  138. # int(float()) allows casting to int a value expressed as float in XML
  139. return 0 if e.text is None else int(float(e.text.strip()))
  140. def parse_float(e):
  141. ''' Helper to convert ElementTree.Element.text to float. '''
  142. return 0.0 if e.text is None else float(e.text.strip())
  143. def parse_str(e):
  144. ''' Helper to convert ElementTree.Element.text to string. '''
  145. return "" if e.text is None else e.text.strip()
  146. def parse_list(e):
  147. ''' Helper to convert ElementTree.Element to list. For now, simply return
  148. the list of root element's children
  149. '''
  150. return list(e)
  151. class Enum(object):
  152. UNKNOWN = -1 # Not in original API
  153. @classmethod
  154. def name(cls, value):
  155. ''' Quick-and-dirty fallback for getting the "name" of an enum item '''
  156. # value as string, if it matches an enum attribute.
  157. # Allows short usage as Enum.name("VALUE") besides Enum.name(Enum.VALUE)
  158. if hasattr(cls, str(value)):
  159. return cls.name(getattr(cls, value, None))
  160. # value not handled in subclass name()
  161. for k, v in cls.__dict__.items():
  162. if v == value:
  163. return k.lower().replace('_', ' ')
  164. # value not found
  165. return cls.name(Enum.UNKNOWN)
  166. class CpuSched(Enum):
  167. ''' values of ACTIVE_TASK::scheduler_state and ACTIVE_TASK::next_scheduler_state
  168. "SCHEDULED" is synonymous with "executing" except when CPU throttling
  169. is in use.
  170. '''
  171. UNINITIALIZED = 0
  172. PREEMPTED = 1
  173. SCHEDULED = 2
  174. class ResultState(Enum):
  175. ''' Values of RESULT::state in client.
  176. THESE MUST BE IN NUMERICAL ORDER
  177. (because of the > comparison in RESULT::computing_done())
  178. see html/inc/common_defs.inc
  179. '''
  180. NEW = 0
  181. #// New result
  182. FILES_DOWNLOADING = 1
  183. #// Input files for result (WU, app version) are being downloaded
  184. FILES_DOWNLOADED = 2
  185. #// Files are downloaded, result can be (or is being) computed
  186. COMPUTE_ERROR = 3
  187. #// computation failed; no file upload
  188. FILES_UPLOADING = 4
  189. #// Output files for result are being uploaded
  190. FILES_UPLOADED = 5
  191. #// Files are uploaded, notify scheduling server at some point
  192. ABORTED = 6
  193. #// result was aborted
  194. UPLOAD_FAILED = 7
  195. #// some output file permanent failure
  196. class Process(Enum):
  197. ''' values of ACTIVE_TASK::task_state '''
  198. UNINITIALIZED = 0
  199. #// process doesn't exist yet
  200. EXECUTING = 1
  201. #// process is running, as far as we know
  202. SUSPENDED = 9
  203. #// we've sent it a "suspend" message
  204. ABORT_PENDING = 5
  205. #// process exceeded limits; send "abort" message, waiting to exit
  206. QUIT_PENDING = 8
  207. #// we've sent it a "quit" message, waiting to exit
  208. COPY_PENDING = 10
  209. #// waiting for async file copies to finish
  210. class _Struct(object):
  211. ''' base helper class with common methods for all classes derived from
  212. BOINC's C++ structs
  213. '''
  214. @classmethod
  215. def parse(cls, xml):
  216. return setattrs_from_xml(cls(), xml)
  217. def __str__(self, indent=0):
  218. buf = '{0}{1}:\n'.format('\t' * indent, self.__class__.__name__)
  219. for attr in self.__dict__:
  220. value = getattr(self, attr)
  221. if isinstance(value, list):
  222. buf += '{0}\t{1} [\n'.format('\t' * indent, attr)
  223. for v in value: buf += '\t\t{0}\t\t,\n'.format(v)
  224. buf += '\t]\n'
  225. else:
  226. buf += '{0}\t{1}\t{2}\n'.format('\t' * indent,
  227. attr,
  228. value.__str__(indent+2)
  229. if isinstance(value, _Struct)
  230. else repr(value))
  231. return buf
  232. @total_ordering
  233. class VersionInfo(_Struct):
  234. def __init__(self, major=0, minor=0, release=0):
  235. self.major = major
  236. self.minor = minor
  237. self.release = release
  238. @property
  239. def _tuple(self):
  240. return (self.major, self.minor, self.release)
  241. def __eq__(self, other):
  242. return isinstance(other, self.__class__) and self._tuple == other._tuple
  243. def __ne__(self, other):
  244. return not self.__eq__(other)
  245. def __gt__(self, other):
  246. if not isinstance(other, self.__class__):
  247. return NotImplemented
  248. return self._tuple > other._tuple
  249. def __str__(self):
  250. return "{0}.{1}.{2}".format(self.major, self.minor, self.release)
  251. def __repr__(self):
  252. return "{0}{1}".format(self.__class__.__name__, self._tuple)
  253. class Result(_Struct):
  254. ''' Also called "task" in some contexts '''
  255. def __init__(self):
  256. # Names and values follow lib/gui_rpc_client.h @ RESULT
  257. # Order too, except when grouping contradicts client/result.cpp
  258. # RESULT::write_gui(), then XML order is used.
  259. self.name = ""
  260. self.wu_name = ""
  261. self.version_num = 0
  262. #// identifies the app used
  263. self.plan_class = ""
  264. self.project_url = "" # from PROJECT.master_url
  265. self.report_deadline = 0.0 # seconds since epoch
  266. self.received_time = 0.0 # seconds since epoch
  267. #// when we got this from server
  268. self.ready_to_report = False
  269. #// we're ready to report this result to the server;
  270. #// either computation is done and all the files have been uploaded
  271. #// or there was an error
  272. self.got_server_ack = False
  273. #// we've received the ack for this result from the server
  274. self.final_cpu_time = 0.0
  275. self.final_elapsed_time = 0.0
  276. self.state = ResultState.NEW
  277. self.estimated_cpu_time_remaining = 0.0
  278. #// actually, estimated elapsed time remaining
  279. self.exit_status = 0
  280. #// return value from the application
  281. self.suspended_via_gui = False
  282. self.project_suspended_via_gui = False
  283. self.edf_scheduled = False
  284. #// temporary used to tell GUI that this result is deadline-scheduled
  285. self.coproc_missing = False
  286. #// a coproc needed by this job is missing
  287. #// (e.g. because user removed their GPU board).
  288. self.scheduler_wait = False
  289. self.scheduler_wait_reason = ""
  290. self.network_wait = False
  291. self.resources = ""
  292. #// textual description of resources used
  293. #// the following defined if active
  294. # XML is generated in client/app.cpp ACTIVE_TASK::write_gui()
  295. self.active_task = False
  296. self.active_task_state = Process.UNINITIALIZED
  297. self.app_version_num = 0
  298. self.slot = -1
  299. self.pid = 0
  300. self.scheduler_state = CpuSched.UNINITIALIZED
  301. self.checkpoint_cpu_time = 0.0
  302. self.current_cpu_time = 0.0
  303. self.fraction_done = 0.0
  304. self.elapsed_time = 0.0
  305. self.swap_size = 0
  306. self.working_set_size_smoothed = 0.0
  307. self.too_large = False
  308. self.needs_shmem = False
  309. self.graphics_exec_path = ""
  310. self.web_graphics_url = ""
  311. self.remote_desktop_addr = ""
  312. self.slot_path = ""
  313. #// only present if graphics_exec_path is
  314. # The following are not in original API, but are present in RPC XML reply
  315. self.completed_time = 0.0
  316. #// time when ready_to_report was set
  317. self.report_immediately = False
  318. self.working_set_size = 0
  319. self.page_fault_rate = 0.0
  320. #// derived by higher-level code
  321. # The following are in API, but are NEVER in RPC XML reply. Go figure
  322. self.signal = 0
  323. self.app = None # APP*
  324. self.wup = None # WORKUNIT*
  325. self.project = None # PROJECT*
  326. self.avp = None # APP_VERSION*
  327. @classmethod
  328. def parse(cls, xml):
  329. if not isinstance(xml, ElementTree.Element):
  330. xml = ElementTree.fromstring(xml)
  331. # parse main XML
  332. result = super(Result, cls).parse(xml)
  333. # parse '<active_task>' children
  334. active_task = xml.find('active_task')
  335. if active_task is None:
  336. result.active_task = False # already the default after __init__()
  337. else:
  338. result.active_task = True # already the default after main parse
  339. result = setattrs_from_xml(result, active_task)
  340. #// if CPU time is nonzero but elapsed time is zero,
  341. #// we must be talking to an old client.
  342. #// Set elapsed = CPU
  343. #// (easier to deal with this here than in the manager)
  344. if result.current_cpu_time != 0 and result.elapsed_time == 0:
  345. result.elapsed_time = result.current_cpu_time
  346. if result.final_cpu_time != 0 and result.final_elapsed_time == 0:
  347. result.final_elapsed_time = result.final_cpu_time
  348. return result
  349. def __str__(self):
  350. buf = '{0}:\n'.format(self.__class__.__name__)
  351. for attr in self.__dict__:
  352. value = getattr(self, attr)
  353. if attr in ['received_time', 'report_deadline']:
  354. value = time.ctime(value)
  355. buf += '\t{0}\t{1}\n'.format(attr, value)
  356. return buf
  357. class BoincClient(object):
  358. def __init__(self, host="", port=0, passwd=None):
  359. self.hostname = host
  360. self.port = port
  361. self.passwd = passwd
  362. self.rpc = Rpc(text_output=False)
  363. self.version = None
  364. self.authorized = False
  365. # Informative, not authoritative. Records status of *last* RPC call,
  366. # but does not infer success about the *next* one.
  367. # Thus, it should be read *after* an RPC call, not prior to one
  368. self.connected = False
  369. def __enter__(self): self.connect(); return self
  370. def __exit__(self, *args): self.disconnect()
  371. def connect(self):
  372. try:
  373. self.rpc.connect(self.hostname, self.port)
  374. self.connected = True
  375. except socket.error:
  376. self.connected = False
  377. return
  378. self.authorized = self.authorize(self.passwd)
  379. self.version = self.exchange_versions()
  380. def disconnect(self):
  381. self.rpc.disconnect()
  382. def authorize(self, password):
  383. ''' Request authorization. If password is None and we are connecting
  384. to localhost, try to read password from the local config file
  385. GUI_RPC_PASSWD_FILE. If file can't be read (not found or no
  386. permission to read), try to authorize with a blank password.
  387. If authorization is requested and fails, all subsequent calls
  388. will be refused with socket.error 'Connection reset by peer' (104).
  389. Since most local calls do no require authorization, do not attempt
  390. it if you're not sure about the password.
  391. '''
  392. if password is None and not self.hostname:
  393. password = read_gui_rpc_password() or ""
  394. nonce = self.rpc.call('<auth1/>').text
  395. authhash = hashlib.md5('{0}{1}'.format(nonce, password).encode()).hexdigest().lower()
  396. reply = self.rpc.call('<auth2><nonce_hash>{0}</nonce_hash></auth2>'.format(authhash))
  397. if reply.tag == 'authorized':
  398. return True
  399. else:
  400. return False
  401. def exchange_versions(self):
  402. ''' Return VersionInfo instance with core client version info '''
  403. return VersionInfo.parse(self.rpc.call('<exchange_versions/>'))
  404. def get_tasks(self):
  405. ''' Same as get_results(active_only=False) '''
  406. return self.get_results(False)
  407. def get_results(self, active_only=False):
  408. ''' Get a list of results.
  409. Those that are in progress will have information such as CPU time
  410. and fraction done. Each result includes a name;
  411. Use CC_STATE::lookup_result() to find this result in the current static state;
  412. if it's not there, call get_state() again.
  413. '''
  414. reply = self.rpc.call("<get_results><active_only>{0}</active_only></get_results>".format(1 if active_only else 0))
  415. if not reply.tag == 'results':
  416. return []
  417. results = []
  418. for item in list(reply):
  419. results.append(Result.parse(item))
  420. return results
  421. def read_gui_rpc_password():
  422. ''' Read password string from GUI_RPC_PASSWD_FILE file, trim the last CR
  423. (if any), and return it
  424. '''
  425. try:
  426. with open(GUI_RPC_PASSWD_FILE, 'r') as f:
  427. buf = f.read()
  428. if buf.endswith('\n'): return buf[:-1] # trim last CR
  429. else: return buf
  430. except IOError:
  431. # Permission denied or File not found.
  432. pass