changeset 0:0f44ee073173 default tip

fake salt, initial commit
author Jeff Hammel <k0scist@gmail.com>
date Mon, 06 Feb 2017 01:10:22 +0000
parents
children
files README.md kcl/__init__.py kcl/ec2.py kcl/kcl.py kcl/run_cmd.py kcl/ssh.py kcl/sudo_scp.py kcl/versions.py setup.py
diffstat 9 files changed, 929 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README.md	Mon Feb 06 01:10:22 2017 +0000
@@ -0,0 +1,27 @@
+# KCl
+
+fake salt
+
+
+       .-"""""-.
+       |'-----'|
+      |'-.....-'|
+      |         |
+      |   K Cl  |
+      |         |
+      |         |
+      |-.......-|
+      '-.......-'
+
+
+
+When you have salt...use it, of course!  When you don't, there's this:
+https://en.wikipedia.org/wiki/Potassium_chloride , or
+https://en.wikipedia.org/wiki/Salt_substitute .
+
+----
+
+Jeff Hammel
+
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/kcl/__init__.py	Mon Feb 06 01:10:22 2017 +0000
@@ -0,0 +1,2 @@
+#
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/kcl/ec2.py	Mon Feb 06 01:10:22 2017 +0000
@@ -0,0 +1,526 @@
+"""
+code for interfacing with EC2 instances:
+
+    curl http://169.254.169.254/latest/meta-data/
+"""
+
+# imports
+import argparse
+import boto.utils
+import hashlib
+import hmac
+import json
+import os
+import requests
+import sys
+import urllib
+import urlparse
+import ConfigParser
+from collections import OrderedDict
+from datetime import datetime
+
+
+class FilesystemCredentials(object):
+    """
+    Read credentials from the filesystem. See:
+    - http://boto.cloudhackers.com/en/latest/boto_config_tut.html
+    - https://blogs.aws.amazon.com/security/post/Tx3D6U6WSFGOK2H/A-New-and-Standardized-Way-to-Manage-Credentials-in-the-AWS-SDKs
+
+    In Unix/Linux systems, on startup, the boto library looks
+    for configuration files in the following locations
+    and in the following order:
+
+    /etc/boto.cfg - for site-wide settings that all users on this machine will use
+    (if profile is given) ~/.aws/credentials - for credentials shared between SDKs
+    (if profile is given) ~/.boto - for user-specific settings
+    ~/.aws/credentials - for credentials shared between SDKs
+    ~/.boto - for user-specific settings
+
+    """
+
+    def read_aws_credentials(self, fp, section='default'):
+        parser = ConfigParser.RawConfigParser()
+        parser.readfp(fp)
+        if section in parser.sections():
+            key = 'aws_access_key_id'
+            if parser.has_option(section, key):
+                secret = 'aws_secret_access_key'
+                if parser.has_option(section, secret):
+                    return (parser.get(section, key),
+                            parser.get(section, secret))
+
+    def __init__(self):
+        self.resolution = OrderedDict()
+        home = os.environ['HOME']
+        if home:
+            self.resolution[os.path.join(home, '.aws', 'credentials')] = self.read_aws_credentials
+
+    def __call__(self):
+        """
+        return credentials....*if* available
+        """
+
+        for path, method in self.resolution.items():
+            if os.path.isfile(path):
+                with open(path, 'r') as f:
+                    credentials = method(f)
+                    if credentials:
+                        return credentials
+
+
+class EC2Metadata(object):
+    """EC2 instance metadata interface"""
+
+    def __init__(self, **kwargs):
+        self._kwargs = kwargs
+
+    def __call__(self):
+        """http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html"""
+        return boto.utils.get_instance_metadata(**self._kwargs)
+
+    def security_credentials(self):
+        """
+        return IAM credentials for an instance, if possible
+
+        See:
+        http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials
+        """
+        # TODO: nested dict -> object notation mapping ;
+        # note also this is actually a `LazyLoader` value,
+        # not actually a dict
+
+        return self()['iam']['security-credentials']
+
+    def credentials(self, role=None):
+        """return active credentials"""
+
+        security_credentials = self.security_credentials()
+        if not security_credentials:
+            raise AssertionError("No security credentials available")
+
+        roles=', '.join(sorted(security_credentials.keys()))
+        if role is None:
+            if len(security_credentials) > 1:
+                raise AssertionError("No role given and multiple roles found for instance: {roles}".format(roles))
+            role = security_credentials.keys()[0]
+
+        if role not in security_credentials:
+            raise KeyError("Role {role} not in available IAM roles: {roles}".format(role=role, roles=roles))
+
+        return security_credentials[role]
+
+class AWSCredentials(FilesystemCredentials):
+    """
+    try to read credentials from the filesystem
+    then from ec2 metadata
+    """
+
+    def __call__(self):
+
+        # return filesystem crednetials, if any
+        credentials = FilesystemCredentials.__call__(self)
+        if credentials:
+            return credentials
+
+        # otherwise try to return credentials from metadata
+        metadata = EC2Metadata()
+        try:
+            ec2_credentials = metadata.credentials()
+        except AssertionError:
+            return
+        keys = ('AccessKeyId', 'SecretAccessKey')
+        if set(keys).issubset(ec2_credentials.keys()):
+            return [ec2_credentials[key]
+                    for key in keys]
+
+class SignedRequest(object):
+    """
+    Signed request using Signature Version 4
+
+    http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
+    """
+
+    # signing information:
+    algorithm = 'AWS4-HMAC-SHA256'
+    termination_string = 'aws4_request'
+    authorization_header = "{algorithm} Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}"
+
+    # date format:
+    date_format = '%Y%m%d'
+    time_format = '%H%M%S'
+
+    ### date methods
+
+    @classmethod
+    def datetime_format(cls):
+        return '{date_format}T{time_format}Z'.format(date_format=cls.date_format,
+                                                     time_format=cls.time_format)
+
+    @classmethod
+    def datetime(cls, _datetime=None):
+        """
+        returns formatted datetime string as appropriate
+        for `x-amz-date` header
+        """
+
+        if _datetime is None:
+            _datetime = datetime.utcnow()
+        return _datetime.strftime(cls.datetime_format())
+
+    ### constructor
+
+    def __init__(self, access_key, secret_key, region, service):
+        self.access_key = access_key
+        self.secret_key = secret_key
+        self.region = region
+        self.service = service
+
+    ### hashing methods
+
+    def hash(self, message):
+        """hash a `message`"""
+        # from e.g. http://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html#sig-v4-examples-get-auth-header
+        return hashlib.sha256(message).hexdigest()
+
+    def sign(self, key, msg):
+        """
+        See:
+        http://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-python
+        """
+        return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
+
+    def signature_key(self, date_stamp):
+        parts = [date_stamp,
+                 self.region,
+                 self.service,
+                 self.termination_string]
+        signed = ('AWS4' + self.secret_key).encode('utf-8')
+        while parts:
+            signed = self.sign(signed, parts.pop(0))
+        return signed
+
+    ###
+
+    def credential_scope(self, date_string):
+        """
+        a string that includes the date, the region you are targeting,
+        the service you are requesting, and a termination string
+        ("aws4_request") in lowercase characters.
+        """
+
+        parts = [date_string,
+                 self.region,
+                 self.service,
+                 self.termination_string]
+        # TODO: "The region and service name strings must be UTF-8 encoded."
+        return '/'.join(parts)
+
+    ### method for canonical components
+
+    @classmethod
+    def canonical_uri(cls, path):
+        """
+        The canonical URI is the URI-encoded version
+        of the absolute path component of the URI
+        """
+
+        if path == '/':
+            path = None
+        if path:
+            canonical_uri = urllib.quote(path, safe='')
+        else:
+            # If the absolute path is empty, use a forward slash (/)
+            canonical_uri = '/'
+        return canonical_uri
+
+    @classmethod
+    def canonical_query(cls, query_string):
+        """
+        returns the canonical query string
+        """
+        # TODO: currently this does not use `cls`
+
+        # split into parameter names + values
+        query = urlparse.parse_qs(query_string)
+
+        # make this into a more appropriate data structure for processing
+        keyvalues = sum([[[key, value] for value in values]
+                         for key, values in query.items()], [])
+
+        # a. URI-encode each parameter name and value
+        def encode(string):
+            return urllib.quote(string, safe='/')
+        encoded = [[encode(string) for string in pair]
+                   for pair in keyvalues]
+
+        # b. Sort the encoded parameter names by character code in ascending order (ASCII order)
+        encoded.sort()
+
+        # c. Build the canonical query string by starting with the first parameter name in the sorted list.
+        # d. For each parameter, append the URI-encoded parameter name, followed by the character '=' (ASCII code 61), followed by the URI-encoded parameter value.
+        # e. Append the character '&' (ASCII code 38) after each parameter value, except for the last value in the list.
+        retval = '&'.join(['{name}={value}'.format(name=name,
+                                                   value=value)
+                           for name, value in encoded])
+        return retval
+
+    @classmethod
+    def signed_headers(cls, headers):
+        """
+        return a list of signed headers
+        """
+        names = [name.lower() for name in headers.keys()]
+        names.sort()
+        return ';'.join(names)
+
+    @classmethod
+    def canonical_headers(cls, headers):
+        """
+        return canonical headers:
+        Construct each header according to the following rules:
+        * Append the lowercase header name followed by a colon.
+        * ...
+        See:
+        - http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
+        - http://docs.python-requests.org/en/latest/user/quickstart/#custom-headers
+        """
+
+        canonical_headers = []
+        for key, value in headers.items():
+
+            # convert header name to lowercase
+            key = key.lower()
+
+            # trim excess white space from the header values
+            value = value.strip()
+
+            # convert sequential spaces in the value to a single space.
+            # However, do not remove extra spaces from any values
+            # that are inside quotation marks.
+            quote = '"'
+            if not (value and value[0] == quote and value[-1] == quote):
+                value = ' '.join(value.split())
+
+            canonical_headers.append((key, value))
+
+        # check for duplicate headers
+        names = [name for name, value in canonical_headers]
+        if len(set(names)) != len(names):
+            raise AssertionError("You have duplicate header names :( While AWS supports this use-case, this library doesn't yet")
+        # Append a comma-separated list of values for that header.
+        # If there are duplicate headers, the values are comma-separated.
+        # Do not sort the values in headers that have multiple values.
+
+        # Build the canonical headers list by sorting the headers by lowercase character code
+        canonical_headers.sort(key=lambda x: x[0])
+
+        # return canonical headers
+        return canonical_headers
+
+    def __call__(self, url, method='GET', headers=None, session=None):
+        """create a signed request and return the response"""
+
+        if session:
+            raise NotImplementedError('TODO')
+        else:
+            session = requests.Session()
+            signed_request = self.signed_request(url,
+                                                 method=method,
+                                                 headers=headers)
+            response = session.send(signed_request)
+        return response
+
+    def canonical_request(self, url, headers, payload='', method='GET'):
+        """
+        Return canonical request
+
+        url: "http://k0s.org/home/documents and settings"
+        GET
+        %2Fhome%2Fdocuments%20and%20settings
+        ...
+        """
+
+        # parse the url
+        parsed = urlparse.urlsplit(url)
+
+        # get canonical URI
+        canonical_uri = self.canonical_uri(parsed.path)
+
+        # construct the canonical query string
+        canonical_query = self.canonical_query(parsed.query)
+
+        # get the canonical headers
+        canonical_headers = self.canonical_headers(headers)
+
+        # format the canonical headers
+        canonical_header_string = '\n'.join(['{0}:{1}'.format(*header)
+                                             for header in canonical_headers]) + '\n'
+
+        # get the signed headers
+        signed_headers = self.signed_headers(headers)
+
+        # get the hashed payload
+        hashed_payload = self.hash(payload)
+
+        # join the parts to make the request:
+        # http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
+        #       CanonicalRequest =
+        # HTTPRequestMethod + '\n' +
+        # CanonicalURI + '\n' +
+        # CanonicalQueryString + '\n' +
+        # CanonicalHeaders + '\n' +
+        # SignedHeaders + '\n' +
+        # HexEncode(Hash(RequestPayload))
+        parts = [method,
+                 canonical_uri,
+                 canonical_query,
+                 canonical_header_string,
+                 signed_headers,
+                 hashed_payload]
+        canonical_request = '\n'.join(parts)
+        return canonical_request
+
+    def signed_request(self, url, method='GET', headers=None):
+        """
+        prepare a request:
+        http://docs.python-requests.org/en/latest/user/advanced/#prepared-requests
+        """
+
+        # parse the URL, since we like doing that so much
+        parsed = urlparse.urlsplit(url)
+
+        # setup the headers
+        if headers is None:
+            headers = {}
+        headers = OrderedDict(headers).copy()
+        mapping = dict([(key.lower(), key) for key in headers])
+        # XXX this is..."fun"
+        # maybe we should just x-form everything to lowercase now?
+        # ensure host header is set
+        if 'host' not in mapping:
+            headers['Host'] = parsed.netloc
+        # add the `x-amz-date` in terms of now:
+        # http://docs.aws.amazon.com/AmazonS3/latest/API/RESTCommonRequestHeaders.html
+        if 'x-amz-date' not in mapping:
+            headers['x-amz-date'] = self.datetime()
+
+        # create a PreparedRequest
+        req = requests.Request(method, url, headers)
+        prepped = req.prepare()
+
+        # return a signed version
+        return self.sign_request(prepped)
+
+    def sign_request(self, request):
+        """
+        sign a request;
+        http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
+        """
+
+        # ensure that we have a `x-amz-date` header in the request
+        request_date = request.headers.get('x-amz-date')
+        # Food for thought:  perhaps here is a more appropriate place
+        # to add the headers?  probably.  Likely.
+        if request_date is None:
+            raise NotImplementedError('TODO')
+
+        # get the canonical request
+        canonical_request = self.canonical_request(method=request.method,
+                                                   url=request.url,
+                                                   headers=request.headers)
+
+        # Create a digest (hash) of the canonical request
+        # with the same algorithm that you used to hash the payload.
+        hashed_request = self.hash(canonical_request)
+
+        # Create the string to sign:
+        # http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
+        # 1. Start with the algorithm designation
+        parts = [self.algorithm]
+        # 2. Append the request date value
+        parts.append(request_date)  # XXX we could validate the format
+        # 3. Append the credential scope value
+        date_string = request_date.split('T')[0]  # XXX could do better
+        credential_scope = self.credential_scope(date_string)
+        parts.append(credential_scope)
+        # 4. Append the hash of the canonical request
+        parts.append(hashed_request)
+        string_to_sign = '\n'.join(parts)
+
+        # Calculate the AWS Signature Version 4
+        # http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
+        # 1. Derive your signing key.
+        signing_key = self.signature_key(date_string)
+        # 2. Calculate the signature
+        signature = hmac.new(signing_key,
+                             string_to_sign.encode('utf-8'),
+                             hashlib.sha256).hexdigest()
+
+        # Add the signing information to the request
+        # http://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html
+        authorization = self.authorization_header.format(algorithm=self.algorithm,
+                                                         access_key=self.access_key,
+                                                         credential_scope=credential_scope,
+                                                         signed_headers=self.signed_headers(request.headers),
+                                                         signature=signature)
+        request.headers['Authorization'] = authorization
+
+        # return the prepared requests
+        return request
+
+
+def main(args=sys.argv[1:]):
+    """CLI"""
+
+    # parse command line
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--credentials', '--print-credentials',
+                        dest='print_credentials',
+                        action='store_true', default=False,
+                        help="print default credentials for instance")
+    parser.add_argument('--url', dest='url',
+                        help="hit this URL with a signed HTTP GET request")
+    parser.add_argument('--service', dest='service', default='es',
+                        help="AWS service to use")
+    parser.add_argument('--region', dest='region', default='us-west-1',
+                        help="AWS region")
+    # TODO: `service` and `region` come from
+    # http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
+    # We need to be able to derive the region from the environment.
+    # It would be very nice to derive the service from, say, the host
+    options = parser.parse_args(args)
+
+    if options.url:
+
+        # get credentials
+        credentials = AWSCredentials()()
+        if not credentials:
+            parser.error("No AWS credentials found")
+        aws_key, aws_secret = credentials
+
+        # make a signed request to the URL and exit
+        request = SignedRequest(aws_key,
+                                aws_secret,
+                                region=options.region,
+                                service=options.service)
+        response = request(options.url, method='GET')
+        print ('-'*10)
+        print (response.text)
+        response.raise_for_status()
+        return
+
+    # metadata interface
+    metadata = EC2Metadata()
+
+    # get desired data
+    if options.print_credentials:
+        data = metadata.credentials()
+    else:
+        data = metadata()
+
+    # display data
+    print (json.dumps(data, indent=2, sort_keys=True))
+
+
+if __name__ == '__main__':
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/kcl/kcl.py	Mon Feb 06 01:10:22 2017 +0000
@@ -0,0 +1,60 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+fake salt
+"""
+
+# imports
+import argparse
+import json
+import ssh
+import sys
+
+class KCl(object):
+    """fake salt's interface to real salt"""
+
+    def __init__(self, salt_master):
+        self.salt_master = salt_master
+        self.ssh = ssh.Ssh(self.salt_master)
+
+    def __call__(self, target, module, *args):
+        command = self.command(target, module, *args)
+        code, output = self.ssh.sudo(command)
+
+        return code, json.loads(output)
+
+    def command(self, target, module, *args):
+        retval = "salt --output=json --static '{target}' {module}".format(target=target, module=module)
+        for arg in args:
+            retval += " '{arg}'".format(arg=arg)
+        return retval
+
+    def ping(self, target='*'):
+        return self(target, 'test.ping')
+
+    def run(self, command, target='*'):
+        return self(target, 'cmd.run', command)
+
+
+def main(args=sys.argv[1:]):
+    """CLI"""
+
+    # parse command line
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('salt_master')
+    options = parser.parse_args(args)
+
+    # instantiate KCl
+    kcl = KCl(options.salt_master)
+
+    # ping, just for fun
+    code, pong = kcl.ping()
+
+    # output
+    print (json.dumps(pong, indent=2, sort_keys=True))
+
+    sys.exit(code)
+
+if __name__ == '__main__':
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/kcl/run_cmd.py	Mon Feb 06 01:10:22 2017 +0000
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+run a command on some things
+"""
+
+# imports
+import argparse
+import os
+import subprocess
+import sys
+import time
+
+# module globals
+__all__ = ['print_command', 'call', 'RunCmdParser']
+string = (str, unicode)
+
+def print_command(command, **kwargs):
+    if not isinstance(command, string):
+        command = subprocess.list2cmdline(command)
+    print (command)
+
+def call(command, **kwargs):
+
+    process = subprocess.Popen(command, **kwargs)
+    stdout, stderr = process.communicate()
+    if process.returncode:
+        cmdline = subprocess.list2cmdline(command)
+        print cmdline
+        raise subprocess.CalledProcessError(process.returncode,
+                                            cmdline)
+
+def ssh_call(host, command, ssh='ssh'):
+    call([ssh, host, command], stderr=subprocess.PIPE)
+
+
+class RunCmdParser(argparse.ArgumentParser):
+    """CLI option parser"""
+
+    def __init__(self, **kwargs):
+        kwargs.setdefault('formatter_class', argparse.RawTextHelpFormatter)
+        kwargs.setdefault('description', __doc__)
+        argparse.ArgumentParser.__init__(self, **kwargs)
+        self.add_argument('-H', '--host', dest='hosts', nargs='+',
+                          help="hosts to run on; or read from stdin if omitted")
+        self.add_argument("command",
+                          help="command to run")
+        self.options = None
+        self._hosts = None
+
+    def parse_args(self, *args, **kw):
+        options = argparse.ArgumentParser.parse_args(self, *args, **kw)
+        self.validate(options)
+        self.options = options
+        return options
+
+    def validate(self, options):
+        """validate options"""
+
+    def hosts(self):
+        if self._hosts is None:
+            assert self.options is not None
+            self._hosts = self.options.hosts or sys.stdin.read().strip().split()
+        return self._hosts
+
+    def command(self):
+        return self.options.command
+
+def main(args=sys.argv[1:]):
+    """CLI"""
+
+    # parse command line options
+    parser = RunCmdParser()
+    options = parser.parse_args(args)
+
+    # get command to run
+    command = parser.command()
+
+    # call the command on all hosts
+    for host in parser.hosts():
+        ssh_call(host, command)
+
+
+if __name__ == '__main__':
+    main()
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/kcl/ssh.py	Mon Feb 06 01:10:22 2017 +0000
@@ -0,0 +1,62 @@
+"""
+crappy python ssh wrapper
+"""
+
+# imports
+import subprocess
+
+string = (str, unicode)
+
+
+def call(command, check=True, echo=True, **kwargs):
+
+    cmd_str = command if isinstance(command, string) else subprocess.list2cmdline(command)
+    kwargs['shell'] = isinstance(command, string)
+    kwargs['stdout'] = subprocess.PIPE
+    kwargs['stderr'] = subprocess.PIPE
+    if echo:
+        print (cmd_str)
+    process = subprocess.Popen(command, **kwargs)
+    stdout, stderr = process.communicate()
+    if check and process.returncode:
+        raise subprocess.CalledProcessError(process.returncode, command, stdout)
+    return process.returncode, stdout, stderr
+
+
+class Ssh(object):
+    """primative form of ssh session using subprocess"""
+
+    def __init__(self, host, ssh='ssh', scp='scp', verbose=False):
+        self.host = host
+        self._ssh = ssh
+        self._scp = scp
+        self.verbose=verbose
+
+    def command(self, command):
+
+        # See:
+        # http://unix.stackexchange.com/questions/122616/why-do-i-need-a-tty-to-run-sudo-if-i-can-sudo-without-a-password
+        # http://unix.stackexchange.com/a/122618
+        return [self._ssh, '-t', self.host, command]
+
+    def sudo_command(self, command):
+        return self.command('sudo ' + command)
+
+    def call(self, command):
+        returncode, output, _ = call(self.command(command),
+                                     echo=self.verbose)
+        return (returncode, output)
+
+    def sudo(self, command):
+        returncode, output, _ = call(self.sudo_command(command),
+                                     echo=self.verbose)
+        return (returncode, output)
+
+    def scp(self, src, dest):
+        """scp a file to the given host"""
+
+        command = [self._scp,
+                   src,
+                   '{host}:{dest}'.format(host=self.host, dest=dest)]
+        returncode, output, _ = call(command)
+        return (returncode, output)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/kcl/sudo_scp.py	Mon Feb 06 01:10:22 2017 +0000
@@ -0,0 +1,67 @@
+#!/usr/bin/env python
+
+"""
+scp with sudo and magic
+"""
+
+# imports
+import argparse
+import os
+import subprocess
+import sys
+
+from run_cmd import call
+from run_cmd import print_command
+
+
+def main(args=sys.argv[1:]):
+    """CLI"""
+
+    # parse command line
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('src')
+    parser.add_argument('dest')
+    parser.add_argument('host', nargs='*',
+                        help="hosts to copy to, or stdin if omitted")
+    parser.add_argument('-u', '--user', dest='user',
+                        help="what user should own this file?")
+    parser.add_argument('--dry-run', dest='dry_run',
+                        action='store_true', default=False,
+                        help="don't actually do anything; just print what would be done")
+    options = parser.parse_args(args)
+
+    # sanity
+    if not os.path.exists(options.src):
+        parser.error("'{}' does not exist".format(options.src))
+
+    if options.dry_run:
+        call = print_command
+    else:
+        call = globals()['call']
+
+    basename = os.path.basename(options.src)
+
+    TMPDIR = '/tmp'
+
+    tmpdest = os.path.join(TMPDIR, basename)
+
+    hosts = options.host or sys.stdin.read().strip().split()
+
+
+    # copy to all hosts
+    for host in hosts:
+        call(['scp', options.src, '{host}:{dest}'.format(host=host, dest=tmpdest)],
+             stderr=subprocess.PIPE)
+        call(['ssh', host, "sudo mv {tmpdest} {dest}".format(tmpdest=tmpdest, dest=options.dest)],
+         stderr=subprocess.PIPE)
+        if options.user:
+            call(['ssh', host, "sudo chown {user} {dest}".format(user=options.user, dest=options.dest)],
+                 stderr=subprocess.PIPE)
+        call(['ssh', host, "sudo chmod a+x {dest}".format(dest=options.dest)],
+             stderr=subprocess.PIPE)
+        call(['ssh', host, "sudo chmod a+r {dest}".format(dest=options.dest)],
+             stderr=subprocess.PIPE)
+
+
+if __name__ == '__main__':
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/kcl/versions.py	Mon Feb 06 01:10:22 2017 +0000
@@ -0,0 +1,51 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+compare package versions across nodes:
+
+$ sudo rpm -q --qf "%{VERSION}" python-requests
+2.6.0
+"""
+
+
+# imports
+import argparse
+import json
+import kcl
+import sys
+
+
+def main(args=sys.argv[1:]):
+    """CLI"""
+
+    # parse command line
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('salt_master')
+    parser.add_argument('package')
+    parser.add_argument('--group', dest='group',
+                        action='store_true', default=False,
+                        help="group hosts by versions")
+    options = parser.parse_args(args)
+
+    # instantiate API
+    salt = kcl.KCl(options.salt_master)
+
+    # get versions
+    command = 'rpm -q --qf "%{VERSION}" ' + options.package
+    code, versions = salt.run(command)
+
+    # output
+    if options.group:
+        # group host by package versions
+        group = {}
+        for host, version in versions.items():
+            group.setdefault(version, []).append(host)
+        print (json.dumps(group, indent=2, sort_keys=True))
+    else:
+        print (json.dumps(versions, indent=2, sort_keys=True))
+
+    sys.exit(code)
+
+if __name__ == '__main__':
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/setup.py	Mon Feb 06 01:10:22 2017 +0000
@@ -0,0 +1,46 @@
+"""
+setup packaging script for KCl
+"""
+
+import os
+
+version = "0.0"
+dependencies = []
+
+# allow use of setuptools/distribute or distutils
+kw = {}
+try:
+    from setuptools import setup
+    kw['entry_points'] = """
+    [console_scripts]
+    kcl = kcl.kcl:main
+    package-versions = kcl.versions:main
+    run_cmd = kcl.run_cmd:main
+    sudo_scp = kcl.sudo_scp:main
+"""
+    kw['install_requires'] = dependencies
+except ImportError:
+    from distutils.core import setup
+    kw['requires'] = dependencies
+
+try:
+    here = os.path.dirname(os.path.abspath(__file__))
+    description = file(os.path.join(here, 'README.txt')).read()
+except IOError:
+    description = ''
+
+
+setup(name='KCl',
+      version=version,
+      description="fake salt",
+      long_description=description,
+      classifiers=[], # Get strings from http://www.python.org/pypi?%3Aaction=list_classifiers
+      author='Jeff Hammel',
+      author_email='k0scist@gmail.com',
+      url='',
+      license='',
+      packages=['kcl'],
+      include_package_data=True,
+      zip_safe=False,
+      **kw
+      )