diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..d1a7b1604b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*~ +*.py[co] +build diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000000..c920bd4782 --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,12 @@ +Patches and Contributions +========================= + + * Michael DeHaan - michael.dehaan AT gmail DOT com + * Jeremy Katz - katzj AT fedoraproject DOT org + * Seth Vidal - skvidal AT fedoraproject DOT org + * Tim Bielawa - tbielawa AT redhat DOT com + +Send in a github pull request to get your name here. + +Upstream: github.com/mpdehaan/ansible + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..dca3a50308 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +#!/usr/bin/make + +ASCII2MAN = a2x -D $(dir $@) -d manpage -f manpage $< +ASCII2HTMLMAN = a2x -D docs/html/man/ -d manpage -f xhtml +MANPAGES := docs/man/man1/ansible.1 +SITELIB = $(shell python -c "from distutils.sysconfig import get_python_lib; print get_python_lib()") + +docs: manuals + +manuals: $(MANPAGES) + +%.1: %.1.asciidoc + $(ASCII2MAN) + +%.5: %.5.asciidoc + $(ASCII2MAN) + +pep8: + @echo "#############################################" + @echo "# Running PEP8 Compliance Tests" + @echo "#############################################" + pep8 lib/ + +clean: + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type f -name "*~" -delete + find ./docs/ -type f -name "*.xml" -delete + find . -type f -name "#*" -delete + +.PHONEY: docs manual clean pep8 +vpath %.asciidoc docs/man/man1 diff --git a/README.md b/README.md index a83b3fdbb0..71502d286a 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,37 @@ Ansible ======= -Ansible is a extra-simple Python API for doing 'remote things' over SSH. +Ansible is a extra-simple tool/API for doing 'parallel remote things' over SSH -- whether +executing commands, running declarative 'modules', or executing larger 'playbooks' that +can serve as a configuration management or deployment system. -While [Func](http://fedorahosted.org/func), which I co-wrote, aspired to avoid using SSH and have it's own daemon infrastructure, Ansible aspires to be quite different and more minimal, but still able to grow more modularly over time. This is based on talking to a lot of users of various tools and wishing to eliminate problems with connectivity and long running daemons, or not picking tool X because they preferred to code in Y. +While [Func](http://fedorahosted.org/func), which I co-wrote, +aspired to avoid using SSH and have it's own daemon infrastructure, +Ansible aspires to be quite different and more minimal, but still able +to grow more modularly over time. This is based on talking to a lot of +users of various tools and wishing to eliminate problems with connectivity +and long running daemons, or not picking tool X because they preferred to +code in Y. -Why use Ansible versus something else? (Fabric, Capistrano, mCollective, Func, SaltStack, etc?) It will have far less code, it will be more correct, and it will be the easiest thing to hack on and use you'll ever see -- regardless of your favorite language of choice. Want to only code plugins in bash or clojure? Ansible doesn't care. The docs will fit on one page and the source will be blindingly obvious. +Why use Ansible versus something else? (Fabric, Capistrano, mCollective, +Func, SaltStack, etc?) It will have far less code, it will be more correct, +and it will be the easiest thing to hack on and use you'll ever see -- +regardless of your favorite language of choice. Want to only code plugins +in bash or clojure? Ansible doesn't care. The docs will fit on one page +and the source will be blindingly obvious. -Principles -========== +Design Principles +================= * Dead simple setup * Super fast & parallel by default * No server or client daemons, uses existing SSHd * No additional software required on client boxes - * Everything is self updating on the clients. "Modules" are remotely transferred to target boxes and exec'd, and do not stay active or consume resources. - * Only SSH keys are allowed for authentication - * usage of ssh-agent is more or less required (no passwords) - * plugins can be written in ANY language - * as with Func, API usage is an equal citizen to CLI usage - * use Python's multiprocessing capabilities to emulate Func's forkbomb logic - * all file paths can be specified as command line options easily allowing non-root usage + * Everything is self updating on the clients + * Encourages use of ssh-agent + * Plugins can be written in ANY language + * API usage is an equal citizen to CLI usage + * Can be controlled/installed/used as non-root Requirements ============ @@ -28,16 +39,17 @@ Requirements For the server the tool is running from, *only*: * python 2.6 -- or the 2.4/2.5 backport of the multiprocessing module + * PyYAML (if using playbooks) * paramiko Inventory file ============== -The inventory file is a required list of hostnames that can be potentially managed by -ansible. Eventually this file may be editable via the CLI, but for now, is -edited with your favorite text editor. +The inventory file is a required list of hostnames that can be +potentially managed by ansible. Eventually this file may be editable +via the CLI, but for now, is edited with your favorite text editor. -The default inventory file (-H) is ~/.ansible_hosts and is a list +The default inventory file (-H) is /etc/ansible/hosts and is a list of all hostnames to target with ansible, one per line. These can be hostnames or IPs @@ -66,32 +78,40 @@ Run a module by name with arguments API Example =========== -The API is simple and returns basic datastructures. +The API is simple and returns basic datastructures. Ansible will keep +track of which hosts were successfully contacted seperately from hosts +that had communication problems. The format of the return, if successful, +is entirely up to the module. import ansible - runner = ansible.Runner( + runner = ansible.runner.Runner( pattern='*', - module_name='inventory', - host_list=['xyz.example.com', '...'] + module_name='inventory', + module_args='...' ) data = runner.run() - { - 'xyz.example.com' : [ 'any kind of datastructure is returnable' ], - 'foo.example.com' : None, # failed to connect, - ... + { + 'contacted' : { + 'xyz.example.com' : [ 'any kind of datastructure is returnable' ], + 'foo.example.com' : [ '...' ] + }, + 'dark' : { + 'bar.example.com' : [ 'failure message' ] + } } Additional options to Runner include the number of forks, hostname -exclusion pattern, library path, arguments, and so on. Read the source, it's not -complicated. +exclusion pattern, library path, arguments, and so on. + +Read the source, it's not complicated. Patterns ======== To target only hosts starting with "rtp", for example: - * ansible "rtp*" -n command -a "yum update apache" + * ansible -p "rtp*" -n command -a "yum update apache" Parallelism =========== @@ -107,38 +127,41 @@ File Transfer Ansible can SCP lots of files to lots of places in parallel. - * ansible -f 10 -n copy -a "/etc/hosts /tmp/hosts" + * ansible -p "web-*.acme.net" -f 10 -n copy -a "/etc/hosts /tmp/hosts" -Bundled Modules -=============== +Ansible Library (Bundled Modules) +================================= See the example library for modules, they can be written in any language and simply return JSON to stdout. The path to your ansible library is specified with the "-L" flag should you wish to use a different location -than "~/ansible". There is potential for a sizeable community to build +than "/usr/share/ansible". This means anyone can use Ansible, even without +root permissions. + +There is potential for a sizeable community to build up around the library scripts. -Existing library modules -======================== +Modules include: * command -- runs commands, giving output, return codes, and run time info * ping - just returns if the system is up or not * facter - retrieves facts about the host OS + * ohai - similar to facter, but returns structured data * copy - add files to remote systems +Playbooks +========= + +Playbooks are loosely equivalent to recipes or manifests in most configuration +management or deployment tools and describe a set of operations to run on +a set of hosts. Some tasks can choose to only fire when certain +conditions are true, and if a task in a chain fails the dependent tasks +will not proceed. Playbooks are described in (YAML)[http://yaml.org] format. + Future plans ============ - * modules for users, groups, and files, using puppet style ensure mechanics - * ansible-inventory -- gathering fact/hw info, storing in git, adding RSS - * ansible-slurp ------ recursively rsync file trees for each host - * very simple option constructing/parsing for modules - * Dead-simple declarative configuration management engine using - a runbook style recipe file, written in JSON or YAML - * maybe it's own fact engine, not required, that also feeds from facter - * add/remove/list hosts from the command line - * list available modules from command line - * filter exclusion (run this only if fact is true/false) + * see TODO.md License ======= @@ -170,8 +193,8 @@ Mailing List Author ====== - Michael DeHaan -- michael.dehaan@gmail.com +Michael DeHaan -- michael.dehaan@gmail.com - [http://michaeldehaan.net](http://michaeldehaan.net/) +[http://michaeldehaan.net](http://michaeldehaan.net/) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000000..8fd5ef73ac --- /dev/null +++ b/TODO.md @@ -0,0 +1,44 @@ +TODO list and plans +=================== + +Playbook TODO: + + * error codes and failure summaries + * create modules that return 'changed' attributes + * fail nodes on errors, i.e. remove from host list, rather than continuing to pound them + * further improve output + * more conditional capability + * very good logging + +Command module: + * allow additional key/value options to be passed to any module (via ENV vars?) + * allow this to be expressed in playbook as a 4th option after the array options list + * use this to pass timeout and async params to the command module + default timeouts will be infinite, async False + +General: + + * logging + * async options + * modules for users, groups, and files, using puppet style ensure mechanics + * very simple option constructing/parsing for modules + * templating module (how might this work syntax wise?) with facter/ohai awareness + * probably could lay down a values.json file + * use copy capabilities to move files to tmp, run python templating + * maybe support templating engine of choice + * think about how to build idempotency guards around command module? + * think about how to feed extra JSON data onto system + +Bonus utilities: + + * ansible-inventory - gathering fact/hw info, storing in git, adding RSS + * ansible-slurp - recursively rsync file trees for each host + * maybe it's own fact engine, not required, that also feeds from facter + +Not so interested really, but maybe: + + * list available modules from command line + * add/remove/list hosts from the command line + * filter exclusion (run this only if fact is true/false) + -- should be doable with playbooks (i.e. not neccessary) + diff --git a/bin/ansible b/bin/ansible index d6070c2c84..92316fcd67 100755 --- a/bin/ansible +++ b/bin/ansible @@ -24,14 +24,10 @@ from optparse import OptionParser import json import os -import ansible - -DEFAULT_HOST_LIST = '~/.ansible_hosts' -DEFAULT_MODULE_PATH = '~/ansible' -DEFAULT_MODULE_NAME = 'ping' -DEFAULT_PATTERN = '*' -DEFAULT_FORKS = 3 -DEFAULT_MODULE_ARGS = '' +import getpass +import ansible.runner +import ansible.playbook +import ansible.constants as C class Cli(object): @@ -40,37 +36,56 @@ class Cli(object): def runner(self): parser = OptionParser() + parser.add_option("-P", "--askpass", default=False, action="store_true", + help="ask the user to input the ssh password for connecting") parser.add_option("-H", "--host-list", dest="host_list", - help="path to hosts list", default=DEFAULT_HOST_LIST) + help="path to hosts list", default=C.DEFAULT_HOST_LIST) parser.add_option("-L", "--library", dest="module_path", - help="path to module library", default=DEFAULT_MODULE_PATH) - parser.add_option("-F", "--forks", dest="forks", - help="level of parallelism", default=DEFAULT_FORKS) + help="path to module library", default=C.DEFAULT_MODULE_PATH) + parser.add_option("-f", "--forks", dest="forks", type="int", + help="level of parallelism", default=C.DEFAULT_FORKS) parser.add_option("-n", "--name", dest="module_name", - help="module name to execute", default=DEFAULT_MODULE_NAME) + help="module name to execute", default=C.DEFAULT_MODULE_NAME) parser.add_option("-a", "--args", dest="module_args", - help="module arguments", default=DEFAULT_MODULE_ARGS) + help="module arguments", default=C.DEFAULT_MODULE_ARGS) parser.add_option("-p", "--pattern", dest="pattern", - help="hostname pattern", default=DEFAULT_PATTERN) + help="hostname pattern", default=C.DEFAULT_PATTERN) + parser.add_option("-u", "--remote-user", dest="remote_user", + help="remote username", default=C.DEFAULT_REMOTE_USER) + parser.add_option("-r", "--run-playbook", dest="playbook", + help="playbook file, instead of -n and -a", default=None) options, args = parser.parse_args() - host_list = self._host_list(options.host_list) # TODO: more shell like splitting on module_args would # be a good idea - return ansible.Runner( - module_name=options.module_name, - module_path=options.module_path, - module_args=options.module_args.split(' '), - host_list=host_list, - forks=options.forks, - pattern=options.pattern, - ) + sshpass = None + if options.askpass: + sshpass = getpass.getpass(prompt="SSH password: ") - def _host_list(self, host_list): - host_list = os.path.expanduser(host_list) - return file(host_list).read().split("\n") + if options.playbook is None: + return ansible.runner.Runner( + module_name=options.module_name, + module_path=options.module_path, + module_args=options.module_args.split(' '), + remote_user=options.remote_user, + remote_pass=sshpass, + host_list=options.host_list, + forks=options.forks, + pattern=options.pattern, + verbose=False, + ) + else: + return ansible.playbook.PlayBook( + playbook=options.playbook, + module_path=options.module_path, + remote_user=options.remote_user, + remote_pass=sshpass, + host_list=options.host_list, + forks=options.forks, + verbose=False, + ) if __name__ == '__main__': diff --git a/docs/man/.gitignore b/docs/man/.gitignore new file mode 100644 index 0000000000..b81c7954b7 --- /dev/null +++ b/docs/man/.gitignore @@ -0,0 +1 @@ +*.xml \ No newline at end of file diff --git a/docs/man/man1/ansible.1 b/docs/man/man1/ansible.1 new file mode 100644 index 0000000000..7ff7233b0e --- /dev/null +++ b/docs/man/man1/ansible.1 @@ -0,0 +1,108 @@ +'\" t +.\" Title: ansible +.\" Author: [see the "AUTHOR" section] +.\" Generator: DocBook XSL Stylesheets v1.76.1 +.\" Date: 02/24/2012 +.\" Manual: System administration commands +.\" Source: Ansible 0.0.1 +.\" Language: English +.\" +.TH "ANSIBLE" "1" "02/24/2012" "Ansible 0\&.0\&.1" "System administration commands" +.\" ----------------------------------------------------------------- +.\" * Define some portability stuff +.\" ----------------------------------------------------------------- +.\" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.\" http://bugs.debian.org/507673 +.\" http://lists.gnu.org/archive/html/groff/2009-02/msg00013.html +.\" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.\" ----------------------------------------------------------------- +.\" * set default formatting +.\" ----------------------------------------------------------------- +.\" disable hyphenation +.nh +.\" disable justification (adjust text to left margin only) +.ad l +.\" ----------------------------------------------------------------- +.\" * MAIN CONTENT STARTS HERE * +.\" ----------------------------------------------------------------- +.SH "NAME" +ansible \- run a command somewhere else +.SH "SYNOPSIS" +.sp +ansible [\-H hosts_path] [\-L library_path] [\-f forks] [\-n module_name] [\-a [args1 [args2 \&...]]] [\-p host_pattern] [\-u remote_user] +.SH "DESCRIPTION" +.sp +\fBAnsible\fR is an extra\-simple Python API for doing \*(Aqremote things\*(Aq over SSH\&. +.SH "OPTIONS" +.PP +\fB\-P\fR, \fB\-\-askpass\fR +.RS 4 +Ask the user to input the ssh password for connecting\&. +.RE +.PP +\fB\-H\fR, \fB\-\-host\-list\fR +.RS 4 +Path to hosts list\&. +.RE +.PP +\fB\-L\fR, \fB\-\-library\fR +.RS 4 +Path to module library\&. +.RE +.PP +\fB\-f\fR, \fB\-\-forks\fR +.RS 4 +Level of parallelism\&. Specify as an integer\&. +.RE +.PP +\fB\-n\fR, \fB\-\-name\fR +.RS 4 +Module name to execute\&. +.RE +.PP +\fB\-a\fR, \fB\-\-args\fR +.RS 4 +Arguments to module\&. +.RE +.PP +\fB\-p\fR, \fB\-\-pattern\fR +.RS 4 +Hostname pattern\&. Accepts shell\-like globs\&. +.RE +.PP +\fB\-r\fR, \fB\-\-run\-playbook\fR +.RS 4 +Playbook file to run\&. Replaces the +\fB\-n\fR +and +\fB\-a\fR +options\&. +.RE +.PP +\fB\-u\fR, \fB\-\-remote\-user\fR +.RS 4 +Remote user to connect as\&. Uses +\fIroot\fR +by default\&. +.RE +.SH "INVENTORY" +.sp +Ansible stores the hosts it can potentially operate on in an inventory file\&. The syntax is simple: one host per line\&. Organize your hosts into multiple groups by separating them into multiple inventory files\&. +.SH "FILES" +.sp +/etc/ansible/hosts \(em Default hosts file +.sp +/usr/share/ansible \(em Default module library +.SH "AUTHOR" +.sp +Ansible was originally written by Michael DeHaan\&. See the AUTHORS file for a complete list of contributors\&. +.SH "COPYRIGHT" +.sp +Copyright \(co 2012, Michael DeHaan +.sp +Ansible is released under the terms of the MIT license\&. +.SH "SEE ALSO" +.sp +Ansible home page: https://github\&.com/mpdehaan/ansible/ diff --git a/docs/man/man1/ansible.1.asciidoc b/docs/man/man1/ansible.1.asciidoc new file mode 100644 index 0000000000..ca5e1e9dff --- /dev/null +++ b/docs/man/man1/ansible.1.asciidoc @@ -0,0 +1,109 @@ +ansible(1) +========= +:doctype:manpage +:man source: Ansible +:man version: 0.0.1 +:man manual: System administration commands + +NAME +---- +ansible - run a command somewhere else + + +SYNOPSIS +-------- +ansible [-H hosts_path] [-L library_path] [-f forks] [-n module_name] + [-a [args1 [args2 ...]]] [-p host_pattern] [-u remote_user] + + +DESCRIPTION +----------- + +*Ansible* is an extra-simple Python API for doing \'remote things' over +SSH. + + +OPTIONS +------- + +*-P*, *--askpass*:: + +Ask the user to input the ssh password for connecting. + + +*-H*, *--host-list*:: + +Path to hosts list. + + +*-L*, *--library*:: + +Path to module library. + + +*-f*, *--forks*:: + +Level of parallelism. Specify as an integer. + + +*-n*, *--name*:: + +Module name to execute. + + +*-a*, *--args*:: + +Arguments to module. + + +*-p*, *--pattern*:: + +Hostname pattern. Accepts shell-like globs. + + +*-r*, *--run-playbook*:: + +Playbook file to run. Replaces the *-n* and *-a* options. + + +*-u*, *--remote-user*:: + +Remote user to connect as. Uses __root__ by default. + + +INVENTORY +--------- + +Ansible stores the hosts it can potentially operate on in an inventory +file. The syntax is simple: one host per line. Organize your hosts +into multiple groups by separating them into multiple inventory files. + + +FILES +----- + +/etc/ansible/hosts -- Default hosts file + +/usr/share/ansible -- Default module library + + +AUTHOR +------ + +Ansible was originally written by Michael DeHaan. See the AUTHORS file +for a complete list of contributors. + + +COPYRIGHT +--------- + +Copyright © 2012, Michael DeHaan + +Ansible is released under the terms of the MIT license. + + + +SEE ALSO +-------- + +Ansible home page: diff --git a/examples/playbook.yml b/examples/playbook.yml new file mode 100644 index 0000000000..8a4cf19aef --- /dev/null +++ b/examples/playbook.yml @@ -0,0 +1,16 @@ +- pattern: '*' + tasks: + - do: + - update apache + - command + - [/usr/bin/yum, update, apache] + onchange: + - do: + - restart apache + - command + - [/sbin/service, apache, restart] + - do: + - run bin false + - command + - [/bin/false] + diff --git a/lib/ansible/__init__.py b/lib/ansible/__init__.py index a056727047..e69de29bb2 100755 --- a/lib/ansible/__init__.py +++ b/lib/ansible/__init__.py @@ -1,153 +0,0 @@ -# Copyright (c) 2012 Michael DeHaan -# -# Permission is hereby granted, free of charge, to any person -# obtaining a copy of this software and associated documentation -# files (the "Software"), to deal in the Software without restriction, -# including without limitation the rights to use, copy, modify, merge, -# publish, distribute, sublicense, and/or sell copies of the Software, -# and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR -# ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF -# CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import fnmatch -from multiprocessing import Process, Pipe -from itertools import izip -import os -import json - -# non-core -import paramiko - -# TODO -- library should have defaults, not just CLI -# update Runner constructor below to use - -DEFAULT_HOST_LIST = '~/.ansible_hosts' -DEFAULT_MODULE_PATH = '~/ansible' -DEFAULT_MODULE_NAME = 'ping' -DEFAULT_PATTERN = '*' -DEFAULT_FORKS = 3 -DEFAULT_MODULE_ARGS = '' - -class Pooler(object): - - # credit: http://stackoverflow.com/questions/3288595/multiprocessing-using-pool-map-on-a-function-defined-in-a-class - - @classmethod - def spawn(cls, f): - def fun(pipe,x): - pipe.send(f(x)) - pipe.close() - return fun - - @classmethod - def parmap(cls, f, X): - pipe=[Pipe() for x in X] - proc=[Process(target=cls.spawn(f),args=(c,x)) for x,(p,c) in izip(X,pipe)] - [p.start() for p in proc] - [p.join() for p in proc] - return [p.recv() for (p,c) in pipe] - -class Runner(object): - - def __init__(self, host_list=[], module_path=None, - module_name=None, module_args=[], - forks=3, timeout=60, pattern='*'): - - self.host_list = host_list - self.module_path = module_path - self.module_name = module_name - self.forks = forks - self.pattern = pattern - self.module_args = module_args - self.timeout = timeout - - - def _matches(self, host_name): - if host_name == '': - return False - if fnmatch.fnmatch(host_name, self.pattern): - return True - return False - - def _connect(self, host): - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - ssh.connect(host, username='root', - allow_agent=True, look_for_keys=True) - return ssh - except: - return None - - def _executor(self, host): - # TODO: try/catch returning none - conn = self._connect(host) - if not conn: - return [ host, None ] - if self.module_name != "copy": - outpath = self._copy_module(conn) - self._exec_command(conn, "chmod +x %s" % outpath) - cmd = self._command(outpath) - result = self._exec_command(conn, cmd) - result = json.loads(result) - else: - ftp = conn.open_sftp() - ftp.put(self.module_args[0], self.module_args[1]) - ftp.close() - return [ host, 1 ] - - return [ host, result ] - - def _command(self, outpath): - cmd = "%s %s" % (outpath, " ".join(self.module_args)) - return cmd - - def _exec_command(self, conn, cmd): - stdin, stdout, stderr = conn.exec_command(cmd) - results = stdout.read() - return results - - def _copy_module(self, conn): - inpath = os.path.expanduser(os.path.join(self.module_path, self.module_name)) - outpath = os.path.join("/var/spool/", "ansible_%s" % self.module_name) - ftp = conn.open_sftp() - ftp.put(inpath, outpath) - ftp.close() - return outpath - - def run(self): - hosts = [ h for h in self.host_list if self._matches(h) ] - def executor(x): - return self._executor(x) - results = Pooler.parmap(executor, hosts) - by_host = dict(results) - return by_host - - -if __name__ == '__main__': - - - # TODO: if host list is string load from file - - r = Runner( - host_list = [ '127.0.0.1' ], - module_path='~/ansible', - module_name='ping', - module_args='', - pattern='*', - forks=3 - ) - print r.run() - - - diff --git a/lib/ansible/constants.py b/lib/ansible/constants.py new file mode 100644 index 0000000000..15ad18de6b --- /dev/null +++ b/lib/ansible/constants.py @@ -0,0 +1,10 @@ +DEFAULT_HOST_LIST = '/etc/ansible/hosts' +DEFAULT_MODULE_PATH = '/usr/share/ansible' +DEFAULT_MODULE_NAME = 'ping' +DEFAULT_PATTERN = '*' +DEFAULT_FORKS = 3 +DEFAULT_MODULE_ARGS = '' +DEFAULT_TIMEOUT = 60 +DEFAULT_REMOTE_USER = 'root' +DEFAULT_REMOTE_PASS = None + diff --git a/lib/ansible/playbook.py b/lib/ansible/playbook.py new file mode 100755 index 0000000000..f2e92b3415 --- /dev/null +++ b/lib/ansible/playbook.py @@ -0,0 +1,175 @@ +# Copyright (c) 2012 Michael DeHaan +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +# ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +# CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import ansible.runner +import ansible.constants as C +import json +import yaml + +# TODO: make a constants file rather than +# duplicating these + +class PlayBook(object): + ''' + runs an ansible playbook, given as a datastructure + or YAML filename. a playbook is a deployment, config + management, or automation based set of commands to + run in series. + + multiple patterns do not execute simultaneously, + but tasks in each pattern do execute in parallel + according to the number of forks requested. + ''' + + def __init__(self, + playbook =None, + host_list =C.DEFAULT_HOST_LIST, + module_path =C.DEFAULT_MODULE_PATH, + forks =C.DEFAULT_FORKS, + timeout =C.DEFAULT_TIMEOUT, + remote_user =C.DEFAULT_REMOTE_USER, + remote_pass =C.DEFAULT_REMOTE_PASS, + verbose=False): + + # runner is reused between calls + + self.host_list = host_list + self.module_path = module_path + self.forks = forks + self.timeout = timeout + self.remote_user = remote_user + self.remote_pass = remote_pass + self.verbose = verbose + + if type(playbook) == str: + playbook = yaml.load(file(playbook).read()) + self.playbook = playbook + + def run(self): + ''' run against all patterns in the playbook ''' + + for pattern in self.playbook: + self._run_pattern(pattern) + + # TODO: return a summary of success & failure counts per node + # TODO: in bin/ancible, ensure return codes are appropriate + + return "complete" + + def _get_task_runner(self, + pattern=None, + host_list=None, + module_name=None, + module_args=None): + + ''' + return a runner suitable for running this task, using + preferences from the constructor + ''' + + if host_list is None: + host_list = self.host_list + + return ansible.runner.Runner( + pattern=pattern, + module_name=module_name, + module_args=module_args, + host_list=host_list, + forks=self.forks, + remote_user=self.remote_user, + remote_pass=self.remote_pass, + module_path=self.module_path, + timeout=self.timeout + ) + + def _run_task(self, pattern, task, host_list=None, conditional=False): + ''' + run a single task in the playbook and + recursively run any subtasks. + ''' + + if host_list is None: + host_list = self.host_list + + instructions = task['do'] + (comment, module_name, module_args) = instructions + + namestr = "%s/%s" % (pattern, comment) + if conditional: + namestr = "subset/%s" % namestr + print "TASK [%s]" % namestr + + runner = self._get_task_runner( + pattern=pattern, + host_list=host_list, + module_name=module_name, + module_args=module_args + ) + results = runner.run() + + dark = results.get("dark", []) + + contacted = results.get("contacted", []) + + # TODO: filter based on values that indicate + # they have changed events to emulate Puppet + # 'notify' behavior, super easy -- just + # a list comprehension -- but we need complaint + # modules first + + ok_hosts = contacted.keys() + + for host, msg in dark.items(): + print "DARK: [%s] => %s" % (host, msg) + + for host, results in contacted.items(): + if module_name == "command": + if results.get("rc", 0) != 0: + print "FAIL: [%s/%s] => %s" % (host, comment, results) + elif results.get("failed", 0) == 1: + print "FAIL: [%s/%s]" % (host, comment, results) + + + subtasks = task.get('onchange', []) + if len(subtasks) > 0: + for subtask in subtasks: + self._run_task(pattern, subtask, ok_hosts, conditional=True) + + # TODO: if a host fails in task 1, add it to an excludes + # list such that no other tasks in the list ever execute + # unlike Puppet, do not allow partial failure of the tree + # and continuing as far as possible. Fail fast. + + + def _run_pattern(self, pg): + ''' + run a list of tasks for a given pattern, in order + ''' + + pattern = pg['pattern'] + tasks = pg['tasks'] + for task in tasks: + self._run_task(pattern, task) + + + + + diff --git a/lib/ansible/runner.py b/lib/ansible/runner.py new file mode 100755 index 0000000000..2517e51455 --- /dev/null +++ b/lib/ansible/runner.py @@ -0,0 +1,198 @@ +# Copyright (c) 2012 Michael DeHaan +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +# ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +# CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import fnmatch +import multiprocessing +import os +import json +import traceback + +# non-core +import paramiko + +import constants as C + +def _executor_hook(x): + ''' callback used by multiprocessing pool ''' + (runner, host) = x + return runner._executor(host) + +class Runner(object): + + def __init__(self, + host_list=C.DEFAULT_HOST_LIST, + module_path=C.DEFAULT_MODULE_PATH, + module_name=C.DEFAULT_MODULE_NAME, + module_args=C.DEFAULT_MODULE_ARGS, + forks=C.DEFAULT_FORKS, + timeout=C.DEFAULT_TIMEOUT, + pattern=C.DEFAULT_PATTERN, + remote_user=C.DEFAULT_REMOTE_USER, + remote_pass=C.DEFAULT_REMOTE_PASS, + verbose=False): + + + ''' + Constructor. + ''' + + self.host_list = self._parse_hosts(host_list) + self.module_path = module_path + self.module_name = module_name + self.forks = forks + self.pattern = pattern + self.module_args = module_args + self.timeout = timeout + self.verbose = verbose + self.remote_user = remote_user + self.remote_pass = remote_pass + + def _parse_hosts(self, host_list): + ''' parse the host inventory file if not sent as an array ''' + if type(host_list) != list: + host_list = os.path.expanduser(host_list) + return file(host_list).read().split("\n") + return host_list + + + def _matches(self, host_name): + ''' returns if a hostname is matched by the pattern ''' + if host_name == '': + return False + if fnmatch.fnmatch(host_name, self.pattern): + return True + return False + + def _connect(self, host): + ''' + obtains a paramiko connection to the host. + on success, returns (True, connection) + on failure, returns (False, traceback str) + ''' + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + ssh.connect(host, username=self.remote_user, allow_agent=True, + look_for_keys=True, password=self.remote_pass) + return [ True, ssh ] + except: + return [ False, traceback.format_exc() ] + + def _executor(self, host): + ''' + callback executed in parallel for each host. + returns (hostname, connected_ok, extra) + where extra is the result of a successful connect + or a traceback string + ''' + # TODO: try/catch around JSON handling + + ok, conn = self._connect(host) + if not ok: + return [ host, False, conn ] + + if self.module_name != "copy": + # transfer a module, set it executable, and run it + outpath = self._copy_module(conn) + self._exec_command(conn, "chmod +x %s" % outpath) + cmd = self._command(outpath) + result = self._exec_command(conn, cmd) + self._exec_command(conn, "rm -f %s" % outpath) + conn.close() + return [ host, True, json.loads(result) ] + else: + # SFTP file copy module is not really a module + ftp = conn.open_sftp() + ftp.put(self.module_args[0], self.module_args[1]) + ftp.close() + conn.close() + return [ host, True, 1 ] + + + def _command(self, outpath): + ''' form up a command string ''' + cmd = "%s %s" % (outpath, " ".join(self.module_args)) + return cmd + + def _exec_command(self, conn, cmd): + ''' execute a command over SSH ''' + stdin, stdout, stderr = conn.exec_command(cmd) + results = "\n".join(stdout.readlines()) + return results + + def _get_tmp_path(self, conn, file_name): + output = self._exec_command(conn, "mktemp /tmp/%s.XXXXXX" % file_name) + return output.split("\n")[0] + + def _copy_module(self, conn): + ''' transfer a module over SFTP ''' + in_path = os.path.expanduser( + os.path.join(self.module_path, self.module_name) + ) + out_path = self._get_tmp_path(conn, "ansible_%s" % self.module_name) + + sftp = conn.open_sftp() + sftp.put(in_path, out_path) + sftp.close() + return out_path + + def run(self): + ''' xfer & run module on all matched hosts ''' + + # find hosts that match the pattern + hosts = [ h for h in self.host_list if self._matches(h) ] + + # attack pool of hosts in N forks + pool = multiprocessing.Pool(self.forks) + hosts = [ (self,x) for x in hosts ] + results = pool.map(_executor_hook, hosts) + + # sort hosts by ones we successfully contacted + # and ones we did not + results2 = { + "contacted" : {}, + "dark" : {} + } + for x in results: + (host, is_ok, result) = x + if not is_ok: + results2["dark"][host] = result + else: + results2["contacted"][host] = result + + return results2 + + +if __name__ == '__main__': + + # test code... + + r = Runner( + host_list = DEFAULT_HOST_LIST, + module_name='ping', + module_args='', + pattern='*', + forks=3 + ) + print r.run() + + + diff --git a/library/command b/library/command index 53a8d6ffff..780e50dd6d 100755 --- a/library/command +++ b/library/command @@ -1,6 +1,9 @@ #!/usr/bin/python -import json +try: + import json +except ImportError: + import simplejson as json import subprocess import sys import datetime @@ -8,7 +11,7 @@ import datetime args = sys.argv[1:] startd = datetime.datetime.now() -cmd = subprocess.Popen(args, shell=True, +cmd = subprocess.Popen(args, shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = cmd.communicate() diff --git a/library/ohai b/library/ohai new file mode 100644 index 0000000000..7aefd06501 --- /dev/null +++ b/library/ohai @@ -0,0 +1,16 @@ +#!/usr/bin/python + +# requires 'ohai' to be installed + +try: + import json +except ImportError: + import simplejson as json + +import subprocess + +cmd = subprocess.Popen("/usr/bin/ohai", stdout=subprocess.PIPE, stderr=subprocess.PIPE) +out, err = cmd.communicate() + +# try to cleanup the JSON, for some reason facter --json doesn't need this hack +print json.dumps(json.loads(out)) diff --git a/library/ping b/library/ping index fe0b394825..e40be68a2f 100644 --- a/library/ping +++ b/library/ping @@ -1,5 +1,8 @@ #!/usr/bin/python -import json +try: + import json +except ImportError: + import simplejson as json print json.dumps(1) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..0749f73a8f --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python + +from distutils.core import setup + +setup(name='ansible', + version='1.0', + description='Minimal SSH command and control', + author='Michael DeHaan', + author_email='michael.dehaan@gmail.com', + url='http://github.com/mpdehaan/ansible/', + license='MIT', + package_dir = { 'ansible' : 'lib/ansible' }, + packages=[ + 'ansible', + ], + data_files=[ + ('/usr/share/ansible', [ + 'library/ping', + 'library/command', + 'library/facter', + 'library/copy', + ]), + ('man/man1', [ + 'docs/man/man1/ansible.1' + ]) + ], + scripts=[ + 'bin/ansible', + ] +)