Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Adding hook support #456

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lago/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
utils,
)
from lago.utils import (in_prefix, with_logging)
from hooks import with_hooks

LOGGER = logging.getLogger('cli')
in_lago_prefix = in_prefix(
Expand Down Expand Up @@ -258,6 +259,7 @@ def do_destroy(
)
@in_lago_prefix
@with_logging
@with_hooks
def do_start(prefix, vm_names=None, **kwargs):
prefix.start(vm_names=vm_names)

Expand All @@ -271,6 +273,7 @@ def do_start(prefix, vm_names=None, **kwargs):
)
@in_lago_prefix
@with_logging
@with_hooks
def do_stop(prefix, vm_names, **kwargs):
prefix.stop(vm_names=vm_names)

Expand Down Expand Up @@ -832,6 +835,11 @@ def create_parser(cli_plugins, out_plugins):
default='/var/lib/lago/reposync',
help='Reposync dir if used',
)
parser.add_argument(
'--without-hooks',
action='store_true',
help='If specified, run Lago command without hooks',
)

parser.add_argument('--ignore-warnings', action='store_true')
parser.set_defaults(**config.get_section('lago', {}))
Expand Down
257 changes: 257 additions & 0 deletions lago/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
# coding=utf-8

import functools
import lockfile
import logging
import log_utils
import os
from os import path
import shutil
import utils

LOGGER = logging.getLogger(__name__)
LogTask = functools.partial(log_utils.LogTask, logger=LOGGER)
log_task = functools.partial(log_utils.log_task, logger=LOGGER)
"""
Hooks
======
Run scripts before or after a Lago command.
The script will be run on the host which runs Lago.
"""


def with_hooks(func):
"""
Decorate a callable to run with hooks.
If without hooks==True, don't run the hooks, just the callable.

Args:
func(callable): callable to decorate

Returns:
The value returned by calling to func

"""

@functools.wraps(func)
def wrap(prefix, without_hooks=False, *args, **kwargs):
kwargs['prefix'] = prefix

if without_hooks:
LOGGER.debug('without_hooks=True, skipping hooks')
return func(*args, **kwargs)

cmd = func.__name__
if cmd.startswith('do_'):
cmd = cmd[3:]

hooks = Hooks(prefix.paths.hooks())
hooks.run_pre_hooks(cmd)
result = func(*args, **kwargs)
hooks.run_post_hooks(cmd)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hooks run regardless of the result, and are not aware of the result. Perhaps worth passing it to them?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Post hook won't run if 'func' throws an exception, do we want to change this behavior ?
  2. When passing 'result' to the post hook I need to assume that it is a string, or can be evaluated to a meaningful string, I'm not sure that I can take this assumption.


return result

return wrap


def copy_hooks_to_prefix(config, dir):
"""
Copy hooks into a prefix.
All the hooks will be copied to $LAGO_PREFIX_PATH/hooks.
Symlinks will be created between each hook and the matching
stage and command
that were specified in the config.

For example, the following config:

"hooks": {
"start": {
"pre": [
"$LAGO_INITFILE_PATH/a.py"
],
"post": [
"$LAGO_INITFILE_PATH/b.sh"
]
},
"stop": {
"pre": [
"$LAGO_INITFILE_PATH/c.sh"
],
"post": [
"$LAGO_INITFILE_PATH/d.sh"
]
}
}

will end up as the following directory structure:

└── $LAGO_PREFIX_PATH
├── hooks
│   ├── scripts
│   │   ├── a.py
│   │   ├── b.sh
│   │   ├── c.sh
│   │   └── d.sh
│   ├── start
│   │   ├── post
│   │   │   └── b.sh -> /home/gbenhaim/tmp/fc24/.lago/default
/hooks/scripts/b.sh
│   │   └── pre
│   │   └── a.py -> .lago/default/hooks/scripts/a.py
│   └── stop
│   ├── post
│   │   └── d.sh -> /home/gbenhaim/tmp/fc24/.lago/default
/hooks/scripts/d.sh
│   └── pre
│   └── c.sh -> /home/gbenhaim/tmp/fc24/.lago/default
/hooks/scripts/c.sh


Args:
config(dict): A dict which contains path to hooks categorized by
command and stage
dir(str): A path to the ne
Returns:
None
"""
with LogTask('Copying Hooks'):
scripts_dir = path.join(dir, 'scripts')
os.mkdir(dir)
os.mkdir(scripts_dir)

