410 lines
15 KiB
Python
410 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
# implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import os
|
|
import stat
|
|
import uuid
|
|
import time
|
|
import logging
|
|
import tarfile
|
|
import tempfile
|
|
|
|
from .. import utils
|
|
|
|
|
|
class SshTarballTransferMixin(object):
|
|
"""
|
|
Transfers resources and recipes by packing them to tar.gz and
|
|
copying it via ssh.
|
|
"""
|
|
def _transfer(self, pack_path, pack_dest, res_dir):
|
|
node = self.node
|
|
args = locals()
|
|
# copy and extract tarball
|
|
script = utils.ScriptRunner()
|
|
script.append("scp %(pack_path)s root@%(node)s:%(pack_dest)s"
|
|
% args)
|
|
script.append("ssh -o StrictHostKeyChecking=no "
|
|
"-o UserKnownHostsFile=/dev/null root@%(node)s "
|
|
"tar -C %(res_dir)s -xpzf %(pack_dest)s" % args)
|
|
try:
|
|
script.execute()
|
|
except ScriptRuntimeError as ex:
|
|
# TO-DO: change to appropriate exception
|
|
raise RuntimeError('Failed to copy resources to node %s. '
|
|
'Reason: %s' % (node, ex))
|
|
|
|
def _pack_resources(self):
|
|
randpart = uuid.uuid4().hex[:8]
|
|
pack_path = os.path.join(self.local_tmpdir,
|
|
'res-%s.tar.gz' % randpart)
|
|
pack = tarfile.open(pack_path, mode='w:gz')
|
|
os.chmod(pack_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
for path, dest in self._resources:
|
|
if not dest:
|
|
dest = os.path.basename(path)
|
|
pack.add(path,
|
|
arcname=os.path.join(dest, os.path.basename(path)))
|
|
pack.close()
|
|
return pack_path
|
|
|
|
def _copy_resources(self):
|
|
pack_path = self._pack_resources()
|
|
pack_dest = os.path.join(self.remote_tmpdir,
|
|
os.path.basename(pack_path))
|
|
self._transfer(pack_path, pack_dest, self.resource_dir)
|
|
|
|
def _pack_recipes(self):
|
|
randpart = uuid.uuid4().hex[:8]
|
|
pack_path = os.path.join(self.local_tmpdir,
|
|
'rec-%s.tar.gz' % randpart)
|
|
pack = tarfile.open(pack_path, mode='w:gz')
|
|
os.chmod(pack_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
if self.recipe_dir.startswith(self.resource_dir):
|
|
dest = self.recipe_dir[len(self.resource_dir):].lstrip('/')
|
|
else:
|
|
dest = ''
|
|
for marker, recipes in self._recipes.items():
|
|
for path in recipes:
|
|
_dest = os.path.join(dest, os.path.basename(path))
|
|
pack.add(path, arcname=_dest)
|
|
pack.close()
|
|
return pack_path
|
|
|
|
def _copy_recipes(self):
|
|
pack_path = self._pack_recipes()
|
|
pack_dest = os.path.join(self.remote_tmpdir,
|
|
os.path.basename(pack_path))
|
|
if self.recipe_dir.startswith(self.resource_dir):
|
|
extr_dest = self.resource_dir
|
|
else:
|
|
extr_dest = self.recipe_dir
|
|
self._transfer(pack_path, pack_dest, extr_dest)
|
|
|
|
|
|
class DroneObserver(object):
|
|
"""
|
|
Base class for listening messages from drones.
|
|
"""
|
|
def applying(self, drone, recipe):
|
|
"""
|
|
Drone is calling this method when it starts applying recipe.
|
|
"""
|
|
# subclass must implement this method
|
|
raise NotImplementedError()
|
|
|
|
def checking(self, drone, recipe):
|
|
"""
|
|
Drone is calling this method when it starts checking if recipe
|
|
has been applied.
|
|
"""
|
|
# subclass must implement this method
|
|
raise NotImplementedError()
|
|
|
|
def finished(self, drone, recipe):
|
|
"""
|
|
Drone is calling this method when it's finished with recipe
|
|
application.
|
|
"""
|
|
# subclass must implement this method
|
|
raise NotImplementedError()
|
|
|
|
|
|
class Drone(object):
|
|
"""
|
|
Base class used to apply installation recipes to nodes.
|
|
"""
|
|
def __init__(self, node, resource_dir=None, recipe_dir=None,
|
|
local_tmpdir=None, remote_tmpdir=None):
|
|
self._recipes = utils.SortedDict()
|
|
self._resources = []
|
|
self._applied = set()
|
|
self._running = set()
|
|
self._observer = None
|
|
|
|
# remote host IP or hostname
|
|
self.node = node
|
|
# working directories on remote host
|
|
self.resource_dir = (resource_dir or
|
|
'/tmp/drone%s' % uuid.uuid4().hex[:8])
|
|
self.recipe_dir = (recipe_dir or
|
|
os.path.join(self.resource_dir, 'recipes'))
|
|
# temporary directories
|
|
self.remote_tmpdir = (remote_tmpdir or
|
|
'/tmp/drone%s' % uuid.uuid4().hex[:8])
|
|
self.local_tmpdir = (local_tmpdir or
|
|
tempfile.mkdtemp(prefix='drone'))
|
|
|
|
def init_node(self):
|
|
"""
|
|
Initializes node for manipulation.
|
|
"""
|
|
created = []
|
|
server = utils.ScriptRunner(self.node)
|
|
for i in (self.resource_dir, self.recipe_dir,
|
|
self.remote_tmpdir):
|
|
server.append('mkdir -p %s' % os.path.dirname(i))
|
|
server.append('mkdir --mode 0700 %s' % i)
|
|
created.append('%s:%s' % (self.node, i))
|
|
server.execute()
|
|
|
|
# TO-DO: complete logger name when logging will be setup correctly
|
|
logger = logging.getLogger()
|
|
logger.debug('Created directories: %s' % ','.join(created))
|
|
|
|
@property
|
|
def recipes(self):
|
|
for i in self._recipes.itervalues():
|
|
for y in i:
|
|
yield y
|
|
|
|
@property
|
|
def resources(self):
|
|
for i in self._resources:
|
|
yield i[0]
|
|
|
|
def add_recipe(self, path, marker=None):
|
|
"""
|
|
Registers recipe for application on node. Recipes will be
|
|
applied in order they where added to drone. Multiple recipes can
|
|
be applied in paralel if they have same marker.
|
|
"""
|
|
marker = marker or uuid.uuid4().hex[:8]
|
|
self._recipes.setdefault(marker, []).append(path)
|
|
|
|
def add_resource(self, path, destination=None):
|
|
"""
|
|
Registers resource. Destination will be relative from resource
|
|
directory on node.
|
|
"""
|
|
dest = destination or ''
|
|
self._resources.append((path, dest))
|
|
|
|
def _copy_resources(self):
|
|
"""
|
|
Copies all local files registered in self._resources to their
|
|
appropriate destination on self.node. If tmpdir is given this
|
|
method can operate only in this directory.
|
|
"""
|
|
# subclass must implement this method
|
|
raise NotImplementedError()
|
|
|
|
def _copy_recipes(self):
|
|
"""
|
|
Copies all local files registered in self._recipes to their
|
|
appropriate destination on self.node. If tmpdir is given this
|
|
method can operate only in this directory.
|
|
"""
|
|
# subclass must implement this method
|
|
raise NotImplementedError()
|
|
|
|
def prepare_node(self):
|
|
"""
|
|
Copies all local resources and recipes to self.node.
|
|
"""
|
|
# TO-DO: complete logger name when logging will be setup correctly
|
|
logger = logging.getLogger()
|
|
logger.debug('Copying drone resources to node %s: %s'
|
|
% (self.node, self.resources))
|
|
self._copy_resources()
|
|
logger.debug('Copying drone recipes to node %s: %s'
|
|
% (self.node, [i[0] for i in self.recipes]))
|
|
self._copy_recipes()
|
|
|
|
def _apply(self, recipe):
|
|
"""
|
|
Starts application of single recipe given as path to the recipe
|
|
file in self.node. This method should not wait until recipe is
|
|
applied.
|
|
"""
|
|
# subclass must implement this method
|
|
raise NotImplementedError()
|
|
|
|
def _finished(self, recipe):
|
|
"""
|
|
Returns True if given recipe is applied, otherwise returns False
|
|
"""
|
|
# subclass must implement this method
|
|
raise NotImplementedError()
|
|
|
|
def _wait(self):
|
|
"""
|
|
Waits until all started applications of recipes will be finished
|
|
"""
|
|
while self._running:
|
|
_run = list(self._running)
|
|
for recipe in _run:
|
|
if self._observer:
|
|
self._observer.checking(self, recipe)
|
|
if self._finished(recipe):
|
|
self._applied.add(recipe)
|
|
self._running.remove(recipe)
|
|
if self._observer:
|
|
self._observer.finished(self, recipe)
|
|
else:
|
|
time.sleep(3)
|
|
continue
|
|
|
|
def set_observer(self, observer):
|
|
"""
|
|
Registers an observer. Given object should be subclass of class
|
|
DroneObserver.
|
|
"""
|
|
for attr in ('applying', 'checking', 'finished'):
|
|
if not hasattr(observer, attr):
|
|
raise ValueError('Observer object should be a subclass '
|
|
'of class DroneObserver.')
|
|
self._observer = observer
|
|
|
|
def apply(self, marker=None, name=None, skip=None):
|
|
"""
|
|
Applies recipes on node. If marker is specified, only recipes
|
|
with given marker are applied. If name is specified only recipe
|
|
with given name is applied. Skips recipes with names given
|
|
in list parameter skip.
|
|
"""
|
|
# TO-DO: complete logger name when logging will be setup correctly
|
|
logger = logging.getLogger()
|
|
skip = skip or []
|
|
lastmarker = None
|
|
for mark, recipelist in self._recipes.items():
|
|
if marker and marker != mark:
|
|
logger.debug('Skipping marker %s for node %s.' %
|
|
(mark, self.node))
|
|
continue
|
|
for recipe in recipelist:
|
|
base = os.path.basename(recipe)
|
|
if (name and name != base) or base in skip:
|
|
logger.debug('Skipping recipe %s for node %s.' %
|
|
(recipe, self.node))
|
|
continue
|
|
|
|
# if the marker has changed then we don't want to
|
|
# proceed until all of the previous puppet runs have
|
|
# finished
|
|
if lastmarker and lastmarker != mark:
|
|
self._wait()
|
|
lastmarker = mark
|
|
|
|
logger.debug('Applying recipe %s to node %s.' %
|
|
(base, self.node))
|
|
rpath = os.path.join(self.recipe_dir, base)
|
|
if self._observer:
|
|
self._observer.applying(self, recipe)
|
|
self._running.add(rpath)
|
|
self._apply(rpath)
|
|
self._wait()
|
|
|
|
def cleanup(self, resource_dir=True, recipe_dir=True):
|
|
"""
|
|
Removes all directories created by this drone.
|
|
"""
|
|
shutil.rmtree(self.local_tmpdir, ignore_errors=True)
|
|
server = utils.ScriptRunner(self.node)
|
|
server.append('rm -fr %s' % self.remote_tmpdir)
|
|
if recipe_dir:
|
|
server.append('rm -fr %s' % self.recipe_dir)
|
|
if resource_dir:
|
|
server.append('rm -fr %s' % self.resource_dir)
|
|
server.execute()
|
|
|
|
|
|
class PackstackDrone(SshTarballTransferMixin, Drone):
|
|
"""
|
|
This drone uses Puppet and it's manifests to manipulate node.
|
|
"""
|
|
# XXX: Since this implementation is Packstack specific (_apply
|
|
# method), it should be moved out of installer when
|
|
# Controller and plugin system will be refactored and installer
|
|
# will support projects.
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs['resource_dir'] = ('/var/tmp/packstack/drone%s'
|
|
% uuid.uuid4().hex[:8])
|
|
kwargs['recipe_dir'] = '%s/manifests' % kwargs['resource_dir']
|
|
kwargs['remote_tmpdir'] = '%s/temp' % kwargs['resource_dir']
|
|
|
|
super(PackstackDrone, self).__init__(*args, **kwargs)
|
|
|
|
self.module_dir = os.path.join(self.resource_dir, 'modules')
|
|
self.fact_dir = os.path.join(self.resource_dir, 'facts')
|
|
|
|
def init_node(self):
|
|
"""
|
|
Initializes node for manipulation.
|
|
"""
|
|
super(PackstackDrone, self).init_node()
|
|
server = utils.ScriptRunner(self.node)
|
|
for pkg in ("puppet", "openssh-clients", "tar"):
|
|
server.append("rpm -q --whatprovides %(pkg)s || "
|
|
"yum install -y %(pkg)s" % locals())
|
|
server.execute()
|
|
|
|
def add_resource(self, path, resource_type=None):
|
|
"""
|
|
Resource type should be module, fact or resource.
|
|
"""
|
|
resource_type = resource_type or 'resource'
|
|
dest = '%ss' % resource_type
|
|
super(PackstackDrone, self).add_resource(path, destination=dest)
|
|
|
|
def _finished(self, recipe):
|
|
recipe_base = os.path.basename(recipe)
|
|
log = os.path.join(self.recipe_dir,
|
|
recipe_base.replace(".finished", ".log"))
|
|
local = utils.ScriptRunner()
|
|
local.append('scp -o StrictHostKeyChecking=no '
|
|
'-o UserKnownHostsFile=/dev/null '
|
|
'root@%s:%s %s' % (self.node, recipe, log))
|
|
try:
|
|
# once a remote puppet run has finished, we retrieve
|
|
# the log file and check it for errors
|
|
local.execute(log=False)
|
|
# if we got to this point the puppet apply has finished
|
|
return True
|
|
except utils.ScriptRuntimeError as e:
|
|
# the test raises an exception if the file doesn't exist yet
|
|
return False
|
|
|
|
def _apply(self, recipe):
|
|
running = "%s.running" % recipe
|
|
finished = "%s.finished" % recipe
|
|
|
|
server = utils.ScriptRunner(self.node)
|
|
server.append("touch %s" % running)
|
|
server.append("chmod 600 %s" % running)
|
|
|
|
# XXX: This is terrible hack, but unfortunatelly the apache
|
|
# puppet module doesn't work if we set FACTERLIB
|
|
# https://github.com/puppetlabs/puppetlabs-apache/pull/138
|
|
for bad_word in ('horizon', 'nagios', 'apache'):
|
|
if bad_word in recipe:
|
|
break
|
|
else:
|
|
server.append("export FACTERLIB=$FACTERLIB:%s" %
|
|
self.fact_dir)
|
|
server.append("export PACKSTACK_VAR_DIR=%s" % self.resource_dir)
|
|
|
|
# TO-DO: complete logger name when logging will be setup correctly
|
|
logger = logging.getLogger()
|
|
loglevel = logger.level <= logging.DEBUG and '--debug' or ''
|
|
rdir = self.resource_dir
|
|
mdir = self._module_dir
|
|
server.append(
|
|
"( flock %(rdir)s/ps.lock "
|
|
"puppet apply %(loglevel)s --modulepath %(mdir)s "
|
|
"%(recipe)s > %(running)s 2>&1 < /dev/null; "
|
|
"mv %(running)s %(finished)s ) "
|
|
"> /dev/null 2>&1 < /dev/null &" % locals())
|
|
server.execute()
|