drones.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. # -*- coding: utf-8 -*-
  2. # Licensed under the Apache License, Version 2.0 (the "License");
  3. # you may not use this file except in compliance with the License.
  4. # You may obtain a copy of the License at
  5. #
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. #
  8. # Unless required by applicable law or agreed to in writing, software
  9. # distributed under the License is distributed on an "AS IS" BASIS,
  10. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
  11. # implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. import os
  15. import stat
  16. import uuid
  17. import time
  18. import logging
  19. import tarfile
  20. import tempfile
  21. from .. import utils
  22. class SshTarballTransferMixin(object):
  23. """
  24. Transfers resources and recipes by packing them to tar.gz and
  25. copying it via ssh.
  26. """
  27. def _transfer(self, pack_path, pack_dest, res_dir):
  28. node = self.node
  29. args = locals()
  30. # copy and extract tarball
  31. script = utils.ScriptRunner()
  32. script.append("scp %(pack_path)s root@%(node)s:%(pack_dest)s"
  33. % args)
  34. script.append("ssh -o StrictHostKeyChecking=no "
  35. "-o UserKnownHostsFile=/dev/null root@%(node)s "
  36. "tar -C %(res_dir)s -xpzf %(pack_dest)s" % args)
  37. try:
  38. script.execute()
  39. except ScriptRuntimeError as ex:
  40. # TO-DO: change to appropriate exception
  41. raise RuntimeError('Failed to copy resources to node %s. '
  42. 'Reason: %s' % (node, ex))
  43. def _pack_resources(self):
  44. randpart = uuid.uuid4().hex[:8]
  45. pack_path = os.path.join(self.local_tmpdir,
  46. 'res-%s.tar.gz' % randpart)
  47. pack = tarfile.open(pack_path, mode='w:gz')
  48. os.chmod(pack_path, stat.S_IRUSR | stat.S_IWUSR)
  49. for path, dest in self._resources:
  50. if not dest:
  51. dest = os.path.basename(path)
  52. pack.add(path,
  53. arcname=os.path.join(dest, os.path.basename(path)))
  54. pack.close()
  55. return pack_path
  56. def _copy_resources(self):
  57. pack_path = self._pack_resources()
  58. pack_dest = os.path.join(self.remote_tmpdir,
  59. os.path.basename(pack_path))
  60. self._transfer(pack_path, pack_dest, self.resource_dir)
  61. def _pack_recipes(self):
  62. randpart = uuid.uuid4().hex[:8]
  63. pack_path = os.path.join(self.local_tmpdir,
  64. 'rec-%s.tar.gz' % randpart)
  65. pack = tarfile.open(pack_path, mode='w:gz')
  66. os.chmod(pack_path, stat.S_IRUSR | stat.S_IWUSR)
  67. if self.recipe_dir.startswith(self.resource_dir):
  68. dest = self.recipe_dir[len(self.resource_dir):].lstrip('/')
  69. else:
  70. dest = ''
  71. for marker, recipes in self._recipes.iteritems():
  72. for path in recipes:
  73. _dest = os.path.join(dest, os.path.basename(path))
  74. pack.add(path, arcname=_dest)
  75. pack.close()
  76. return pack_path
  77. def _copy_recipes(self):
  78. pack_path = self._pack_recipes()
  79. pack_dest = os.path.join(self.remote_tmpdir,
  80. os.path.basename(pack_path))
  81. if self.recipe_dir.startswith(self.resource_dir):
  82. extr_dest = self.resource_dir
  83. else:
  84. extr_dest = self.recipe_dir
  85. self._transfer(pack_path, pack_dest, extr_dest)
  86. class DroneObserver(object):
  87. """
  88. Base class for listening messages from drones.
  89. """
  90. def applying(self, drone, recipe):
  91. """
  92. Drone is calling this method when it starts applying recipe.
  93. """
  94. # subclass must implement this method
  95. raise NotImplementedError()
  96. def checking(self, drone, recipe):
  97. """
  98. Drone is calling this method when it starts checking if recipe
  99. has been applied.
  100. """
  101. # subclass must implement this method
  102. raise NotImplementedError()
  103. def finished(self, drone, recipe):
  104. """
  105. Drone is calling this method when it's finished with recipe
  106. application.
  107. """
  108. # subclass must implement this method
  109. raise NotImplementedError()
  110. class Drone(object):
  111. """
  112. Base class used to apply installation recipes to nodes.
  113. """
  114. def __init__(self, node, resource_dir=None, recipe_dir=None,
  115. local_tmpdir=None, remote_tmpdir=None):
  116. self._recipes = utils.SortedDict()
  117. self._resources = []
  118. self._applied = set()
  119. self._running = set()
  120. self._observer = None
  121. # remote host IP or hostname
  122. self.node = node
  123. # working directories on remote host
  124. self.resource_dir = (resource_dir or
  125. '/tmp/drone%s' % uuid.uuid4().hex[:8])
  126. self.recipe_dir = (recipe_dir or
  127. os.path.join(self.resource_dir, 'recipes'))
  128. # temporary directories
  129. self.remote_tmpdir = (remote_tmpdir or
  130. '/tmp/drone%s' % uuid.uuid4().hex[:8])
  131. self.local_tmpdir = (local_tmpdir or
  132. tempfile.mkdtemp(prefix='drone'))
  133. def init_node(self):
  134. """
  135. Initializes node for manipulation.
  136. """
  137. created = []
  138. server = utils.ScriptRunner(self.node)
  139. for i in (self.resource_dir, self.recipe_dir,
  140. self.remote_tmpdir):
  141. server.append('mkdir -p %s' % os.path.dirname(i))
  142. server.append('mkdir --mode 0700 %s' % i)
  143. created.append('%s:%s' % (self.node, i))
  144. server.execute()
  145. # TO-DO: complete logger name when logging will be setup correctly
  146. logger = logging.getLogger()
  147. logger.debug('Created directories: %s' % ','.join(created))
  148. @property
  149. def recipes(self):
  150. for i in self._recipes.itervalues():
  151. for y in i:
  152. yield y
  153. @property
  154. def resources(self):
  155. for i in self._resources:
  156. yield i[0]
  157. def add_recipe(self, path, marker=None):
  158. """
  159. Registers recipe for application on node. Recipes will be
  160. applied in order they where added to drone. Multiple recipes can
  161. be applied in paralel if they have same marker.
  162. """
  163. marker = marker or uuid.uuid4().hex[:8]
  164. self._recipes.setdefault(marker, []).append(path)
  165. def add_resource(self, path, destination=None):
  166. """
  167. Registers resource. Destination will be relative from resource
  168. directory on node.
  169. """
  170. dest = destination or ''
  171. self._resources.append((path, dest))
  172. def _copy_resources(self):
  173. """
  174. Copies all local files registered in self._resources to their
  175. appropriate destination on self.node. If tmpdir is given this
  176. method can operate only in this directory.
  177. """
  178. # subclass must implement this method
  179. raise NotImplementedError()
  180. def _copy_recipes(self):
  181. """
  182. Copies all local files registered in self._recipes to their
  183. appropriate destination on self.node. If tmpdir is given this
  184. method can operate only in this directory.
  185. """
  186. # subclass must implement this method
  187. raise NotImplementedError()
  188. def prepare_node(self):
  189. """
  190. Copies all local resources and recipes to self.node.
  191. """
  192. # TO-DO: complete logger name when logging will be setup correctly
  193. logger = logging.getLogger()
  194. logger.debug('Copying drone resources to node %s: %s'
  195. % (self.node, self.resources))
  196. self._copy_resources()
  197. logger.debug('Copying drone recipes to node %s: %s'
  198. % (self.node, [i[0] for i in self.recipes]))
  199. self._copy_recipes()
  200. def _apply(self, recipe):
  201. """
  202. Starts application of single recipe given as path to the recipe
  203. file in self.node. This method should not wait until recipe is
  204. applied.
  205. """
  206. # subclass must implement this method
  207. raise NotImplementedError()
  208. def _finished(self, recipe):
  209. """
  210. Returns True if given recipe is applied, otherwise returns False
  211. """
  212. # subclass must implement this method
  213. raise NotImplementedError()
  214. def _wait(self):
  215. """
  216. Waits until all started applications of recipes will be finished
  217. """
  218. while self._running:
  219. _run = list(self._running)
  220. for recipe in _run:
  221. if self._observer:
  222. self._observer.checking(self, recipe)
  223. if self._finished(recipe):
  224. self._applied.add(recipe)
  225. self._running.remove(recipe)
  226. if self._observer:
  227. self._observer.finished(self, recipe)
  228. else:
  229. time.sleep(3)
  230. continue
  231. def set_observer(self, observer):
  232. """
  233. Registers an observer. Given object should be subclass of class
  234. DroneObserver.
  235. """
  236. for attr in ('applying', 'checking', 'finished'):
  237. if not hasattr(observer, attr):
  238. raise ValueError('Observer object should be a subclass '
  239. 'of class DroneObserver.')
  240. self._observer = observer
  241. def apply(self, marker=None, name=None, skip=None):
  242. """
  243. Applies recipes on node. If marker is specified, only recipes
  244. with given marker are applied. If name is specified only recipe
  245. with given name is applied. Skips recipes with names given
  246. in list parameter skip.
  247. """
  248. # TO-DO: complete logger name when logging will be setup correctly
  249. logger = logging.getLogger()
  250. skip = skip or []
  251. lastmarker = None
  252. for mark, recipelist in self._recipes.iteritems():
  253. if marker and marker != mark:
  254. logger.debug('Skipping marker %s for node %s.' %
  255. (mark, self.node))
  256. continue
  257. for recipe in recipelist:
  258. base = os.path.basename(recipe)
  259. if (name and name != base) or base in skip:
  260. logger.debug('Skipping recipe %s for node %s.' %
  261. (recipe, self.node))
  262. continue
  263. # if the marker has changed then we don't want to
  264. # proceed until all of the previous puppet runs have
  265. # finished
  266. if lastmarker and lastmarker != mark:
  267. self._wait()
  268. lastmarker = mark
  269. logger.debug('Applying recipe %s to node %s.' %
  270. (base, self.node))
  271. rpath = os.path.join(self.recipe_dir, base)
  272. if self._observer:
  273. self._observer.applying(self, recipe)
  274. self._running.add(rpath)
  275. self._apply(rpath)
  276. self._wait()
  277. def cleanup(self, resource_dir=True, recipe_dir=True):
  278. """
  279. Removes all directories created by this drone.
  280. """
  281. shutil.rmtree(self.local_tmpdir, ignore_errors=True)
  282. server = utils.ScriptRunner(self.node)
  283. server.append('rm -fr %s' % self.remote_tmpdir)
  284. if recipe_dir:
  285. server.append('rm -fr %s' % self.recipe_dir)
  286. if resource_dir:
  287. server.append('rm -fr %s' % self.resource_dir)
  288. server.execute()
  289. class PackstackDrone(SshTarballTransferMixin, Drone):
  290. """
  291. This drone uses Puppet and it's manifests to manipulate node.
  292. """
  293. # XXX: Since this implementation is Packstack specific (_apply
  294. # method), it should be moved out of installer when
  295. # Controller and plugin system will be refactored and installer
  296. # will support projects.
  297. def __init__(self, *args, **kwargs):
  298. kwargs['resource_dir'] = ('/var/tmp/packstack/drone%s'
  299. % uuid.uuid4().hex[:8])
  300. kwargs['recipe_dir'] = '%s/manifests' % kwargs['resource_dir']
  301. kwargs['remote_tmpdir'] = '%s/temp' % kwargs['resource_dir']
  302. super(PackstackDrone, self).__init__(*args, **kwargs)
  303. self.module_dir = os.path.join(self.resource_dir, 'modules')
  304. self.fact_dir = os.path.join(self.resource_dir, 'facts')
  305. def init_node(self):
  306. """
  307. Initializes node for manipulation.
  308. """
  309. super(PackstackDrone, self).init_node()
  310. server = utils.ScriptRunner(self.node)
  311. for pkg in ("puppet", "openssh-clients", "tar"):
  312. server.append("rpm -q --whatprovides %(pkg)s || "
  313. "yum install -y %(pkg)s" % locals())
  314. server.execute()
  315. def add_resource(self, path, resource_type=None):
  316. """
  317. Resource type should be module, fact or resource.
  318. """
  319. resource_type = resource_type or 'resource'
  320. dest = '%ss' % resource_type
  321. super(PackstackDrone, self).add_resource(path, destination=dest)
  322. def _finished(self, recipe):
  323. recipe_base = os.path.basename(recipe)
  324. log = os.path.join(self.recipe_dir,
  325. recipe_base.replace(".finished", ".log"))
  326. local = utils.ScriptRunner()
  327. local.append('scp -o StrictHostKeyChecking=no '
  328. '-o UserKnownHostsFile=/dev/null '
  329. 'root@%s:%s %s' % (self.node, recipe, log))
  330. try:
  331. # once a remote puppet run has finished, we retrieve
  332. # the log file and check it for errors
  333. local.execute(log=False)
  334. # if we got to this point the puppet apply has finished
  335. return True
  336. except utils.ScriptRuntimeError as e:
  337. # the test raises an exception if the file doesn't exist yet
  338. return False
  339. def _apply(self, recipe):
  340. running = "%s.running" % recipe
  341. finished = "%s.finished" % recipe
  342. server = utils.ScriptRunner(self.node)
  343. server.append("touch %s" % running)
  344. server.append("chmod 600 %s" % running)
  345. # XXX: This is terrible hack, but unfortunatelly the apache
  346. # puppet module doesn't work if we set FACTERLIB
  347. # https://github.com/puppetlabs/puppetlabs-apache/pull/138
  348. for bad_word in ('horizon', 'nagios', 'apache'):
  349. if bad_word in recipe:
  350. break
  351. else:
  352. server.append("export FACTERLIB=$FACTERLIB:%s" %
  353. self.fact_dir)
  354. server.append("export PACKSTACK_VAR_DIR=%s" % self.resource_dir)
  355. # TO-DO: complete logger name when logging will be setup correctly
  356. logger = logging.getLogger()
  357. loglevel = logger.level <= logging.DEBUG and '--debug' or ''
  358. rdir = self.resource_dir
  359. mdir = self._module_dir
  360. server.append(
  361. "( flock %(rdir)s/ps.lock "
  362. "puppet apply %(loglevel)s --modulepath %(mdir)s "
  363. "%(recipe)s > %(running)s 2>&1 < /dev/null; "
  364. "mv %(running)s %(finished)s ) "
  365. "> /dev/null 2>&1 < /dev/null &" % locals())
  366. server.execute()