library/fireball
69612ba1
 #!/usr/bin/python
 # -*- coding: utf-8 -*-
 
 # (c) 2012, Michael DeHaan <michael.dehaan@gmail.com>
 #
 # This file is part of Ansible
 #
 # Ansible 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.
 #
 # Ansible 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 Ansible.  If not, see <http://www.gnu.org/licenses/>.
 
8a4df98f
 DOCUMENTATION = '''
 ---
 module: fireball
f2dc815e
 short_description: Enable fireball mode on remote node
8a4df98f
 description:
f897f19f
      - This modules launches an ephemeral I(fireball) ZeroMQ message bus daemon on the remote node which
caf003c8
        Ansible can use to communicate with nodes at high speed.
f897f19f
      - The daemon listens on a configurable port for a configurable amount of time.
      - Starting a new fireball as a given user terminates any existing user fireballs.
      - Fireball mode is AES encrypted
8a4df98f
 version_added: "0.9"
 options:
   port:
     description:
       - TCP port for ZeroMQ
     required: false
     default: 5099
     aliases: []
   minutes:
     description:
f2dc815e
       - The I(fireball) listener daemon is started on nodes and will stay around for
f897f19f
         this number of minutes before turning itself off.
8a4df98f
     required: false
     default: 30
f2dc815e
 # WARNING: very careful when moving space around, below
8a4df98f
 examples:
f2dc815e
    - code: |
            - hosts: devservers
                  gather_facts: false
                  connection: ssh
                  sudo: yes
                  tasks:
caf003c8
                      - action: fireball
57545946
            - hosts: devservers
f2dc815e
                  connection: fireball
                  tasks:
feab57e2
                      - command: /usr/bin/anything
f897f19f
      description: "This example playbook has two plays: the first launches I(fireball) mode on all hosts via SSH, and the second actually starts using I(fireball) node for subsequent management over the fireball interface"
8a4df98f
 notes:
f897f19f
     - See the advanced playbooks chapter for more about using fireball mode.
8a4df98f
 requirements: [ "zmq", "keyczar" ]
 author: Michael DeHaan
 '''
 
69612ba1
 import os
 import sys
 import shutil
 import time
 import base64
 import syslog
 import signal
f897f19f
 import time
69612ba1
 import subprocess
f897f19f
 import signal
69612ba1
 
 syslog.openlog('ansible-%s' % os.path.basename(__file__))
 PIDFILE = os.path.expanduser("~/.fireball.pid")
 
 def log(msg):
c0747b7b
     syslog.syslog(syslog.LOG_NOTICE, msg)
69612ba1
 
 if os.path.exists(PIDFILE):
     try:
         data = int(open(PIDFILE).read())
         try:
             os.kill(data, signal.SIGKILL)
         except OSError:
             pass
     except ValueError:
         pass
     os.unlink(PIDFILE)
 
 HAS_ZMQ = False
 try:
c0747b7b
     import zmq
     HAS_ZMQ = True
69612ba1
 except ImportError:
c0747b7b
     pass
69612ba1
 
 HAS_KEYCZAR = False
 try:
c0747b7b
     from keyczar.keys import AesKey
     HAS_KEYCZAR = True
69612ba1
 except ImportError:
c0747b7b
     pass
69612ba1
 
 # NOTE: this shares a fair amount of code in common with async_wrapper, if async_wrapper were a new module we could move
 # this into utils.module_common and probably should anyway
 
 def daemonize_self(module, password, port, minutes):
     # daemonizing code: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66012
     try:
         pid = os.fork()
         if pid > 0:
             log("exiting pid %s" % pid)
             # exit first parent
             module.exit_json(msg="daemonzed fireball on port %s for %s minutes" % (port, minutes))
     except OSError, e:
         log("fork #1 failed: %d (%s)" % (e.errno, e.strerror))
         sys.exit(1)
 
     # decouple from parent environment
     os.chdir("/")
     os.setsid()
     os.umask(022)
 
     # do second fork
     try:
         pid = os.fork()
         if pid > 0:
             log("daemon pid %s, writing %s" % (pid, PIDFILE))
             pid_file = open(PIDFILE, "w")
             pid_file.write("%s" % pid)
             pid_file.close()
             log("pidfile written")
             sys.exit(0)
     except OSError, e:
         log("fork #2 failed: %d (%s)" % (e.errno, e.strerror))
         sys.exit(1)
 
     dev_null = file('/dev/null','rw')
     os.dup2(dev_null.fileno(), sys.stdin.fileno())
     os.dup2(dev_null.fileno(), sys.stdout.fileno())
     os.dup2(dev_null.fileno(), sys.stderr.fileno())
     log("daemonizing successful (%s,%s)" % (password, port))
 
 def command(data):
     if 'cmd' not in data:
         return dict(failed=True, msg='internal error: cmd is required')
     if 'tmp_path' not in data:
         return dict(failed=True, msg='internal error: tmp_path is required')
 
     log("executing: %s" % data['cmd'])
