diff options
Diffstat (limited to 'tools/mock-meta.py')
-rwxr-xr-x | tools/mock-meta.py | 444 |
1 files changed, 444 insertions, 0 deletions
diff --git a/tools/mock-meta.py b/tools/mock-meta.py new file mode 100755 index 00000000..4548e4ae --- /dev/null +++ b/tools/mock-meta.py @@ -0,0 +1,444 @@ +#!/usr/bin/python + +# Provides a somewhat random, somewhat compat, somewhat useful mock version of +# +# http://docs.amazonwebservices.com/AWSEC2/2007-08-29/DeveloperGuide/AESDG-chapter-instancedata.html + +""" +To use this to mimic the EC2 metadata service entirely, run it like: + # Where 'eth0' is *some* interface. + sudo ifconfig eth0:0 169.254.169.254 netmask 255.255.255.255 + + sudo ./mock-meta -a 169.254.169.254 -p 80 + +Then: + wget -q http://169.254.169.254/latest/meta-data/instance-id -O -; echo + curl --silent http://169.254.169.254/latest/meta-data/instance-id ; echo + ec2metadata --instance-id +""" + +import functools +import httplib +import json +import logging +import os +import random +import string +import sys +import yaml + +from optparse import OptionParser + +from BaseHTTPServer import (HTTPServer, BaseHTTPRequestHandler) + +log = logging.getLogger('meta-server') + +EC2_VERSIONS = [ + '1.0', + '2007-01-19', + '2007-03-01', + '2007-08-29', + '2007-10-10', + '2007-12-15', + '2008-02-01', + '2008-09-01', + '2009-04-04', +] + +BLOCK_DEVS = [ + 'ami', + 'ephemeral0', + 'root', +] + +DEV_PREFIX = 'v' # This seems to vary alot depending on images... +DEV_MAPPINGS = { + 'ephemeral0': '%sda2' % (DEV_PREFIX), + 'root': '/dev/%sda1' % (DEV_PREFIX), + 'ami': '%sda1' % (DEV_PREFIX), + 'swap': '%sda3' % (DEV_PREFIX), +} + +META_CAPABILITIES = [ + 'aki-id', + 'ami-id', + 'ami-launch-index', + 'ami-manifest-path', + 'ari-id', + 'block-device-mapping/', + 'hostname', + 'instance-action', + 'instance-id', + 'instance-type', + 'local-hostname', + 'local-ipv4', + 'placement/', + 'product-codes', + 'public-hostname', + 'public-ipv4', + 'public-keys/', + 'reservation-id', + 'security-groups' +] + +PUB_KEYS = { + 'brickies': [ + ('ssh-rsa ' + 'AAAAB3NzaC1yc2EAAAABIwAAAQEA3I7VUf2l5gSn5uavROsc5HRDpZdQueUq5ozemNSj8T' + '7enqKHOEaFoU2VoPgGEWC9RyzSQVeyD6s7APMcE82EtmW4skVEgEGSbDc1pvxzxtchBj78' + 'hJP6Cf5TCMFSXw+Fz5rF1dR23QDbN1mkHs7adr8GW4kSWqU7Q7NDwfIrJJtO7Hi42GyXtv' + 'EONHbiRPOe8stqUly7MvUoN+5kfjBM8Qqpfl2+FNhTYWpMfYdPUnE7u536WqzFmsaqJctz' + '3gBxH9Ex7dFtrxR4qiqEr9Qtlu3xGn7Bw07/+i1D+ey3ONkZLN+LQ714cgj8fRS4Hj29SC' + 'mXp5Kt5/82cD/VN3NtHw== brickies'), + '', + ], +} + +INSTANCE_TYPES = [ + 'm1.large', + 'm1.medium', + 'm1.small', + 'm1.xlarge', +] + +AVAILABILITY_ZONES = [ + "us-east-1a", + "us-east-1b", + "us-east-1c", + "us-east-1d", + 'eu-west-1a', + 'eu-west-1b', + 'us-west-1', +] + +PLACEMENT_CAPABILITIES = { + 'availability-zone': AVAILABILITY_ZONES, +} + +NOT_IMPL_RESPONSE = json.dumps({}) + + +class WebException(Exception): + def __init__(self, code, msg): + Exception.__init__(self, msg) + self.code = code + + +def yamlify(data): + formatted = yaml.dump(data, + line_break="\n", + indent=4, + explicit_start=True, + explicit_end=True, + default_flow_style=False) + return formatted + + +def format_text(text): + if not len(text): + return "<<" + lines = text.splitlines() + nlines = [] + for line in lines: + nlines.append("<< %s" % line) + return "\n".join(nlines) + + +def traverse(keys, mp): + result = dict(mp) + for k in keys: + try: + result = result.get(k) + except (AttributeError, TypeError): + result = None + break + return result + + +ID_CHARS = [c for c in (string.ascii_uppercase + string.digits)] +def id_generator(size=6, lower=False): + txt = ''.join(random.choice(ID_CHARS) for x in range(size)) + if lower: + return txt.lower() + else: + return txt + + +def get_ssh_keys(): + keys = {} + keys.update(PUB_KEYS) + + # Nice helper to add in the 'running' users key (if they have one) + key_pth = os.path.expanduser('~/.ssh/id_rsa.pub') + if not os.path.isfile(key_pth): + key_pth = os.path.expanduser('~/.ssh/id_dsa.pub') + + if os.path.isfile(key_pth): + with open(key_pth, 'rb') as fh: + contents = fh.read() + keys[os.getlogin()] = [contents, ''] + + return keys + + +class MetaDataHandler(object): + + def __init__(self, opts): + self.opts = opts + self.instances = {} + + def get_data(self, params, who, **kwargs): + if not params: + # Show the root level capabilities when + # no params are passed... + caps = sorted(META_CAPABILITIES) + return "\n".join(caps) + action = params[0] + action = action.lower() + if action == 'instance-id': + return 'i-%s' % (id_generator(lower=True)) + elif action == 'ami-launch-index': + return "%s" % random.choice([0, 1, 2, 3]) + elif action == 'aki-id': + return 'aki-%s' % (id_generator(lower=True)) + elif action == 'ami-id': + return 'ami-%s' % (id_generator(lower=True)) + elif action == 'ari-id': + return 'ari-%s' % (id_generator(lower=True)) + elif action == 'block-device-mapping': + nparams = params[1:] + if not nparams: + return "\n".join(BLOCK_DEVS) + else: + subvalue = traverse(nparams, DEV_MAPPINGS) + if not subvalue: + return "\n".join(sorted(list(DEV_MAPPINGS.keys()))) + else: + return str(subvalue) + elif action in ['hostname', 'local-hostname', 'public-hostname']: + # Just echo back there own hostname that they called in on.. + return "%s" % (who) + elif action == 'instance-type': + return random.choice(INSTANCE_TYPES) + elif action == 'ami-manifest-path': + return 'my-amis/spamd-image.manifest.xml' + elif action == 'security-groups': + return 'default' + elif action in ['local-ipv4', 'public-ipv4']: + # Just echo back there own ip that they called in on... + return "%s" % (kwargs.get('client_ip', '10.0.0.1')) + elif action == 'reservation-id': + return "r-%s" % (id_generator(lower=True)) + elif action == 'product-codes': + return "%s" % (id_generator(size=8)) + elif action == 'public-keys': + nparams = params[1:] + # This is a weird kludge, why amazon why!!! + # public-keys is messed up, a list of /latest/meta-data/public-keys/ + # shows something like: '0=brickies' + # but a GET to /latest/meta-data/public-keys/0=brickies will fail + # you have to know to get '/latest/meta-data/public-keys/0', then + # from there you get a 'openssh-key', which you can get. + # this hunk of code just re-works the object for that. + avail_keys = get_ssh_keys() + key_ids = sorted(list(avail_keys.keys())) + if nparams: + mybe_key = nparams[0] + try: + key_id = int(mybe_key) + key_name = key_ids[key_id] + except: + raise WebException(httplib.BAD_REQUEST, "Unknown key id %r" % mybe_key) + # Extract the possible sub-params + result = traverse(nparams[1:], { + "openssh-key": "\n".join(avail_keys[key_name]), + }) + if isinstance(result, (dict)): + # TODO: This might not be right?? + result = "\n".join(sorted(result.keys())) + if not result: + result = '' + return result + else: + contents = [] + for (i, key_id) in enumerate(key_ids): + contents.append("%s=%s" % (i, key_id)) + return "\n".join(contents) + elif action == 'placement': + nparams = params[1:] + if not nparams: + pcaps = sorted(PLACEMENT_CAPABILITIES.keys()) + return "\n".join(pcaps) + else: + pentry = nparams[0].strip().lower() + if pentry == 'availability-zone': + zones = PLACEMENT_CAPABILITIES[pentry] + return "%s" % random.choice(zones) + else: + return "%s" % (PLACEMENT_CAPABILITIES.get(pentry, '')) + else: + log.warn(("Did not implement action %s, " + "returning empty response: %r"), + action, NOT_IMPL_RESPONSE) + return NOT_IMPL_RESPONSE + + +class UserDataHandler(object): + + def __init__(self, opts): + self.opts = opts + + def _get_user_blob(self, **kwargs): + blob = None + if self.opts['user_data_file'] is not None: + blob = self.opts['user_data_file'] + if not blob: + blob_mp = { + 'hostname': kwargs.get('who', 'localhost'), + } + lines = [ + "#cloud-config", + yamlify(blob_mp), + ] + blob = "\n".join(lines) + return blob.strip() + + def get_data(self, params, who, **kwargs): + if not params: + return self._get_user_blob(who=who) + return NOT_IMPL_RESPONSE + + +# Seem to need to use globals since can't pass +# data into the request handlers instances... +# Puke! +meta_fetcher = None +user_fetcher = None + + +class Ec2Handler(BaseHTTPRequestHandler): + + def _get_versions(self): + versions = ['latest'] + EC2_VERSIONS + versions = sorted(versions) + return "\n".join(versions) + + def log_message(self, format, *args): + msg = "%s - %s" % (self.address_string(), format % (args)) + log.info(msg) + + def _find_method(self, path): + # Puke! (globals) + global meta_fetcher + global user_fetcher + func_mapping = { + 'user-data': user_fetcher.get_data, + 'meta-data': meta_fetcher.get_data, + } + segments = [piece for piece in path.split('/') if len(piece)] + log.info("Received segments %s", segments) + if not segments: + return self._get_versions + date = segments[0].strip().lower() + if date not in self._get_versions(): + raise WebException(httplib.BAD_REQUEST, "Unknown version format %r" % date) + if len(segments) < 2: + raise WebException(httplib.BAD_REQUEST, "No action provided") + look_name = segments[1].lower() + if look_name not in func_mapping: + raise WebException(httplib.BAD_REQUEST, "Unknown requested data %r" % look_name) + base_func = func_mapping[look_name] + who = self.address_string() + ip_from = self.client_address[0] + if who == ip_from: + # Nothing resolved, so just use 'localhost' + who = 'localhost' + kwargs = { + 'params': list(segments[2:]), + 'who': who, + 'client_ip': ip_from, + } + return functools.partial(base_func, **kwargs) + + def _do_response(self): + who = self.client_address + log.info("Got a call from %s for path %s", who, self.path) + try: + func = self._find_method(self.path) + data = func() + if not data: + data = '' + self.send_response(httplib.OK) + self.send_header("Content-Type", "binary/octet-stream") + self.send_header("Content-Length", len(data)) + log.info("Sending data (len=%s):\n%s", len(data), format_text(data)) + self.end_headers() + self.wfile.write(data) + except RuntimeError as e: + log.exception("Error somewhere in the server.") + self.send_error(httplib.INTERNAL_SERVER_ERROR, message=str(e)) + except WebException as e: + code = e.code + log.exception(str(e)) + self.send_error(code, message=str(e)) + + def do_GET(self): + self._do_response() + + def do_POST(self): + self._do_response() + + +def setup_logging(log_level, format='%(levelname)s: @%(name)s : %(message)s'): + root_logger = logging.getLogger() + console_logger = logging.StreamHandler(sys.stdout) + console_logger.setFormatter(logging.Formatter(format)) + root_logger.addHandler(console_logger) + root_logger.setLevel(log_level) + + +def extract_opts(): + parser = OptionParser() + parser.add_option("-p", "--port", dest="port", action="store", type=int, default=80, + help="port from which to serve traffic (default: %default)", metavar="PORT") + parser.add_option("-a", "--addr", dest="address", action="store", type=str, default='0.0.0.0', + help="address from which to serve traffic (default: %default)", metavar="ADDRESS") + parser.add_option("-f", '--user-data-file', dest='user_data_file', action='store', + help="user data filename to serve back to incoming requests", metavar='FILE') + (options, args) = parser.parse_args() + out = dict() + out['extra'] = args + out['port'] = options.port + out['user_data_file'] = None + out['address'] = options.address + if options.user_data_file: + if not os.path.isfile(options.user_data_file): + parser.error("Option -f specified a non-existent file") + with open(options.user_data_file, 'rb') as fh: + out['user_data_file'] = fh.read() + return out + + +def setup_fetchers(opts): + global meta_fetcher + global user_fetcher + meta_fetcher = MetaDataHandler(opts) + user_fetcher = UserDataHandler(opts) + + +def run_server(): + # Using global here since it doesn't seem like we + # can pass opts into a request handler constructor... + opts = extract_opts() + setup_logging(logging.DEBUG) + setup_fetchers(opts) + log.info("CLI opts: %s", opts) + server_address = (opts['address'], opts['port']) + server = HTTPServer(server_address, Ec2Handler) + sa = server.socket.getsockname() + log.info("Serving ec2 metadata on %s using port %s ...", sa[0], sa[1]) + server.serve_forever() + + +if __name__ == '__main__': + run_server() |