diff --git a/cdist/config.py b/cdist/config.py index b0131601..e6251476 100644 --- a/cdist/config.py +++ b/cdist/config.py @@ -130,13 +130,32 @@ class Config(object): for host in source: yield host + @staticmethod + def construct_remote_exec_copy_patterns(args): + # default remote cmd patterns + args.remote_exec_pattern = None + args.remote_copy_pattern = None + + args_dict = vars(args) + # if remote-exec and/or remote-copy args are None then user + # didn't specify command line options nor env vars: + # inspect multiplexing options for default cdist.REMOTE_COPY/EXEC + if (args_dict['remote_copy'] is None or + args_dict['remote_exec'] is None): + mux_opts = inspect_ssh_mux_opts() + if args_dict['remote_exec'] is None: + args.remote_exec_pattern = cdist.REMOTE_EXEC + mux_opts + if args_dict['remote_copy'] is None: + args.remote_copy_pattern = cdist.REMOTE_COPY + mux_opts + + @classmethod def commandline(cls, args): """Configure remote system""" import multiprocessing # FIXME: Refactor relict - remove later - log = logging.getLogger("cdist") + log = logging.getLogger(__name__) if args.manifest == '-' and args.hostfile == '-': raise cdist.Error(("Cannot read both, manifest and host file, " @@ -166,32 +185,14 @@ class Config(object): failed_hosts = [] time_start = time.time() - # default remote cmd patterns - args.remote_exec_pattern = None - args.remote_copy_pattern = None - - args_dict = vars(args) - # if remote-exec and/or remote-copy args are None then user - # didn't specify command line options nor env vars: - # inspect multiplexing options for default cdist.REMOTE_COPY/EXEC - if (args_dict['remote_copy'] is None or - args_dict['remote_exec'] is None): - mux_opts = inspect_ssh_mux_opts() - if args_dict['remote_exec'] is None: - args.remote_exec_pattern = cdist.REMOTE_EXEC + mux_opts - if args_dict['remote_copy'] is None: - args.remote_copy_pattern = cdist.REMOTE_COPY + mux_opts - - if args.out_path: - base_root_path = args.out_path - else: - base_root_path = tempfile.mkdtemp() + cls.construct_remote_exec_copy_patterns(args) + base_root_path = cls.create_base_root_path(args.out_path) hostcnt = 0 for host in itertools.chain(cls.hosts(args.host), cls.hosts(args.hostfile)): - hostdir = cdist.str_hash(host) - host_base_path = os.path.join(base_root_path, hostdir) + host_base_path, hostdir = cls.create_host_base_dirs( + host, base_root_path) log.debug("Base root path for target host \"{}\" is \"{}\"".format( host, host_base_path)) @@ -227,6 +228,8 @@ class Config(object): raise cdist.Error("Failed to configure the following hosts: " + " ".join(failed_hosts)) + + @classmethod def onehost(cls, host, host_base_path, host_dir_name, args, parallel): """Configure ONE system""" @@ -317,6 +320,25 @@ class Config(object): else: raise + + @staticmethod + def create_base_root_path(out_path=None): + if out_path: + base_root_path = out_path + else: + base_root_path = tempfile.mkdtemp() + + return base_root_path + + + @staticmethod + def create_host_base_dirs(host, base_root_path): + hostdir = cdist.str_hash(host) + host_base_path = os.path.join(base_root_path, hostdir) + + return (host_base_path, hostdir) + + def run(self): """Do what is most often done: deploy & cleanup""" start_time = time.time() diff --git a/cdist/trigger.py b/cdist/trigger.py new file mode 100644 index 00000000..ae787578 --- /dev/null +++ b/cdist/trigger.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# 2016 Nico Schottelius (nico-cdist at schottelius.org) +# +# This file is part of cdist. +# +# cdist is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# cdist is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with cdist. If not, see . +# +# + +import ipaddress +import logging +import re +import socket +import http.server +import os +import socketserver +import shutil + +import multiprocessing + +import cdist.config +import cdist.install + +log = logging.getLogger(__name__) + +class Trigger(): + """cdist trigger handling""" + + # Arguments that are only trigger specific + triggers_args = [ "http_port", "ipv6", "directory", "source" ] + + + def __init__(self, http_port=None, dry_run=False, ipv6=False, + directory=None, source=None, cdistargs=None): + self.log = logging.getLogger("trigger") + self.dry_run = dry_run + self.http_port = int(http_port) + self.ipv6 = ipv6 + self.args = cdistargs + + self.directory = directory + self.source = source + + log.debug("IPv6: %s", self.ipv6) + + def run_httpd(self): + server_address = ('', self.http_port) + + if self.ipv6: + httpdcls = HTTPServerV6 + else: + httpdcls = HTTPServerV4 + httpd = httpdcls(self.args, self.directory, self.source, server_address, TriggerHttp) + + log.debug("Starting server at port %d", self.http_port) + if self.dry_run: + log.debug("Running in dry run mode") + httpd.serve_forever() + + def run(self): + if self.http_port: + self.run_httpd() + + @classmethod + def commandline(cls, args): + http_port = args.http_port + ipv6 = args.ipv6 + + ownargs = {} + for targ in cls.triggers_args: + arg = getattr(args, targ) + ownargs[targ] = arg + + del arg + + t = cls(**ownargs, dry_run=args.dry_run, cdistargs=args) + t.run() + +class TriggerHttp(http.server.BaseHTTPRequestHandler): + actions = { "cdist": [ "config", "install" ], + "file": [ "present", "absent" ] + } + + def do_HEAD(self): + self.dispatch_request() + + def do_POST(self): + self.dispatch_request() + + def do_GET(self): + self.dispatch_request() + + def dispatch_request(self): + host = self.client_address[0] + code = 200 + + self.cdistargs = self.server.cdistargs + + # FIXME: generate regexp based on self.actions + m = re.match("^/(?Pcdist|file)/(?Ppresent|absent|config|install)/", self.path) + + if m: + subsystem = m.group('subsystem') + action = m.group('action') + handler = getattr(self, "handler_" + subsystem) + + if not action in self.actions[subsystem]: + code = 404 + else: + code = 404 + + if code == 200: + log.debug("Calling {} -> {}".format(subsystem, action)) + handler(action, host) + + self.send_response(code) + self.end_headers() + + def handler_file(self, action, host): + if not self.server.directory or not self.server.source: + log.info("Cannot server file request: directory or source not setup") + return + + try: + ipaddress.ip_address(host) + except ValueError: + log.error("Host is not a valid IP address - aborting") + return + + dst = os.path.join(self.server.directory, host) + + if action == "present": + shutil.copyfile(self.server.source, dst) + if action == "absent": + if os.path.exists(dst): + os.remove(dst) + + def handler_cdist(self, action, host): + log.debug("Running cdist for %s in mode %s", host, mode) + + if self.server.dry_run: + log.info("Dry run, skipping cdist execution") + return + + cname = action.title() + module = getattr(cdist, action) + theclass = getattr(module, cname) + + if hasattr(self.cdistargs, 'out_path'): + out_path = self.cdistargs.out_path + else: + out_path = None + host_base_path, hostdir = theclass.create_host_base_dirs( + host, theclass.create_base_root_path(out_path)) + theclass.construct_remote_exec_copy_patterns(self.cdistargs) + log.debug("Executing cdist onehost with params: %s, %s, %s, %s, ", + host, host_base_path, hostdir, self.cdistargs) + theclass.onehost(host, host_base_path, hostdir, self.cdistargs, + parallel=False) + + +class HTTPServerV6(socketserver.ForkingMixIn, http.server.HTTPServer): + """ + Server that listens to both IPv4 and IPv6 requests. + """ + address_family = socket.AF_INET6 + + def __init__(self, cdistargs, directory, source, *args, **kwargs): + self.cdistargs = cdistargs + self.dry_run = cdistargs.dry_run + self.directory = directory + self.source = source + + http.server.HTTPServer.__init__(self, *args, **kwargs) + +class HTTPServerV4(HTTPServerV6): + """ + Server that listens to IPv4 requests. + """ + address_family = socket.AF_INET diff --git a/docs/src/man1/cdist.rst b/docs/src/man1/cdist.rst index 45ce339e..8c1a4e98 100644 --- a/docs/src/man1/cdist.rst +++ b/docs/src/man1/cdist.rst @@ -27,6 +27,11 @@ SYNOPSIS cdist shell [-h] [-d] [-v] [-s SHELL] + cdist trigger [-h] [-d] [-v] [-b] [-c CONF_DIR] [-i MANIFEST] + [-j [JOBS]] [-n] [-o OUT_PATH] + [--remote-copy REMOTE_COPY] [--remote-exec REMOTE_EXEC] + [-6] [-H HTTP_PORT] + DESCRIPTION ----------- @@ -148,6 +153,67 @@ usage. Its primary use is for debugging type parameters. Select shell to use, defaults to current shell. Used shell should be POSIX compatible shell. + +TRIGGER +------- +Start trigger (simple http server) that waits for connections. When host +connects then it triggers config or install command, cdist config is then +executed which configures/installs host. +Request path recognies following formats: + +* :strong:`/config/.*` for config +* :strong:`/install/.*` for install + + +.. option:: -6, --ipv6 + + Listen to both IPv4 and IPv6 (instead of only IPv4) + +.. option:: -b, --enable-beta + + Enable beta functionalities. + +.. option:: -c CONF_DIR, --conf-dir CONF_DIR + + Add configuration directory (can be repeated, last one wins) + +.. option:: -d, --debug + + Set log level to debug + +.. option:: -H HTTP_PORT, --http-port HTTP_PORT + + Create trigger listener via http on specified port + +.. option:: -h, --help + + show this help message and exit + +.. option:: -i MANIFEST, --initial-manifest MANIFEST + + path to a cdist manifest or '-' to read from stdin. + +.. option:: -n, --dry-run + + do not execute code + +.. option:: -o OUT_PATH, --out-dir OUT_PATH + + directory to save cdist output in + +.. option:: --remote-copy REMOTE_COPY + + Command to use for remote copy (should behave like scp) + +.. option:: --remote-exec REMOTE_EXEC + + Command to use for remote execution (should behave like ssh) + +.. option:: -v, --verbose + + Set log level to info, be more verbose + + FILES ----- ~/.cdist @@ -199,6 +265,11 @@ EXAMPLES # Install ikq05.ethz.ch with debug enabled % cdist install -d ikq05.ethz.ch + # Start trigger in verbose mode that will configure host using specified + # init manifest + % cdist trigger -b -v -i ~/.cdist/manifest/init-for-triggered + + ENVIRONMENT ----------- TMPDIR, TEMP, TMP diff --git a/scripts/cdist b/scripts/cdist index 68084ca4..dfadd75f 100755 --- a/scripts/cdist +++ b/scripts/cdist @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# 2010-2013 Nico Schottelius (nico-cdist at schottelius.org) +# 2010-2016 Nico Schottelius (nico-cdist at schottelius.org) # 2016 Darko Poljak (darko.poljak at gmail.com) # # This file is part of cdist. @@ -23,7 +23,7 @@ # list of beta sub-commands -BETA_COMMANDS = ['install', ] +BETA_COMMANDS = ['install', 'trigger' ] # list of beta arguments for sub-commands BETA_ARGS = { 'config': ['jobs', ], @@ -69,6 +69,7 @@ def commandline(): import cdist.config import cdist.install import cdist.shell + import cdist.trigger import shutil import os import multiprocessing @@ -84,6 +85,12 @@ def commandline(): '-v', '--verbose', help='Set log level to info, be more verbose', action='store_true', default=False) + parser['beta'] = argparse.ArgumentParser(add_help=False) + parser['beta'].add_argument( + '-b', '--enable-beta', + help=('Enable beta functionalities.'), + action='store_true', dest='beta', default=False) + # Main subcommand parser parser['main'] = argparse.ArgumentParser( description='cdist ' + cdist.VERSION, parents=[parser['loglevel']]) @@ -98,20 +105,57 @@ def commandline(): 'banner', parents=[parser['loglevel']]) parser['banner'].set_defaults(func=cdist.banner.banner) - # Config - parser['config'] = parser['sub'].add_parser( - 'config', parents=[parser['loglevel']]) - parser['config'].add_argument( - 'host', nargs='*', help='host(s) to operate on') - parser['config'].add_argument( - '-b', '--enable-beta', - help=('Enable beta functionalities. Beta functionalities ' - 'include the following options: -j/--jobs.'), - action='store_true', dest='beta', default=False) - parser['config'].add_argument( + parser['config_main'] = argparse.ArgumentParser(add_help=False) + parser['config_main'].add_argument( '-c', '--conf-dir', help=('Add configuration directory (can be repeated, ' 'last one wins)'), action='append') + parser['config_main'].add_argument( + '-i', '--initial-manifest', + help='path to a cdist manifest or \'-\' to read from stdin.', + dest='manifest', required=False) + parser['config_main'].add_argument( + '-j', '--jobs', nargs='?', type=check_positive_int, + help=('specify the maximum number of parallel jobs, currently ' + 'only global explorers are supported (currently in beta'), + action='store', dest='jobs', + const=multiprocessing.cpu_count()) + parser['config_main'].add_argument( + '-n', '--dry-run', + help='do not execute code', action='store_true') + parser['config_main'].add_argument( + '-o', '--out-dir', + help='directory to save cdist output in', dest="out_path") + + # remote-copy and remote-exec defaults are environment variables + # if set; if not then None - these will be futher handled after + # parsing to determine implementation default + parser['config_main'].add_argument( + '--remote-copy', + help='Command to use for remote copy (should behave like scp)', + action='store', dest='remote_copy', + default=os.environ.get('CDIST_REMOTE_COPY')) + parser['config_main'].add_argument( + '--remote-exec', + help=('Command to use for remote execution ' + '(should behave like ssh)'), + action='store', dest='remote_exec', + default=os.environ.get('CDIST_REMOTE_EXEC')) + + # Config + parser['config'] = parser['sub'].add_parser( + 'config', parents=[parser['loglevel'], parser['beta'], + parser['config_main']]) + parser['config'].add_argument( + 'host', nargs='*', help='host(s) to operate on') + parser['config'].add_argument( + '-s', '--sequential', + help='operate on multiple hosts sequentially (default)', + action='store_false', dest='parallel') + parser['config'].add_argument( + '-p', '--parallel', + help='operate on multiple hosts in parallel', + action='store_true', dest='parallel') parser['config'].add_argument( '-f', '--file', help=('Read additional hosts to operate on from specified file ' @@ -119,44 +163,6 @@ def commandline(): 'If no host or host file is specified then, by default, ' 'read hosts from stdin.'), dest='hostfile', required=False) - parser['config'].add_argument( - '-i', '--initial-manifest', - help='Path to a cdist manifest or \'-\' to read from stdin.', - dest='manifest', required=False) - parser['config'].add_argument( - '-j', '--jobs', nargs='?', type=check_positive_int, - help=('Specify the maximum number of parallel jobs, currently ' - 'only global explorers are supported (currently in beta'), - action='store', dest='jobs', - const=multiprocessing.cpu_count()) - parser['config'].add_argument( - '-n', '--dry-run', - help='Do not execute code', action='store_true') - parser['config'].add_argument( - '-o', '--out-dir', - help='Directory to save cdist output in', dest="out_path") - parser['config'].add_argument( - '-p', '--parallel', - help='Operate on multiple hosts in parallel', - action='store_true', dest='parallel') - parser['config'].add_argument( - '-s', '--sequential', - help='Operate on multiple hosts sequentially (default)', - action='store_false', dest='parallel') - # remote-copy and remote-exec defaults are environment variables - # if set; if not then None - these will be futher handled after - # parsing to determine implementation default - parser['config'].add_argument( - '--remote-copy', - help='Command to use for remote copy (should behave like scp)', - action='store', dest='remote_copy', - default=os.environ.get('CDIST_REMOTE_COPY')) - parser['config'].add_argument( - '--remote-exec', - help=('Command to use for remote execution ' - '(should behave like ssh)'), - action='store', dest='remote_exec', - default=os.environ.get('CDIST_REMOTE_EXEC')) parser['config'].set_defaults(func=cdist.config.Config.commandline) # Install @@ -173,6 +179,34 @@ def commandline(): ' should be POSIX compatible shell.')) parser['shell'].set_defaults(func=cdist.shell.Shell.commandline) + # Trigger + parser['trigger'] = parser['sub'].add_parser( + 'trigger', parents=[parser['loglevel'], + parser['beta'], + parser['config_main']]) + parser['trigger'].add_argument( + '-6', '--ipv6', default=False, + help=('Listen to both IPv4 and IPv6 (instead of only IPv4)'), + action='store_true') + parser['trigger'].add_argument( + '-H', '--http-port', action='store', default=3000, required=False, + help=('Create trigger listener via http on specified port')) + + parser['trigger'].add_argument( + '-D', '--directory', action='store', required=False, + help=('Where to create local files')) + + parser['trigger'].add_argument( + '-S', '--source', action='store', required=False, + help=('Which file to copy for creation')) + + parser['trigger'].set_defaults(func=cdist.trigger.Trigger.commandline) + + # Install + parser['install'] = parser['sub'].add_parser('install', add_help=False, + parents=[parser['config']]) + parser['install'].set_defaults(func=cdist.install.Install.commandline) + for p in parser: parser[p].epilog = ( "Get cdist at http://www.nico.schottelius.org/software/cdist/")