web.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. import os
  2. import re
  3. import time
  4. import traceback
  5. import datetime
  6. import meld3
  7. from supervisor.compat import StringIO
  8. from supervisor.compat import urllib
  9. from supervisor.compat import parse_qs
  10. from supervisor.compat import parse_qsl
  11. from supervisor.compat import as_string
  12. from supervisor.compat import PY3
  13. from supervisor.medusa import producers
  14. from supervisor.medusa.http_server import http_date
  15. from supervisor.medusa.http_server import get_header
  16. from supervisor.medusa.xmlrpc_handler import collector
  17. from supervisor.process import ProcessStates
  18. from supervisor.http import NOT_DONE_YET
  19. from supervisor.options import VERSION
  20. from supervisor.options import make_namespec
  21. from supervisor.options import split_namespec
  22. from supervisor.xmlrpc import SystemNamespaceRPCInterface
  23. from supervisor.xmlrpc import RootRPCInterface
  24. from supervisor.xmlrpc import Faults
  25. from supervisor.xmlrpc import RPCError
  26. from supervisor.rpcinterface import SupervisorNamespaceRPCInterface
  27. class DeferredWebProducer:
  28. """ A medusa producer that implements a deferred callback; requires
  29. a subclass of asynchat.async_chat that handles NOT_DONE_YET sentinel """
  30. CONNECTION = re.compile ('Connection: (.*)', re.IGNORECASE)
  31. def __init__(self, request, callback):
  32. self.callback = callback
  33. self.request = request
  34. self.finished = False
  35. self.delay = float(callback.delay)
  36. def more(self):
  37. if self.finished:
  38. return ''
  39. try:
  40. response = self.callback()
  41. if response is NOT_DONE_YET:
  42. return NOT_DONE_YET
  43. self.finished = True
  44. return self.sendresponse(response)
  45. except:
  46. io = StringIO()
  47. traceback.print_exc(file=io)
  48. # this should go to the main supervisor log file
  49. self.request.channel.server.logger.log('Web interface error',
  50. io.getvalue())
  51. self.finished = True
  52. self.request.error(500)
  53. def sendresponse(self, response):
  54. headers = response.get('headers', {})
  55. for header in headers:
  56. self.request[header] = headers[header]
  57. if 'Content-Type' not in self.request:
  58. self.request['Content-Type'] = 'text/plain'
  59. if headers.get('Location'):
  60. self.request['Content-Length'] = 0
  61. self.request.error(301)
  62. return
  63. body = response.get('body', '')
  64. self.request['Content-Length'] = len(body)
  65. self.request.push(body)
  66. connection = get_header(self.CONNECTION, self.request.header)
  67. close_it = 0
  68. wrap_in_chunking = 0
  69. if self.request.version == '1.0':
  70. if connection == 'keep-alive':
  71. if not self.request.has_key('Content-Length'):
  72. close_it = 1
  73. else:
  74. self.request['Connection'] = 'Keep-Alive'
  75. else:
  76. close_it = 1
  77. elif self.request.version == '1.1':
  78. if connection == 'close':
  79. close_it = 1
  80. elif 'Content-Length' not in self.request:
  81. if 'Transfer-Encoding' in self.request:
  82. if not self.request['Transfer-Encoding'] == 'chunked':
  83. close_it = 1
  84. elif self.request.use_chunked:
  85. self.request['Transfer-Encoding'] = 'chunked'
  86. wrap_in_chunking = 1
  87. else:
  88. close_it = 1
  89. elif self.request.version is None:
  90. close_it = 1
  91. outgoing_header = producers.simple_producer (
  92. self.request.build_reply_header())
  93. if close_it:
  94. self.request['Connection'] = 'close'
  95. if wrap_in_chunking:
  96. outgoing_producer = producers.chunked_producer (
  97. producers.composite_producer (self.request.outgoing)
  98. )
  99. # prepend the header
  100. outgoing_producer = producers.composite_producer(
  101. [outgoing_header, outgoing_producer]
  102. )
  103. else:
  104. # fix AttributeError: 'unicode' object has no attribute 'more'
  105. if (not PY3) and (len(self.request.outgoing) > 0):
  106. body = self.request.outgoing[0]
  107. if isinstance(body, unicode):
  108. self.request.outgoing[0] = producers.simple_producer (body)
  109. # prepend the header
  110. self.request.outgoing.insert(0, outgoing_header)
  111. outgoing_producer = producers.composite_producer (
  112. self.request.outgoing)
  113. # apply a few final transformations to the output
  114. self.request.channel.push_with_producer (
  115. # globbing gives us large packets
  116. producers.globbing_producer (
  117. # hooking lets us log the number of bytes sent
  118. producers.hooked_producer (
  119. outgoing_producer,
  120. self.request.log
  121. )
  122. )
  123. )
  124. self.request.channel.current_request = None
  125. if close_it:
  126. self.request.channel.close_when_done()
  127. class ViewContext:
  128. def __init__(self, **kw):
  129. self.__dict__.update(kw)
  130. class MeldView:
  131. content_type = 'text/html'
  132. delay = .5
  133. def __init__(self, context):
  134. self.context = context
  135. template = self.context.template
  136. if not os.path.isabs(template):
  137. here = os.path.abspath(os.path.dirname(__file__))
  138. template = os.path.join(here, template)
  139. self.root = meld3.parse_xml(template)
  140. self.callback = None
  141. def __call__(self):
  142. body = self.render()
  143. if body is NOT_DONE_YET:
  144. return NOT_DONE_YET
  145. response = self.context.response
  146. headers = response['headers']
  147. headers['Content-Type'] = self.content_type
  148. headers['Pragma'] = 'no-cache'
  149. headers['Cache-Control'] = 'no-cache'
  150. headers['Expires'] = http_date.build_http_date(0)
  151. response['body'] = as_string(body)
  152. return response
  153. def render(self):
  154. pass
  155. def clone(self):
  156. return self.root.clone()
  157. class TailView(MeldView):
  158. def render(self):
  159. supervisord = self.context.supervisord
  160. form = self.context.form
  161. if not 'processname' in form:
  162. tail = 'No process name found'
  163. processname = None
  164. else:
  165. processname = form['processname']
  166. offset = 0
  167. limit = form.get('limit', '1024')
  168. limit = min(-1024, int(limit)*-1 if limit.isdigit() else -1024)
  169. if not processname:
  170. tail = 'No process name found'
  171. else:
  172. rpcinterface = SupervisorNamespaceRPCInterface(supervisord)
  173. try:
  174. tail = rpcinterface.readProcessStdoutLog(processname,
  175. limit, offset)
  176. except RPCError as e:
  177. if e.code == Faults.NO_FILE:
  178. tail = 'No file for %s' % processname
  179. else:
  180. raise
  181. root = self.clone()
  182. title = root.findmeld('title')
  183. title.content('Supervisor tail of process %s' % processname)
  184. tailbody = root.findmeld('tailbody')
  185. tailbody.content(tail)
  186. refresh_anchor = root.findmeld('refresh_anchor')
  187. if processname is not None:
  188. refresh_anchor.attributes(
  189. href='tail.html?processname=%s&limit=%s' % (
  190. urllib.quote(processname), urllib.quote(str(abs(limit)))
  191. )
  192. )
  193. else:
  194. refresh_anchor.deparent()
  195. return as_string(root.write_xhtmlstring())
  196. class StatusView(MeldView):
  197. def actions_for_process(self, process):
  198. state = process.get_state()
  199. processname = urllib.quote(make_namespec(process.group.config.name,
  200. process.config.name))
  201. start = {
  202. 'name':'Start',
  203. 'href':'index.html?processname=%s&action=start' % processname,
  204. 'target':None,
  205. }
  206. restart = {
  207. 'name':'Restart',
  208. 'href':'index.html?processname=%s&action=restart' % processname,
  209. 'target':None,
  210. }
  211. stop = {
  212. 'name':'Stop',
  213. 'href':'index.html?processname=%s&action=stop' % processname,
  214. 'target':None,
  215. }
  216. clearlog = {
  217. 'name':'Clear Log',
  218. 'href':'index.html?processname=%s&action=clearlog' % processname,
  219. 'target':None,
  220. }
  221. tailf = {
  222. 'name':'Tail -f',
  223. 'href':'logtail/%s' % processname,
  224. 'target':'_blank'
  225. }
  226. if state == ProcessStates.RUNNING:
  227. actions = [restart, stop, clearlog, tailf]
  228. elif state in (ProcessStates.STOPPED, ProcessStates.EXITED,
  229. ProcessStates.FATAL):
  230. actions = [start, None, clearlog, tailf]
  231. else:
  232. actions = [None, None, clearlog, tailf]
  233. return actions
  234. def css_class_for_state(self, state):
  235. if state == ProcessStates.RUNNING:
  236. return 'statusrunning'
  237. elif state in (ProcessStates.FATAL, ProcessStates.BACKOFF):
  238. return 'statuserror'
  239. else:
  240. return 'statusnominal'
  241. def make_callback(self, namespec, action):
  242. supervisord = self.context.supervisord
  243. # the rpc interface code is already written to deal properly in a
  244. # deferred world, so just use it
  245. main = ('supervisor', SupervisorNamespaceRPCInterface(supervisord))
  246. system = ('system', SystemNamespaceRPCInterface([main]))
  247. rpcinterface = RootRPCInterface([main, system])
  248. if action:
  249. if action == 'refresh':
  250. def donothing():
  251. message = 'Page refreshed at %s' % time.ctime()
  252. return message
  253. donothing.delay = 0.05
  254. return donothing
  255. elif action == 'stopall':
  256. callback = rpcinterface.supervisor.stopAllProcesses()
  257. def stopall():
  258. if callback() is NOT_DONE_YET:
  259. return NOT_DONE_YET
  260. else:
  261. return 'All stopped at %s' % time.ctime()
  262. stopall.delay = 0.05
  263. return stopall
  264. elif action == 'restartall':
  265. callback = rpcinterface.system.multicall(
  266. [ {'methodName':'supervisor.stopAllProcesses'},
  267. {'methodName':'supervisor.startAllProcesses'} ] )
  268. def restartall():
  269. result = callback()
  270. if result is NOT_DONE_YET:
  271. return NOT_DONE_YET
  272. return 'All restarted at %s' % time.ctime()
  273. restartall.delay = 0.05
  274. return restartall
  275. elif namespec:
  276. def wrong():
  277. return 'No such process named %s' % namespec
  278. wrong.delay = 0.05
  279. group_name, process_name = split_namespec(namespec)
  280. group = supervisord.process_groups.get(group_name)
  281. if group is None:
  282. return wrong
  283. process = group.processes.get(process_name)
  284. if process is None:
  285. return wrong
  286. elif action == 'stop':
  287. callback = rpcinterface.supervisor.stopProcess(namespec)
  288. def stopprocess():
  289. result = callback()
  290. if result is NOT_DONE_YET:
  291. return NOT_DONE_YET
  292. return 'Process %s stopped' % namespec
  293. stopprocess.delay = 0.05
  294. return stopprocess
  295. elif action == 'restart':
  296. callback = rpcinterface.system.multicall(
  297. [ {'methodName':'supervisor.stopProcess',
  298. 'params': [namespec]},
  299. {'methodName':'supervisor.startProcess',
  300. 'params': [namespec]},
  301. ]
  302. )
  303. def restartprocess():
  304. result = callback()
  305. if result is NOT_DONE_YET:
  306. return NOT_DONE_YET
  307. return 'Process %s restarted' % namespec
  308. restartprocess.delay = 0.05
  309. return restartprocess
  310. elif action == 'start':
  311. try:
  312. callback = rpcinterface.supervisor.startProcess(
  313. namespec)
  314. except RPCError as e:
  315. if e.code == Faults.NO_FILE:
  316. msg = 'no such file'
  317. elif e.code == Faults.NOT_EXECUTABLE:
  318. msg = 'file not executable'
  319. elif e.code == Faults.ALREADY_STARTED:
  320. msg = 'already started'
  321. elif e.code == Faults.SPAWN_ERROR:
  322. msg = 'spawn error'
  323. elif e.code == Faults.ABNORMAL_TERMINATION:
  324. msg = 'abnormal termination'
  325. else:
  326. msg = 'unexpected rpc fault code %d' % e.code
  327. def starterr():
  328. return 'ERROR: Process %s: %s' % (namespec, msg)
  329. starterr.delay = 0.05
  330. return starterr
  331. def startprocess():
  332. try:
  333. result = callback()
  334. except RPCError as e:
  335. if e.code == Faults.SPAWN_ERROR:
  336. msg = 'spawn error'
  337. elif e.code == Faults.ABNORMAL_TERMINATION:
  338. msg = 'abnormal termination'
  339. else:
  340. msg = 'unexpected rpc fault code %d' % e.code
  341. return 'ERROR: Process %s: %s' % (namespec, msg)
  342. if result is NOT_DONE_YET:
  343. return NOT_DONE_YET
  344. return 'Process %s started' % namespec
  345. startprocess.delay = 0.05
  346. return startprocess
  347. elif action == 'clearlog':
  348. callback = rpcinterface.supervisor.clearProcessLogs(
  349. namespec)
  350. def clearlog():
  351. return 'Log for %s cleared' % namespec
  352. clearlog.delay = 0.05
  353. return clearlog
  354. raise ValueError(action)
  355. def render(self):
  356. form = self.context.form
  357. response = self.context.response
  358. processname = form.get('processname')
  359. action = form.get('action')
  360. message = form.get('message')
  361. if action:
  362. if not self.callback:
  363. self.callback = self.make_callback(processname, action)
  364. return NOT_DONE_YET
  365. else:
  366. message = self.callback()
  367. if message is NOT_DONE_YET:
  368. return NOT_DONE_YET
  369. if message is not None:
  370. server_url = form['SERVER_URL']
  371. location = server_url + '?message=%s' % urllib.quote(
  372. message)
  373. response['headers']['Location'] = location
  374. supervisord = self.context.supervisord
  375. rpcinterface = RootRPCInterface(
  376. [('supervisor',
  377. SupervisorNamespaceRPCInterface(supervisord))]
  378. )
  379. processnames = []
  380. for group in supervisord.process_groups.values():
  381. for gprocname in group.processes.keys():
  382. processnames.append((group.config.name, gprocname))
  383. processnames.sort()
  384. data = []
  385. for groupname, processname in processnames:
  386. actions = self.actions_for_process(
  387. supervisord.process_groups[groupname].processes[processname])
  388. sent_name = make_namespec(groupname, processname)
  389. info = rpcinterface.supervisor.getProcessInfo(sent_name)
  390. data.append({
  391. 'status':info['statename'],
  392. 'name':processname,
  393. 'group':groupname,
  394. 'actions':actions,
  395. 'state':info['state'],
  396. 'description':info['description'],
  397. })
  398. root = self.clone()
  399. if message is not None:
  400. statusarea = root.findmeld('statusmessage')
  401. statusarea.attrib['class'] = 'status_msg'
  402. statusarea.content(message)
  403. if data:
  404. iterator = root.findmeld('tr').repeat(data)
  405. shaded_tr = False
  406. for tr_element, item in iterator:
  407. status_text = tr_element.findmeld('status_text')
  408. status_text.content(item['status'].lower())
  409. status_text.attrib['class'] = self.css_class_for_state(
  410. item['state'])
  411. info_text = tr_element.findmeld('info_text')
  412. info_text.content(item['description'])
  413. anchor = tr_element.findmeld('name_anchor')
  414. processname = make_namespec(item['group'], item['name'])
  415. anchor.attributes(href='tail.html?processname=%s' %
  416. urllib.quote(processname))
  417. anchor.content(processname)
  418. actions = item['actions']
  419. actionitem_td = tr_element.findmeld('actionitem_td')
  420. for li_element, actionitem in actionitem_td.repeat(actions):
  421. anchor = li_element.findmeld('actionitem_anchor')
  422. if actionitem is None:
  423. anchor.attrib['class'] = 'hidden'
  424. else:
  425. anchor.attributes(href=actionitem['href'],
  426. name=actionitem['name'])
  427. anchor.content(actionitem['name'])
  428. if actionitem['target']:
  429. anchor.attributes(target=actionitem['target'])
  430. if shaded_tr:
  431. tr_element.attrib['class'] = 'shade'
  432. shaded_tr = not shaded_tr
  433. else:
  434. table = root.findmeld('statustable')
  435. table.replace('No programs to manage')
  436. root.findmeld('supervisor_version').content(VERSION)
  437. copyright_year = str(datetime.date.today().year)
  438. root.findmeld('copyright_date').content(copyright_year)
  439. return as_string(root.write_xhtmlstring())
  440. class OKView:
  441. delay = 0
  442. def __init__(self, context):
  443. self.context = context
  444. def __call__(self):
  445. return {'body':'OK'}
  446. VIEWS = {
  447. 'index.html': {
  448. 'template':'ui/status.html',
  449. 'view':StatusView
  450. },
  451. 'tail.html': {
  452. 'template':'ui/tail.html',
  453. 'view':TailView,
  454. },
  455. 'ok.html': {
  456. 'template':None,
  457. 'view':OKView,
  458. },
  459. }
  460. class supervisor_ui_handler:
  461. IDENT = 'Supervisor Web UI HTTP Request Handler'
  462. def __init__(self, supervisord):
  463. self.supervisord = supervisord
  464. def match(self, request):
  465. if request.command not in ('POST', 'GET'):
  466. return False
  467. path, params, query, fragment = request.split_uri()
  468. while path.startswith('/'):
  469. path = path[1:]
  470. if not path:
  471. path = 'index.html'
  472. for viewname in VIEWS.keys():
  473. if viewname == path:
  474. return True
  475. def handle_request(self, request):
  476. if request.command == 'POST':
  477. request.collector = collector(self, request)
  478. else:
  479. self.continue_request('', request)
  480. def continue_request (self, data, request):
  481. form = {}
  482. cgi_env = request.cgi_environment()
  483. form.update(cgi_env)
  484. if 'QUERY_STRING' not in form:
  485. form['QUERY_STRING'] = ''
  486. query = form['QUERY_STRING']
  487. # we only handle x-www-form-urlencoded values from POSTs
  488. form_urlencoded = parse_qsl(data)
  489. query_data = parse_qs(query)
  490. for k, v in query_data.items():
  491. # ignore dupes
  492. form[k] = v[0]
  493. for k, v in form_urlencoded:
  494. # ignore dupes
  495. form[k] = v
  496. form['SERVER_URL'] = request.get_server_url()
  497. path = form['PATH_INFO']
  498. # strip off all leading slashes
  499. while path and path[0] == '/':
  500. path = path[1:]
  501. if not path:
  502. path = 'index.html'
  503. viewinfo = VIEWS.get(path)
  504. if viewinfo is None:
  505. # this should never happen if our match method works
  506. return
  507. response = {'headers': {}}
  508. viewclass = viewinfo['view']
  509. viewtemplate = viewinfo['template']
  510. context = ViewContext(template=viewtemplate,
  511. request = request,
  512. form = form,
  513. response = response,
  514. supervisord=self.supervisord)
  515. view = viewclass(context)
  516. pushproducer = request.channel.push_with_producer
  517. pushproducer(DeferredWebProducer(request, view))