PageRenderTime 55ms CodeModel.GetById 17ms RepoModel.GetById 1ms app.codeStats 0ms

/LabController/src/bkr/labcontroller/proxy.py

https://github.com/beaker-project/beaker
Python | 1050 lines | 933 code | 34 blank | 83 comment | 51 complexity | 2d0a8781d4d384fdcc1d173a9c664247 MD5 | raw file
Possible License(s): GPL-2.0, CC-BY-SA-3.0
  1. # This program is free software; you can redistribute it and/or modify
  2. # it under the terms of the GNU General Public License as published by
  3. # the Free Software Foundation; either version 2 of the License, or
  4. # (at your option) any later version.
  5. import errno
  6. import os
  7. import sys
  8. import logging
  9. import time
  10. import base64
  11. import lxml.etree
  12. import re
  13. import json
  14. import shutil
  15. import tempfile
  16. import xmlrpclib
  17. import subprocess
  18. import pkg_resources
  19. import shlex
  20. from xml.sax.saxutils import escape as xml_escape, quoteattr as xml_quoteattr
  21. from werkzeug.wrappers import Response
  22. from werkzeug.exceptions import BadRequest, NotAcceptable, NotFound, \
  23. LengthRequired, UnsupportedMediaType, Conflict
  24. from werkzeug.utils import redirect
  25. from werkzeug.http import parse_content_range_header
  26. from werkzeug.wsgi import wrap_file
  27. from bkr.common.hub import HubProxy
  28. from bkr.labcontroller.config import get_conf
  29. from bkr.labcontroller.log_storage import LogStorage
  30. import utils
  31. try:
  32. #pylint: disable=E0611
  33. from subprocess import check_output
  34. except ImportError:
  35. from utils import check_output
  36. logger = logging.getLogger(__name__)
  37. def replace_with_blanks(match):
  38. return ' ' * (match.end() - match.start() - 1) + '\n'
  39. class ProxyHelper(object):
  40. def __init__(self, conf=None, hub=None, **kwargs):
  41. self.conf = get_conf()
  42. # update data from another config
  43. if conf is not None:
  44. self.conf.load_from_conf(conf)
  45. # update data from config specified in os.environ
  46. conf_environ_key = "BEAKER_PROXY_CONFIG_FILE"
  47. if conf_environ_key in os.environ:
  48. self.conf.load_from_file(os.environ[conf_environ_key])
  49. self.conf.load_from_dict(kwargs)
  50. # self.hub is created here
  51. self.hub = hub
  52. if self.hub is None:
  53. self.hub = HubProxy(logger=logging.getLogger('bkr.common.hub.HubProxy'), conf=self.conf,
  54. **kwargs)
  55. self.log_storage = LogStorage(self.conf.get("CACHEPATH"),
  56. "%s://%s/beaker/logs" % (self.conf.get('URL_SCHEME',
  57. 'http'), self.conf.get_url_domain()),
  58. self.hub)
  59. def close(self):
  60. if sys.version_info >= (2, 7):
  61. self.hub._hub('close')()
  62. def recipe_upload_file(self,
  63. recipe_id,
  64. path,
  65. name,
  66. size,
  67. md5sum,
  68. offset,
  69. data):
  70. """ Upload a file in chunks
  71. path: the relative path to upload to
  72. name: the name of the file
  73. size: size of the contents (bytes)
  74. md5: md5sum (hex digest) of contents
  75. data: base64 encoded file contents
  76. offset: the offset of the chunk
  77. Files can be uploaded in chunks, if so the md5 and the size
  78. describe the chunk rather than the whole file. The offset
  79. indicates where the chunk belongs
  80. """
  81. # Originally offset=-1 had special meaning, but that was unused
  82. logger.debug("recipe_upload_file recipe_id:%s name:%s offset:%s size:%s",
  83. recipe_id, name, offset, size)
  84. with self.log_storage.recipe(str(recipe_id), os.path.join(path, name)) as log_file:
  85. log_file.update_chunk(base64.decodestring(data), int(offset or 0))
  86. return True
  87. def task_result(self,
  88. task_id,
  89. result_type,
  90. result_path=None,
  91. result_score=None,
  92. result_summary=None):
  93. """ report a result to the scheduler """
  94. logger.debug("task_result %s", task_id)
  95. return self.hub.recipes.tasks.result(task_id,
  96. result_type,
  97. result_path,
  98. result_score,
  99. result_summary)
  100. def task_info(self,
  101. qtask_id):
  102. """ accepts qualified task_id J:213 RS:1234 R:312 T:1234 etc.. Returns dict with status """
  103. logger.debug("task_info %s", qtask_id)
  104. return self.hub.taskactions.task_info(qtask_id)
  105. def recipe_stop(self,
  106. recipe_id,
  107. stop_type,
  108. msg=None):
  109. """ tell the scheduler that we are stopping this recipe
  110. stop_type = ['abort', 'cancel']
  111. msg to record
  112. """
  113. logger.debug("recipe_stop %s", recipe_id)
  114. return self.hub.recipes.stop(recipe_id, stop_type, msg)
  115. def recipeset_stop(self,
  116. recipeset_id,
  117. stop_type,
  118. msg=None):
  119. """ tell the scheduler that we are stopping this recipeset
  120. stop_type = ['abort', 'cancel']
  121. msg to record
  122. """
  123. logger.debug("recipeset_stop %s", recipeset_id)
  124. return self.hub.recipesets.stop(recipeset_id, stop_type, msg)
  125. def job_stop(self,
  126. job_id,
  127. stop_type,
  128. msg=None):
  129. """ tell the scheduler that we are stopping this job
  130. stop_type = ['abort', 'cancel']
  131. msg to record
  132. """
  133. logger.debug("job_stop %s", job_id)
  134. return self.hub.jobs.stop(job_id, stop_type, msg)
  135. def get_my_recipe(self, request):
  136. """
  137. Accepts a dict with key 'recipe_id'. Returns an XML document for the
  138. recipe with that id.
  139. """
  140. if 'recipe_id' in request:
  141. logger.debug("get_recipe recipe_id:%s", request['recipe_id'])
  142. return self.hub.recipes.to_xml(request['recipe_id'])
  143. def get_peer_roles(self, task_id):
  144. logger.debug('get_peer_roles %s', task_id)
  145. return self.hub.recipes.tasks.peer_roles(task_id)
  146. def extend_watchdog(self, task_id, kill_time):
  147. """ tell the scheduler to extend the watchdog by kill_time seconds
  148. """
  149. logger.debug("extend_watchdog %s %s", task_id, kill_time)
  150. return self.hub.recipes.tasks.extend(task_id, kill_time)
  151. def task_to_dict(self, task_name):
  152. """ returns metadata about task_name from the TaskLibrary
  153. """
  154. return self.hub.tasks.to_dict(task_name)
  155. def get_console_log(self, recipe_id, length=None):
  156. """
  157. Get console log from the OpenStack instance
  158. """
  159. return self.hub.recipes.console_output(recipe_id, length)
  160. class ConsoleLogHelper(object):
  161. """
  162. Helper class to watch console log outputs and upload them to Scheduler
  163. """
  164. blocksize = 65536
  165. def __init__(self, watchdog, proxy, panic, logfile_name=None):
  166. self.watchdog = watchdog
  167. self.proxy = proxy
  168. self.logfile_name = logfile_name if logfile_name is not None else "console.log"
  169. self.strip_ansi = re.compile("(\033\[[0-9;\?]*[ABCDHfsnuJKmhr])")
  170. ascii_control_chars = map(chr, range(0, 32) + [127])
  171. keep_chars = '\t\n'
  172. strip_control_chars = [c for c in ascii_control_chars if c not in keep_chars]
  173. self.strip_cntrl = re.compile('[%s]' % re.escape(''.join(strip_control_chars)))
  174. self.panic_detector = PanicDetector(panic)
  175. self.install_failure_detector = InstallFailureDetector()
  176. self.where = 0
  177. self.incomplete_line = ''
  178. def process_log(self, block):
  179. # Sanitize control characters
  180. # We can't just strip the ansi codes, that would change the size
  181. # of the file, so whatever we end up stripping needs to be replaced
  182. # with spaces and a terminating \n.
  183. if self.strip_ansi:
  184. block = self.strip_ansi.sub(replace_with_blanks, block)
  185. if self.strip_cntrl:
  186. block = self.strip_cntrl.sub(' ', block)
  187. # Check for panics
  188. # Only feed the panic detector complete lines. If we have read a part
  189. # of a line, store it in self.incomplete_line and it will be prepended
  190. # to the subsequent block.
  191. lines = (self.incomplete_line + block).split('\n')
  192. self.incomplete_line = lines.pop()
  193. # Guard against a pathological case of the console filling up with
  194. # bytes but no newlines. Avoid buffering them into memory forever.
  195. if len(self.incomplete_line) > self.blocksize * 2:
  196. lines.append(self.incomplete_line)
  197. self.incomplete_line = ''
  198. if self.panic_detector:
  199. for line in lines:
  200. panic_found = self.panic_detector.feed(line)
  201. if panic_found:
  202. self.proxy.report_panic(self.watchdog, panic_found)
  203. failure_found = self.install_failure_detector.feed(line)
  204. if failure_found:
  205. self.proxy.report_install_failure(self.watchdog, failure_found)
  206. # Store block
  207. try:
  208. log_file = self.proxy.log_storage.recipe(
  209. str(self.watchdog['recipe_id']),
  210. self.logfile_name, create=(self.where == 0))
  211. with log_file:
  212. log_file.update_chunk(block, self.where)
  213. except (OSError, IOError), e:
  214. if e.errno == errno.ENOENT:
  215. pass # someone has removed our log, discard the update
  216. else:
  217. raise
  218. class ConsoleWatchLogFiles(object):
  219. """ Monitor a directory for log files and upload them """
  220. def __init__(self, logdir, system_name, watchdog, proxy, panic):
  221. self.logdir = os.path.abspath(logdir)
  222. self.system_name = system_name
  223. self.watchdog = watchdog
  224. self.proxy = proxy
  225. self.panic = panic
  226. self.logfiles = {}
  227. for filename, logfile_name in utils.get_console_files(
  228. console_logs_directory=self.logdir, system_name=self.system_name):
  229. logger.info('Watching console log file %s for recipe %s',
  230. filename, self.watchdog['recipe_id'])
  231. self.logfiles[filename] = ConsoleWatchFile(
  232. log=filename, watchdog=self.watchdog, proxy=self.proxy,
  233. panic=self.panic, logfile_name=logfile_name)
  234. def update(self):
  235. # Check for any new log files
  236. for filename, logfile_name in utils.get_console_files(
  237. console_logs_directory=self.logdir, system_name=self.system_name):
  238. if filename not in self.logfiles:
  239. logger.info('Watching console log file %s for recipe %s',
  240. filename, self.watchdog['recipe_id'])
  241. self.logfiles[filename] = ConsoleWatchFile(
  242. log=filename, watchdog=self.watchdog, proxy=self.proxy,
  243. panic=self.panic, logfile_name=logfile_name)
  244. # Update all of our log files. If any had updated data return True
  245. updated = False
  246. for console_log in self.logfiles.values():
  247. updated |= console_log.update()
  248. return updated
  249. class ConsoleWatchFile(ConsoleLogHelper):
  250. def __init__(self, log, watchdog, proxy, panic, logfile_name=None):
  251. self.log = log
  252. super(ConsoleWatchFile, self).__init__(
  253. watchdog, proxy, panic, logfile_name=logfile_name)
  254. def update(self):
  255. """
  256. If the log exists and the file has grown then upload the new piece
  257. """
  258. try:
  259. file = open(self.log, "r")
  260. except (OSError, IOError), e:
  261. if e.errno == errno.ENOENT:
  262. return False # doesn't exist
  263. else:
  264. raise
  265. try:
  266. file.seek(self.where)
  267. block = file.read(self.blocksize)
  268. now = file.tell()
  269. finally:
  270. file.close()
  271. if not block:
  272. return False # nothing new has been read
  273. self.process_log(block)
  274. self.where = now
  275. return True
  276. def truncate(self):
  277. try:
  278. f = open(self.log, 'r+')
  279. except IOError, e:
  280. if e.errno != errno.ENOENT:
  281. raise
  282. else:
  283. f.truncate()
  284. self.where = 0
  285. class ConsoleWatchVirt(ConsoleLogHelper):
  286. """
  287. Watch console logs from virtual machines
  288. """
  289. def update(self):
  290. output = self.proxy.get_console_log(self.watchdog['recipe_id'])
  291. # OpenStack returns the console output as unicode, although it just
  292. # replaces all non-ASCII bytes with U+FFFD REPLACEMENT CHARACTER.
  293. # But Beaker normally deals in raw bytes for the consoles.
  294. # We can't get back the original bytes that OpenStack discarded so
  295. # let's just convert to UTF-8 so that the U+FFFD characters are written
  296. # properly at least.
  297. output = output.encode('utf8')
  298. if len(output) >= 102400:
  299. # If the console log is more than 100KB OpenStack only returns the *last* 100KB.
  300. # https://bugs.launchpad.net/nova/+bug/1081436
  301. # So we have to treat this chunk as if it were the entire file contents,
  302. # since we don't know the actual byte position anymore.
  303. block = output
  304. now = len(block)
  305. self.where = 0
  306. else:
  307. block = output[self.where:]
  308. now = self.where + len(block)
  309. if not block:
  310. return False
  311. self.process_log(block)
  312. self.where = now
  313. return True
  314. class PanicDetector(object):
  315. def __init__(self, pattern):
  316. self.pattern = re.compile(pattern)
  317. self.fired = False
  318. def feed(self, line):
  319. if self.fired:
  320. return
  321. # Search the line for panics
  322. # The regex is stored in /etc/beaker/proxy.conf
  323. match = self.pattern.search(line)
  324. if match:
  325. self.fired = True
  326. return match.group()
  327. class InstallFailureDetector(object):
  328. def __init__(self):
  329. self.patterns = []
  330. for raw_pattern in self._load_patterns():
  331. pattern = re.compile(raw_pattern)
  332. # If the pattern is empty, it is either a mistake or the admin is
  333. # trying to override a package pattern to disable it. Either way,
  334. # exclude it from the list.
  335. if pattern.search(''):
  336. continue
  337. self.patterns.append(pattern)
  338. self.fired = False
  339. def _load_patterns(self):
  340. site_dir = '/etc/beaker/install-failure-patterns'
  341. try:
  342. site_patterns = os.listdir(site_dir)
  343. except OSError, e:
  344. if e.errno == errno.ENOENT:
  345. site_patterns = []
  346. else:
  347. raise
  348. package_patterns = pkg_resources.resource_listdir('bkr.labcontroller',
  349. 'install-failure-patterns')
  350. # site patterns override package patterns of the same name
  351. for p in site_patterns:
  352. if p in package_patterns:
  353. package_patterns.remove(p)
  354. patterns = []
  355. for p in site_patterns:
  356. try:
  357. patterns.append(open(os.path.join(site_dir, p), 'r').read().strip())
  358. except OSError, e:
  359. if e.errno == errno.ENOENT:
  360. pass # readdir race
  361. else:
  362. raise
  363. for p in package_patterns:
  364. patterns.append(pkg_resources.resource_string('bkr.labcontroller',
  365. 'install-failure-patterns/' + p))
  366. return patterns
  367. def feed(self, line):
  368. if self.fired:
  369. return
  370. for pattern in self.patterns:
  371. match = pattern.search(line)
  372. if match:
  373. self.fired = True
  374. return match.group()
  375. class LogArchiver(ProxyHelper):
  376. def transfer_logs(self):
  377. transfered = False
  378. server = self.conf.get_url_domain()
  379. logger.debug('Polling for recipes to be transferred')
  380. try:
  381. recipe_ids = self.hub.recipes.by_log_server(server)
  382. except xmlrpclib.Fault as fault:
  383. if 'Anonymous access denied' in fault.faultString:
  384. logger.debug('Session expired, re-authenticating')
  385. self.hub._login()
  386. recipe_ids = self.hub.recipes.by_log_server(server)
  387. else:
  388. raise
  389. for recipe_id in recipe_ids:
  390. transfered = True
  391. self.transfer_recipe_logs(recipe_id)
  392. return transfered
  393. def transfer_recipe_logs(self, recipe_id):
  394. """ If Cache is turned on then move the recipes logs to their final place
  395. """
  396. tmpdir = tempfile.mkdtemp(dir=self.conf.get("CACHEPATH"))
  397. try:
  398. # Move logs to tmp directory layout
  399. logger.debug('Fetching files list for recipe %s', recipe_id)
  400. mylogs = self.hub.recipes.files(recipe_id)
  401. trlogs = []
  402. logger.debug('Building temporary log tree for transfer under %s', tmpdir)
  403. for mylog in mylogs:
  404. mysrc = '%s/%s/%s' % (mylog['basepath'], mylog['path'], mylog['filename'])
  405. mydst = '%s/%s/%s/%s' % (tmpdir, mylog['filepath'],
  406. mylog['path'], mylog['filename'])
  407. if os.path.exists(mysrc):
  408. if not os.path.exists(os.path.dirname(mydst)):
  409. os.makedirs(os.path.dirname(mydst))
  410. try:
  411. os.link(mysrc,mydst)
  412. trlogs.append(mylog)
  413. except OSError, e:
  414. logger.exception('Error hard-linking %s to %s', mysrc, mydst)
  415. return
  416. else:
  417. logger.warn('Recipe %s file %s missing on disk, ignoring',
  418. recipe_id, mysrc)
  419. # rsync the logs to their new home
  420. rsync_succeeded = self.rsync('%s/' % tmpdir, '%s' % self.conf.get("ARCHIVE_RSYNC"))
  421. if not rsync_succeeded:
  422. return
  423. # if the logs have been transferred then tell the server the new location
  424. logger.debug('Updating recipe %s file locations on the server', recipe_id)
  425. self.hub.recipes.change_files(recipe_id, self.conf.get("ARCHIVE_SERVER"),
  426. self.conf.get("ARCHIVE_BASEPATH"))
  427. for mylog in trlogs:
  428. mysrc = '%s/%s/%s' % (mylog['basepath'], mylog['path'], mylog['filename'])
  429. self.rm(mysrc)
  430. try:
  431. self.removedirs('%s/%s' % (mylog['basepath'], mylog['path']))
  432. except OSError:
  433. # It's ok if it fails, dir may not be empty yet
  434. pass
  435. finally:
  436. # get rid of our tmpdir.
  437. shutil.rmtree(tmpdir)
  438. def rm(self, src):
  439. """ remove src
  440. """
  441. if os.path.exists(src):
  442. return os.unlink(src)
  443. return True
  444. def removedirs(self, path):
  445. """ remove empty dirs
  446. """
  447. if os.path.exists(path):
  448. return os.removedirs(path)
  449. return True
  450. def rsync(self, src, dst):
  451. """ Run system rsync command to move files
  452. """
  453. args = ['rsync'] + shlex.split(self.conf.get('RSYNC_FLAGS', '')) + [src, dst]
  454. logger.debug('Invoking rsync as %r', args)
  455. p = subprocess.Popen(args, stderr=subprocess.PIPE)
  456. out, err = p.communicate()
  457. if p.returncode != 0:
  458. logger.error('Failed to rsync recipe logs from %s to %s\nExit status: %s\n%s',
  459. src, dst, p.returncode, err)
  460. return False
  461. return True
  462. def sleep(self):
  463. # Sleep between polling
  464. time.sleep(self.conf.get("SLEEP_TIME", 20))
  465. class Monitor(ProxyHelper):
  466. """ Upload console log if present to Scheduler
  467. and look for panic/bug/etc..
  468. """
  469. def __init__(self, watchdog, obj, *args, **kwargs):
  470. """ Monitor system
  471. """
  472. self.watchdog = watchdog
  473. self.conf = obj.conf
  474. self.hub = obj.hub
  475. self.log_storage = obj.log_storage
  476. if(self.watchdog['is_virt_recipe']):
  477. logger.info('Watching OpenStack console for recipe %s', self.watchdog['recipe_id'])
  478. self.console_watch = ConsoleWatchVirt(
  479. self.watchdog, self, self.conf["PANIC_REGEX"])
  480. else:
  481. self.console_watch = ConsoleWatchLogFiles(
  482. logdir=self.conf['CONSOLE_LOGS'],
  483. system_name=self.watchdog['system'], watchdog=self.watchdog,
  484. proxy=self, panic=self.conf["PANIC_REGEX"])
  485. def run(self):
  486. """ check the logs for new data to upload/or cp
  487. """
  488. return self.console_watch.update()
  489. def report_panic(self, watchdog, panic_message):
  490. logger.info('Panic detected for recipe %s on system %s: '
  491. 'console log contains string %r', watchdog['recipe_id'],
  492. watchdog['system'], panic_message)
  493. job = lxml.etree.fromstring(self.get_my_recipe(
  494. dict(recipe_id=watchdog['recipe_id'])))
  495. recipe = job.find('recipeSet/guestrecipe')
  496. if recipe is None:
  497. recipe = job.find('recipeSet/recipe')
  498. if recipe.find('watchdog').get('panic') == 'ignore':
  499. # Don't Report the panic
  500. logger.info('Not reporting panic due to panic=ignore')
  501. elif recipe.get('status') == 'Reserved':
  502. logger.info('Not reporting panic as recipe is reserved')
  503. else:
  504. # Report the panic
  505. # Look for active task, worst case it records it on the last task
  506. for task in recipe.iterfind('task'):
  507. if task.get('status') == 'Running':
  508. break
  509. self.task_result(task.get('id'), 'panic', '/', 0, panic_message)
  510. # set the watchdog timeout to 10 minutes, gives some time for all data to
  511. # print out on the serial console
  512. # this may abort the recipe depending on what the recipeSets
  513. # watchdog behaviour is set to.
  514. self.extend_watchdog(task.get('id'), 60 * 10)
  515. def report_install_failure(self, watchdog, failure_message):
  516. logger.info('Install failure detected for recipe %s on system %s: '
  517. 'console log contains string %r', watchdog['recipe_id'],
  518. watchdog['system'], failure_message)
  519. job = lxml.etree.fromstring(self.get_my_recipe(
  520. dict(recipe_id=watchdog['recipe_id'])))
  521. recipe = job.find('recipeSet/guestrecipe')
  522. if recipe is None:
  523. recipe = job.find('recipeSet/recipe')
  524. # For now we are re-using the same panic="" attribute which is used to
  525. # control panic detection, bug 1055320 is an RFE to change this
  526. if recipe.find('watchdog').get('panic') == 'ignore':
  527. logger.info('Not reporting install failure due to panic=ignore')
  528. elif recipe.find('installation') is not None and recipe.find('installation').get('install_finished'):
  529. logger.info('Not reporting install failure for finished installation')
  530. else:
  531. # Ideally we would record it against the Installation entity for
  532. # the recipe, but that's not a thing yet, so we just add a result
  533. # to the first task (which is typically /distribution/install)
  534. first_task = recipe.findall('task')[0]
  535. self.task_result(first_task.get('id'), 'fail', '/', 0, failure_message)
  536. self.recipe_stop(recipe.get('id'), 'abort', 'Installation failed')
  537. class Proxy(ProxyHelper):
  538. def task_upload_file(self,
  539. task_id,
  540. path,
  541. name,
  542. size,
  543. md5sum,
  544. offset,
  545. data):
  546. """ Upload a file in chunks
  547. path: the relative path to upload to
  548. name: the name of the file
  549. size: size of the contents (bytes)
  550. md5: md5sum (hex digest) of contents
  551. data: base64 encoded file contents
  552. offset: the offset of the chunk
  553. Files can be uploaded in chunks, if so the md5 and the size
  554. describe the chunk rather than the whole file. The offset
  555. indicates where the chunk belongs
  556. """
  557. # Originally offset=-1 had special meaning, but that was unused
  558. logger.debug("task_upload_file task_id:%s name:%s offset:%s size:%s",
  559. task_id, name, offset, size)
  560. with self.log_storage.task(str(task_id), os.path.join(path, name)) as log_file:
  561. log_file.update_chunk(base64.decodestring(data), int(offset or 0))
  562. return True
  563. def task_start(self,
  564. task_id,
  565. kill_time=None):
  566. """ tell the scheduler that we are starting a task
  567. default watchdog time can be overridden with kill_time seconds """
  568. logger.debug("task_start %s", task_id)
  569. return self.hub.recipes.tasks.start(task_id, kill_time)
  570. def install_start(self, recipe_id=None):
  571. """ Called from %pre of the test machine. We call
  572. the server's install_start()
  573. """
  574. _debug_id = "(unspecified recipe)" if recipe_id is None else recipe_id
  575. logger.debug("install_start for R:%s" % _debug_id)
  576. return self.hub.recipes.install_start(recipe_id)
  577. def clear_netboot(self, fqdn):
  578. ''' Called from %post section to remove netboot entry '''
  579. logger.debug('clear_netboot %s', fqdn)
  580. p = subprocess.Popen(["sudo", "/usr/bin/beaker-clear-netboot", fqdn],
  581. stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
  582. output, _ = p.communicate()
  583. if p.returncode:
  584. raise RuntimeError('sudo beaker-clear-netboot failed: %s' % output.strip())
  585. logger.debug('clear_netboot %s completed', fqdn)
  586. return self.hub.labcontrollers.add_completed_command(fqdn, "clear_netboot")
  587. def postreboot(self, recipe_id):
  588. # XXX would be nice if we could limit this so that systems could only
  589. # reboot themselves, instead of accepting any arbitrary recipe id
  590. logger.debug('postreboot %s', recipe_id)
  591. return self.hub.recipes.postreboot(recipe_id)
  592. def power(self, hostname, action):
  593. # XXX this should also be authenticated and
  594. # restricted to systems in the same recipeset as the caller
  595. logger.debug('power %s %s', hostname, action)
  596. return self.hub.systems.power(action, hostname, False,
  597. # force=True because we are not the system's user
  598. True)
  599. def install_done(self, recipe_id=None, fqdn=None):
  600. logger.debug("install_done recipe_id=%s fqdn=%s", recipe_id, fqdn)
  601. return self.hub.recipes.install_done(recipe_id, fqdn)
  602. def install_fail(self, recipe_id=None):
  603. _debug_id = "(unspecified recipe)" if recipe_id is None else recipe_id
  604. logger.debug("install_fail for R:%s", _debug_id)
  605. return self.hub.recipes.install_fail(recipe_id)
  606. def postinstall_done(self, recipe_id=None):
  607. logger.debug("postinstall_done recipe_id=%s", recipe_id)
  608. return self.hub.recipes.postinstall_done(recipe_id)
  609. def status_watchdog(self, task_id):
  610. """ Ask the scheduler how many seconds are left on a watchdog for this task
  611. """
  612. logger.debug("status_watchdog %s", task_id)
  613. return self.hub.recipes.tasks.watchdog(task_id)
  614. def task_stop(self,
  615. task_id,
  616. stop_type,
  617. msg=None):
  618. """ tell the scheduler that we are stoping a task
  619. stop_type = ['stop', 'abort', 'cancel']
  620. msg to record if issuing Abort or Cancel """
  621. logger.debug("task_stop %s", task_id)
  622. return self.hub.recipes.tasks.stop(task_id, stop_type, msg)
  623. def result_upload_file(self,
  624. result_id,
  625. path,
  626. name,
  627. size,
  628. md5sum,
  629. offset,
  630. data):
  631. """ Upload a file in chunks
  632. path: the relative path to upload to
  633. name: the name of the file
  634. size: size of the contents (bytes)
  635. md5: md5sum (hex digest) of contents
  636. data: base64 encoded file contents
  637. offset: the offset of the chunk
  638. Files can be uploaded in chunks, if so the md5 and the size
  639. describe the chunk rather than the whole file. The offset
  640. indicates where the chunk belongs
  641. """
  642. # Originally offset=-1 had special meaning, but that was unused
  643. logger.debug("result_upload_file result_id:%s name:%s offset:%s size:%s",
  644. result_id, name, offset, size)
  645. with self.log_storage.result(str(result_id), os.path.join(path, name)) as log_file:
  646. log_file.update_chunk(base64.decodestring(data), int(offset or 0))
  647. return True
  648. def push(self, fqdn, inventory):
  649. """ Push inventory data to Scheduler
  650. """
  651. return self.hub.push(fqdn, inventory)
  652. def legacypush(self, fqdn, inventory):
  653. """ Push legacy inventory data to Scheduler
  654. """
  655. return self.hub.legacypush(fqdn, inventory)
  656. def updateDistro(self, distro, arch):
  657. """ This proxy method allows the installed machine
  658. to report that the distro was successfully installed
  659. The Scheduler will add an INSTALLS tag to this
  660. distro/arch, and if all distro/arch combo's
  661. contain an INSTALLS tag then it will also add
  662. a STABLE tag signifying that it successfully installed
  663. on all applicable arches.
  664. """
  665. return self.hub.tags.updateDistro(distro, arch)
  666. def add_distro_tree(self, distro):
  667. """ This proxy method allows the lab controller to add new
  668. distros to the Scheduler/Inventory server.
  669. """
  670. return self.hub.labcontrollers.add_distro_tree(distro)
  671. def remove_distro_trees(self, distro_tree_ids):
  672. """ This proxy method allows the lab controller to remove
  673. distro_tree_ids from the Scheduler/Inventory server.
  674. """
  675. return self.hub.labcontrollers.remove_distro_trees(distro_tree_ids)
  676. def get_distro_trees(self, filter=None):
  677. """ This proxy method allows the lab controller to query
  678. for all distro_trees that are associated to it.
  679. """
  680. return self.hub.labcontrollers.get_distro_trees(filter)
  681. def get_installation_for_system(self, fqdn):
  682. """
  683. A system can call this to get the details of the distro tree which was
  684. most recently installed on it.
  685. """
  686. return self.hub.labcontrollers.get_installation_for_system(fqdn)
  687. class ProxyHTTP(object):
  688. def __init__(self, proxy):
  689. self.hub = proxy.hub
  690. self.log_storage = proxy.log_storage
  691. def get_recipe(self, req, recipe_id):
  692. if req.accept_mimetypes.provided and \
  693. 'application/xml' not in req.accept_mimetypes:
  694. raise NotAcceptable()
  695. return Response(self.hub.recipes.to_xml(recipe_id),
  696. content_type='application/xml')
  697. _result_types = { # maps from public API names to internal Beaker names
  698. 'pass': 'pass_',
  699. 'warn': 'warn',
  700. 'fail': 'fail',
  701. 'none': 'result_none',
  702. 'skip': 'skip',
  703. }
  704. def post_result(self, req, recipe_id, task_id):
  705. if 'result' not in req.form:
  706. raise BadRequest('Missing "result" parameter')
  707. result = req.form['result'].lower()
  708. if result not in self._result_types:
  709. raise BadRequest('Unknown result type %r' % req.form['result'])
  710. try:
  711. result_id = self.hub.recipes.tasks.result(task_id,
  712. self._result_types[result],
  713. req.form.get('path'), req.form.get('score'),
  714. req.form.get('message'))
  715. except xmlrpclib.Fault, fault:
  716. # XXX need to find a less fragile way to do this
  717. if 'Cannot record result for finished task' in fault.faultString:
  718. return Response(status=409, response=fault.faultString,
  719. content_type='text/plain')
  720. elif 'Too many results in recipe' in fault.faultString:
  721. return Response(status=403, response=fault.faultString,
  722. content_type='text/plain')
  723. else:
  724. raise
  725. return redirect('/recipes/%s/tasks/%s/results/%s' % (
  726. recipe_id, task_id, result_id), code=201)
  727. def post_recipe_status(self, req, recipe_id):
  728. if 'status' not in req.form:
  729. raise BadRequest('Missing "status" parameter')
  730. status = req.form['status'].lower()
  731. if status != 'aborted':
  732. raise BadRequest('Unknown status %r' % req.form['status'])
  733. self.hub.recipes.stop(recipe_id, 'abort',
  734. req.form.get('message'))
  735. return Response(status=204)
  736. def post_task_status(self, req, recipe_id, task_id):
  737. if 'status' not in req.form:
  738. raise BadRequest('Missing "status" parameter')
  739. self._update_status(task_id, req.form['status'], req.form.get('message'))
  740. return Response(status=204)
  741. def _update_status(self, task_id, status, message):
  742. status = status.lower()
  743. if status not in ['running', 'completed', 'aborted']:
  744. raise BadRequest('Unknown status %r' % status)
  745. try:
  746. if status == 'running':
  747. self.hub.recipes.tasks.start(task_id)
  748. elif status == 'completed':
  749. self.hub.recipes.tasks.stop(task_id, 'stop')
  750. elif status == 'aborted':
  751. self.hub.recipes.tasks.stop(task_id, 'abort', message)
  752. except xmlrpclib.Fault as fault:
  753. # XXX This has to be completely replaced with JSON response in next major release
  754. # We don't want to blindly return 500 because of opposite side
  755. # will try to retry request - which is almost in all situation wrong
  756. if ('Cannot restart finished task' in fault.faultString
  757. or 'Cannot change status for finished task' in fault.faultString):
  758. raise Conflict(fault.faultString)
  759. else:
  760. raise
  761. def patch_task(self, request, recipe_id, task_id):
  762. if request.json:
  763. data = dict(request.json)
  764. elif request.form:
  765. data = request.form.to_dict()
  766. else:
  767. raise UnsupportedMediaType
  768. if 'status' in data:
  769. status = data.pop('status')
  770. self._update_status(task_id, status, data.pop('message', None))
  771. # If the caller only wanted to update the status and nothing else,
  772. # we will avoid making a second XML-RPC call.
  773. updated = {'status': status}
  774. if data:
  775. updated = self.hub.recipes.tasks.update(task_id, data)
  776. return Response(status=200, response=json.dumps(updated),
  777. content_type='application/json')
  778. def get_watchdog(self, req, recipe_id):
  779. seconds = self.hub.recipes.watchdog(recipe_id)
  780. return Response(status=200,
  781. response=json.dumps({'seconds': seconds}),
  782. content_type='application/json')
  783. def post_watchdog(self, req, recipe_id):
  784. if 'seconds' not in req.form:
  785. raise BadRequest('Missing "seconds" parameter')
  786. try:
  787. seconds = int(req.form['seconds'])
  788. except ValueError:
  789. raise BadRequest('Invalid "seconds" parameter %r' % req.form['seconds'])
  790. self.hub.recipes.extend(recipe_id, seconds)
  791. return Response(status=204)
  792. # XXX should do streaming here, so that clients can send
  793. # big files without chunking
  794. def _put_log(self, log_file, req):
  795. if req.content_length is None:
  796. raise LengthRequired()
  797. content_range = parse_content_range_header(req.headers.get('Content-Range'))
  798. if content_range:
  799. # a few sanity checks
  800. if req.content_length != (content_range.stop - content_range.start):
  801. raise BadRequest('Content length does not match range length')
  802. if content_range.length and content_range.length < content_range.stop:
  803. raise BadRequest('Total length is smaller than range end')
  804. try:
  805. with log_file:
  806. if content_range:
  807. if content_range.length: # length may be '*' meaning unspecified
  808. log_file.truncate(content_range.length)
  809. log_file.update_chunk(req.data, content_range.start)
  810. else:
  811. # no Content-Range, therefore the request is the whole file
  812. log_file.truncate(req.content_length)
  813. log_file.update_chunk(req.data, 0)
  814. # XXX need to find a less fragile way to do this
  815. except xmlrpclib.Fault, fault:
  816. if 'Cannot register file for finished ' in fault.faultString:
  817. return Response(status=409, response=fault.faultString,
  818. content_type='text/plain')
  819. elif 'Too many ' in fault.faultString:
  820. return Response(status=403, response=fault.faultString,
  821. content_type='text/plain')
  822. else:
  823. raise
  824. return Response(status=204)
  825. def _get_log(self, log_file, req):
  826. try:
  827. f = log_file.open_ro()
  828. except IOError, e:
  829. if e.errno == errno.ENOENT:
  830. raise NotFound()
  831. else:
  832. raise
  833. return Response(status=200, response=wrap_file(req.environ, f),
  834. content_type='text/plain', direct_passthrough=True)
  835. def do_recipe_log(self, req, recipe_id, path):
  836. log_file = self.log_storage.recipe(recipe_id, path)
  837. if req.method == 'GET':
  838. return self._get_log(log_file, req)
  839. elif req.method == 'PUT':
  840. return self._put_log(log_file, req)
  841. def do_task_log(self, req, recipe_id, task_id, path):
  842. log_file = self.log_storage.task(task_id, path)
  843. if req.method == 'GET':
  844. return self._get_log(log_file, req)
  845. elif req.method == 'PUT':
  846. return self._put_log(log_file, req)
  847. def do_result_log(self, req, recipe_id, task_id, result_id, path):
  848. log_file = self.log_storage.result(result_id, path)
  849. if req.method == 'GET':
  850. return self._get_log(log_file, req)
  851. elif req.method == 'PUT':
  852. return self._put_log(log_file, req)
  853. # XXX use real templates here, make the Atom feed valid
  854. def _html_log_index(self, logs):
  855. hrefs = [os.path.join((log['path'] or '').lstrip('/'), log['filename'])
  856. for log in logs]
  857. lis = ['<li><a href=%s>%s</a></li>' % (xml_quoteattr(href), xml_escape(href))
  858. for href in hrefs]
  859. html = '<!DOCTYPE html><html><body><ul>%s</ul></body></html>' % ''.join(lis)
  860. return Response(status=200, content_type='text/html', response=html)
  861. def _atom_log_index(self, logs):
  862. hrefs = [os.path.join((log['path'] or '').lstrip('/'), log['filename'])
  863. for log in logs]
  864. entries = ['<entry><link rel="alternate" href=%s /><title type="text">%s</title></entry>'
  865. % (xml_quoteattr(href), xml_escape(href)) for href in hrefs]
  866. atom = '<feed xmlns="http://www.w3.org/2005/Atom">%s</feed>' % ''.join(entries)
  867. return Response(status=200, content_type='application/atom+xml', response=atom)
  868. def _log_index(self, req, logs):
  869. if not req.accept_mimetypes.provided:
  870. response_type = 'text/html'
  871. else:
  872. response_type = req.accept_mimetypes.best_match(['text/html', 'application/atom+xml'])
  873. if not response_type:
  874. raise NotAcceptable()
  875. if response_type == 'text/html':
  876. return self._html_log_index(logs)
  877. elif response_type == 'application/atom+xml':
  878. return self._atom_log_index(logs)
  879. def list_recipe_logs(self, req, recipe_id):
  880. try:
  881. logs = self.hub.taskactions.files('R:%s' % recipe_id)
  882. except xmlrpclib.Fault, fault:
  883. # XXX need to find a less fragile way to do this
  884. if 'is not a valid Recipe id' in fault.faultString:
  885. raise NotFound()
  886. else:
  887. raise
  888. # The server includes all sub-elements' logs, filter them out
  889. logs = [log for log in logs if log['tid'].startswith('R:')]
  890. return self._log_index(req, logs)
  891. def list_task_logs(self, req, recipe_id, task_id):
  892. try:
  893. logs = self.hub.taskactions.files('T:%s' % task_id)
  894. except xmlrpclib.Fault, fault:
  895. # XXX need to find a less fragile way to do this
  896. if 'is not a valid RecipeTask id' in fault.faultString:
  897. raise NotFound()
  898. else:
  899. raise
  900. # The server includes all sub-elements' logs, filter them out
  901. logs = [log for log in logs if log['tid'].startswith('T:')]
  902. return self._log_index(req, logs)
  903. def list_result_logs(self, req, recipe_id, task_id, result_id):
  904. try:
  905. logs = self.hub.taskactions.files('TR:%s' % result_id)
  906. except xmlrpclib.Fault, fault:
  907. # XXX need to find a less fragile way to do this
  908. if 'is not a valid RecipeTaskResult id' in fault.faultString:
  909. raise NotFound()
  910. else:
  911. raise
  912. return self._log_index(req, logs)
  913. def put_power(self, req, fqdn):
  914. """
  915. Controls power for the system with the given fully-qualified domain
  916. name.
  917. :param req: request
  918. :param fqdn: fully-qualified domain name of the system to be power controlled
  919. """
  920. if req.json:
  921. payload = dict(req.json)
  922. elif req.form:
  923. payload = req.form.to_dict()
  924. else:
  925. raise UnsupportedMediaType
  926. if 'action' not in payload:
  927. raise BadRequest('Missing "action" parameter')
  928. action = payload['action']
  929. if action not in ['on', 'off', 'reboot']:
  930. raise BadRequest('Unknown action {}'.format(action))
  931. self.hub.systems.power(action, fqdn, False, True)
  932. return Response(status=204)
  933. def healthz(self, req):
  934. """
  935. Health check
  936. :param req: request
  937. """
  938. # HEAD is identical to GET except that it MUST NOT return a body in the response
  939. response = "We are healthy!" if req.method == 'GET' else None
  940. return Response(status=200, response=response)