for cmd, stages in config.viewitems():
cmd_dir = path.join(dir, cmd)
os.mkdir(cmd_dir)
for stage, hooks in stages.viewitems():
stage_dir = path.join(cmd_dir, stage)
os.mkdir(stage_dir)
for idx, hook in enumerate(hooks):
hook_src_path = path.expandvars(hook)
hook_name = path.basename(hook_src_path)
hook_dst_path = path.join(scripts_dir, hook_name)

try:
shutil.copy(hook_src_path, hook_dst_path)
os.symlink(
hook_dst_path,
path.join(
stage_dir, '{}_{}'.format(idx, hook_name)
)
)
except IOError as e:
raise utils.LagoUserException(e)


class Hooks(object):

PRE_CMD = 'pre'
POST_CMD = 'post'

def __init__(self, path):
"""
Args:
path(list of str): path to the hook dir inside the prefix

Returns:
None
"""
self._path = path

def run_pre_hooks(self, cmd):
"""
Run the pre hooks of cmd
Args:
cmd(str): Name of the command

Returns:
None
"""
self._run(cmd, Hooks.PRE_CMD)

def run_post_hooks(self, cmd):
"""
Run the post hooks of cmd
Args:
cmd(str): Name of the command

Returns:
None
"""
self._run(cmd, Hooks.POST_CMD)

def _run(self, cmd, stage):
"""
Run the [ pre | post ] hooks of cmd
Note that the directory of cmd will be locked by this function in
order to avoid circular call, for example:

a.sh = lago stop
b.sh = lago start

a.sh is post hook of start
b.sh is post hook of stop

start -> a.sh -> stop -> b.sh -> start (in this step the hook
directory of start is locked, so start will be called without
its hooks)

Args:
cmd(str): Name of the command
stage(str): The stage of the hook

Returns:
None
"""
LOGGER.debug('hook called for {}-{}'.format(stage, cmd))
cmd_dir = path.join(self._path, cmd)
hook_dir = path.join(self._path, cmd, stage)

if not path.isdir(hook_dir):
LOGGER.debug('{} directory not found'.format(hook_dir))
return

_, _, hooks = os.walk(hook_dir).next()

if not hooks:
LOGGER.debug('No hooks were found for command: {}'.format(cmd))
return

# Avoid Recursion
try:
with utils.DirLockWithTimeout(cmd_dir):
self._run_hooks(
sorted([path.join(hook_dir, hook) for hook in hooks])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hooks are running alphabetically?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to preserve the order in which the hooks were mentioned in the init file, but now I realize that I didn't prefix them with an index. will fix.

)
except lockfile.AlreadyLocked:
LOGGER.debug(
'Hooks dir "{cmd}" is locked, skipping hooks'
' for command {cmd}'.format(cmd=cmd)
)

def _run_hooks(self, hooks):
"""
Run a list of scripts.
Each script should have execute permission.

Args:
hooks(list of str): list of path's of the the scrips
that should be run.

Returns:
None

Raises:
:exc:HookError: If a script returned code is != 0
"""
for hook in hooks:
with LogTask('Running hook: {}'.format(hook)):
result = utils.run_command([hook])
if result:
raise HookError(
'Failed to run hook {}\n{}'.format(hook, result.err)
)


class HookError(Exception):
pass
3 changes: 3 additions & 0 deletions lago/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@ def prefix_lagofile(self):

def scripts(self, *args):
return self.prefixed('scripts', *args)

def hooks(self, *args):
return self.prefixed('hooks', *args)
5 changes: 5 additions & 0 deletions lago/prefix.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import utils
import virt
import log_utils
import hooks

LOGGER = logging.getLogger(__name__)
LogTask = functools.partial(log_utils.LogTask, logger=LOGGER)
Expand Down Expand Up @@ -998,6 +999,10 @@ def virt_conf(
conf['domains'] = self._copy_deploy_scripts_for_hosts(
domains=conf['domains']
)

if 'hooks' in conf:
hooks.copy_hooks_to_prefix(conf['hooks'], self.paths.hooks())

self._virt_env = self.VIRT_ENV_CLASS(
prefix=self,
vm_specs=conf['domains'],
Expand Down
11 changes: 11 additions & 0 deletions lago/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,21 @@
from . import constants
from .log_utils import (LogTask, setup_prefix_logging)
import hashlib
from lockfile import mkdirlockfile

LOGGER = logging.getLogger(__name__)


class DirLockWithTimeout(mkdirlockfile.MkdirLockFile):
def __init__(self, path, threaded=True, timeout=0):
super(DirLockWithTimeout, self).__init__(path, threaded)
self.timeout = timeout

def __enter__(self):
self.acquire(self.timeout)
return self


class TimerException(Exception):
"""
Exception to throw when a timeout is reached
Expand Down