cf290a00
     p = subprocess.Popen(data['cmd'], shell=True, stdout=subprocess.PIPE, close_fds=True)
69612ba1
     (stdout, stderr) = p.communicate()
     if stdout is None:
         stdout = ''
     if stderr is None:
         stderr = ''
     log("got stdout: %s" % stdout)
 
     return dict(stdout=stdout, stderr=stderr)
caf003c8
 
69612ba1
 def fetch(data):
     if 'in_path' not in data:
096607ee
         return dict(failed=True, msg='internal error: in_path is required')
 
     # FIXME: should probably support chunked file transfer for binary files
     # at some point.  For now, just base64 encodes the file
     # so don't use it to move ISOs, use rsync.
69612ba1
 
     fh = open(data['in_path'])
096607ee
     data =  base64.b64encode(fh.read())
69612ba1
     return dict(data=data)
 
 def put(data):
 
     if 'data' not in data:
         return dict(failed=True, msg='internal error: data is required')
     if 'out_path' not in data:
         return dict(failed=True, msg='internal error: out_path is required')
caf003c8
 
096607ee
     # FIXME: should probably support chunked file transfer for binary files
     # at some point.  For now, just base64 encodes the file
     # so don't use it to move ISOs, use rsync.
69612ba1
 
     fh = open(data['out_path'], 'w')
096607ee
     fh.write(base64.b64decode(data['data']))
69612ba1
     fh.close()
 
     return dict()
 
 def serve(module, password, port, minutes):
 
f897f19f
 
69612ba1
     log("serving")
     context = zmq.Context()
     socket = context.socket(zmq.REP)
     addr = "tcp://*:%s" % port
     log("zmq serving on %s" % addr)
     socket.bind(addr)
 
     # password isn't so much a password but a serialized AesKey object that we xferred over SSH
     # password as a variable in ansible is never logged though, so it serves well
 
     key = AesKey.Read(password)
caf003c8
 
69612ba1
     while True:
 
         data = socket.recv()
f897f19f
 
         try:
             data = key.Decrypt(data)
         except:
             continue
 
69612ba1
         data = json.loads(data)
 
         mode = data['mode']
         response = {}
 
         if mode == 'command':
             response = command(data)
         elif mode == 'put':
             response = put(data)
         elif mode == 'fetch':
             response = fetch(data)
 
         data2 = json.dumps(response)
         data2 = key.Encrypt(data2)
         socket.send(data2)
 
 def daemonize(module, password, port, minutes):
 
     try:
         daemonize_self(module, password, port, minutes)
f897f19f
 
         def catcher(signum, _):
             module.exit_json(msg='timer expired')
 
         signal.signal(signal.SIGALRM, catcher)
         signal.setitimer(signal.ITIMER_REAL, 60 * minutes)
 
 
69612ba1
         serve(module, password, port, minutes)
     except Exception, e:
         log("exception caught, exiting fireball mode: %s" % e)
         sys.exit(0)
 
 def main():
 
     module = AnsibleModule(
         argument_spec = dict(
             port=dict(required=False, default=5099),
             password=dict(required=True),
             minutes=dict(required=False, default=30),
         )
     )
 
     password  = base64.b64decode(module.params['password'])
     port      = module.params['port']
f897f19f
     minutes   = int(module.params['minutes'])
69612ba1
 
     if not HAS_ZMQ:
         module.fail_json(msg="zmq is not installed")
     if not HAS_KEYCZAR:
caf003c8
         module.fail_json(msg="keyczar is not installed")
69612ba1
 
     daemonize(module, password, port, minutes)
caf003c8
 
69612ba1
 
 # this is magic, see lib/ansible/module_common.py
 #<<INCLUDE_ANSIBLE_MODULE_COMMON>>
 main()