diff --git a/.gitignore b/.gitignore index d1eadd9..d6c499d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,5 @@ target/ # PyCharm .idea/ + +.vagrant/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..619765d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,60 @@ +language: python + +python: + - "2.7" + - "3.4" + - "3.5" + +cache: + directories: + - $HOME/.cache/pip + +# So that we get a docker container +sudo: false + +## Customize dependencies +install: + - pip install -U pip + - pip install -U wheel + - pip install -U twine + - pip install -U -e .[testing] + +## Customize test commands +before_script: + - pep8 stackdio/ && echo 'Finished PEP-8 Check Cleanly' || echo 'Finished PEP-8 Check With Errors' + - pylint stackdio/ && echo 'Finished Pylint Check Cleanly' || echo 'Finished Pylint Check With Errors' + +# Nothing to do here yet +script: + - date + +# Only build artifacts on success +after_success: + - coveralls + - python setup.py sdist + - python setup.py bdist_wheel + +deploy: + - provider: releases + api_key: + secure: WDRJ+QYPfAMuH8sEFPTTEHabaEtfvLWvHiXi69NA3lruIlKr0Id5gpF/Bqr5VfHiz9jdHuBRdVLgYRYVXAVsRkw13N1YlHgR4j4oi61fMugwDTC820Jnf8EDpuvXys8TPiPRh7Xe2XTGc4HMO0moGz6gp9gH4OAsxGgLPNLmiDA= + file: + - dist/stackdio-${TRAVIS_TAG}.tar.gz + - dist/stackdio-${TRAVIS_TAG}-py2.py3-none-any.whl + skip_cleanup: true + on: + tags: true + repo: stackdio/stackdio-python-client + python: "2.7" + + # Upload to pypi. Do this instead of the pypi provider so that we + # ensure the exact same artifact is uploaded to github and pypi. + # The pypi provider will re-build the 2 artifacts, which is not ideal. + # This requires setting TWINE_USERNAME and TWINE_PASSWORD in travis. + - provider: script + script: twine upload dist/stackdio-${TRAVIS_TAG}.tar.gz dist/stackdio-${TRAVIS_TAG}-py2.py3-none-any.whl + skip_cleanup: true + on: + tags: true + repo: stackdio/stackdio-python-client + python: "2.7" diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..bb3ec5f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.md diff --git a/README.md b/README.md deleted file mode 100644 index 03bfceb..0000000 --- a/README.md +++ /dev/null @@ -1,4 +0,0 @@ -stackdio-python-client -====================== - -The canonical Python client for the stackd.io API diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..e475fa0 --- /dev/null +++ b/README.rst @@ -0,0 +1,149 @@ +stackdio-python-client +====================== + +|Travis CI| + +The canonical Python client and cli for the stackd.io API + + +Overview +-------- + +This is a small set of tools for internal use of stackd.io. After cloning +this repo, you should be able to quickly get up and running with your own +stacks. + +Advanced usage like creating custom blueprints or writing your own formulas is +beyond the scope of this. + +Installation +------------ + +We recommend using virtualenv via `virtualenvwrapper`_ to install this in a +virtualenv. If you consider yourself a knowledgeable Pythonista, feel free to +install this however you'd like, but this document will assume that you are +using virtualenvwrapper. See the full `virtualenvwrapper`_ docs for details, +but in short you can install it on most systems like: + +.. code:: bash + + pip install virtualenvwrapper + +Once you've got it, installing this tool goes something like: + +.. code:: bash + + mkvirtualenv stackdio-client + + pip install stackdio + +You'll see a few things scrolling by, but should be set after this. To use +this later, you'll need to re-activate the virtualenv like: + +.. code:: bash + + workon stackdio-client + +Whenever it's activated, ``stackdio-cli`` should be on your path. + +First Use +--------- + +The first time that you fire up ``stackdio-cli``, you'll need to run the +``configure`` command. This will prompt you for your LDAP username and +password, and store them securely in your OS keychain for later use. It will +import some standard formula, and create a few commonly used blueprints. + +.. code:: bash + + $ stackdio-cli + None @ None + > configure + # YOU WILL BE WALKED THROUGH A SIMPLE SET OF QUESTIONS + +Stack Operations +---------------- + +All of the following assume that you have run ``initial_setup`` successfully. To +launch the cli, simply type: + +.. code:: bash + + $ stackdio-cli + +You can run ``help`` at any point to see available commands. For details on a +specific command you can run ``help COMMAND``, e.g. ``help stacks``. The rest of +these commands assume you have the cli running. + +Launching Stacks +~~~~~~~~~~~~~~~~ +Stacks are launched from blueprints. To launch the 3 node HBase stack that's +included with this you do: + +.. code:: bash + + > stacks launch cdh450-ipa-3 MYSTACKNAME + + +.. note:: + + To avoid DNS namespace collisions, the stack name needs to be unique. + An easy way to ensure this is to include your name in the stack name. + +Deleting Stacks +~~~~~~~~~~~~~~~ + +When you are done with a stack you can delete it. This is destructive and +cannot be recovered from, so think carefully before deleting your stack! + +.. code:: bash + + > stacks delete STACK_NAME + +Alternatively you can ``terminate`` a stack which will terminate all instances, +but leave the stack definition in place. + +Provisioning Stacks +~~~~~~~~~~~~~~~~~~~ + +Occassionally something will go wrong when launching your stack, e.g. network +connections may flake out causing some package installations to fail. If this +happens you can manually provision your stack, causing everything to be brought +back up to date: + +.. code:: bash + + > stacks provision STACK_NAME + +Stack Info +~~~~~~~~~~ + +Once you have launched a stack, you can then monitor the status of it like: + +.. code:: bash + + > stacks history STACK_NAME + +This displays the top level information for a stack. You can supply additional +arguments to pull back additional info about a stack. For example, to get a +list of FQDNs (aka hostnames) for a stack: + +.. code:: bash + + > stacks hostnames STACK_NAME + +There are various logs available that you can access with the ``stacks logs`` +command. + +What's Next? +------------ + +For anything not covered by this tool, you'll need to use the stackdio-server web UI or +API directly. For more information on that, check out http://docs.stackd.io. + + +.. |Travis CI| image:: https://travis-ci.org/stackdio/stackdio-python-client.svg?branch=master + :target: https://travis-ci.org/stackdio/stackdio-python-client + :alt: Build Status + +.. _virtualenvwrapper: https://pypi.python.org/pypi/virtualenvwrapper diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..95d6aff --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,13 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! +VAGRANTFILE_API_VERSION = "2" + +Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| + config.vm.box = "precise64" + + config.vm.provision "shell", path: "vbox_setup.sh" + + config.vm.synced_folder "~/Workspace/pi/stackdio-blueprints", "/home/vagrant/.stackdio-blueprints", create: true +end diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..58e20e3 --- /dev/null +++ b/pylintrc @@ -0,0 +1,162 @@ +[MASTER] + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +init-hook=" + exec 'aW1wb3J0IG9zLCBzeXMKCmlmICdWSVJUVUFMX0VOVicgaW4gb3MuZW52aXJvbjoKCiAgICB2ZV9k \ + aXIgPSBvcy5lbnZpcm9uWydWSVJUVUFMX0VOViddCiAgICB2ZV9kaXIgaW4gc3lzLnBhdGggb3Ig \ + c3lzLnBhdGguaW5zZXJ0KDAsIHZlX2RpcikKICAgIGFjdGl2YXRlX3RoaXMgPSBvcy5wYXRoLmpv \ + aW4ob3MucGF0aC5qb2luKHZlX2RpciwgJ2JpbicpLCAnYWN0aXZhdGVfdGhpcy5weScpCgogICAg \ + IyBGaXggZm9yIHdpbmRvd3MKICAgIGlmIG5vdCBvcy5wYXRoLmV4aXN0cyhhY3RpdmF0ZV90aGlz \ + KToKICAgICAgICBhY3RpdmF0ZV90aGlzID0gb3MucGF0aC5qb2luKG9zLnBhdGguam9pbih2ZV9k \ + aXIsICdTY3JpcHRzJyksICdhY3RpdmF0ZV90aGlzLnB5JykKCiAgICBleGVjZmlsZShhY3RpdmF0 \ + ZV90aGlzLCBkaWN0KF9fZmlsZV9fPWFjdGl2YXRlX3RoaXMpKQo='.decode('base64')" +# Pickle collected data for later comparisons. +persistent=no + +#ignore= + +#load-plugins= + +[MESSAGES CONTROL] + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time. See also the "--disable" option for examples. +#enable=E0001,E0100,E0101,E0102,E0103,E0104,E0105,E0106,E0107,E0108,E0202,E0203,E0211,E0213,E0221,E0222,E0601,E0602,E0603,E0604,E0701,E0702,E0710,E0711,E1001,E1002,E1003,E1102,E1111,E1120,E1121,E1122,E1123,E1124,E1200,E1201,E1205,E1206,E1300,E1301,E1302,E1303,E1304,E1305,E1306,E1310,C0112,C0121,C0202,C0321,C0322,C0323,C0324,W0101,W0102,W0104,W0105,W0106,W0107,W0108,W0109,W0120,W0141,W0150,W0199,W0211,W0221,W0222,W0223,W0231,W0232,W0233,W0301,W0311,W0312,W0331,W0332,W0333,W0401,W0402,W0403,W0406,W0410,W0601,W0602,W0604,W0614,W0623,W0701,W0702,W0703,W0710,W0711,W1001,W1111,W1201,W1300,W1301,W1401,W1402,F0202 + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=R, + I0011, + I0013, + E1101, + E1103, + C0102, + C0103, + C0111, + C0203, + C0204, + C0302, + C0330, + C1001, + W0110, + W0122, + W0142, + W0201, + W0212, + W0231, + W0232, + W0404, + W0511, + W0603, + W0612, + W0613, + W0621, + W0622, + W0631, + W0704, + F0220, + E8121, + E8122, + E8123, + E8124, + E8125, + E8126, + E8127, + E8128, + E8129, + E8131, + E8265 + +# Disabled: +# R* [refactoring suggestions & reports] +# I0011 (locally-disabling) +# I0013 (file-ignored) +# E1101 (no-member) [pylint isn't smart enough] +# E1103 (maybe-no-member) +# C0102 (blacklisted-name) [because it activates C0103 too] +# C0103 (invalid-name) +# C0111 (missing-docstring) +# C0203 (bad-mcs-method-argument) +# C0204 (bad-mcs-classmethod-argument) +# C0302 (too-many-lines) +# C0330 (bad-continuation) +# C1001 (old-style-class) [Way to many Meta inner classes that don't need it] +# W0110 (deprecated-lambda) +# W0122 (exec-statement) +# W0142 (star-args) +# W0201 (attribute-defined-outside-init) [done in several places in the codebase] +# W0212 (protected-access) +# W0231 (super-init-not-called) [this doesn't play well with our mixins] +# W0232 (no-init) [classes missing __init__ method - several Meta classes that don't need it] +# W0404 (reimported) [done intentionally for legit reasons] +# W0511 (fixme) [several outstanding instances currently in the codebase] +# W0603 (global-statement) +# W0612 (unused-variable) [unused return values] +# W0613 (unused-argument) +# W0621 (redefined-outer-name) +# W0622 (redefined-builtin) [many parameter names shadow builtins] +# W0631 (undefined-loop-variable) [~3 instances, seem to be okay] +# W0704 (pointless-except) [misnomer; "ignores the exception" rather than "pointless"] +# F0220 (unresolved-interface) +# +# E812* All PEP8 E12* +# E8265 PEP8 E265 - block comment should start with "# " + +[REPORTS] + +# Tells whether to display a full report or only the messages +reports=no + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html +# output-format=parseable +msg-template='{path}:{line}: [{msg_id}({symbol}), {obj}] {msg}' + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". +files-output=no + + +[BASIC] + +# List of builtins function names that should not be used, separated by a comma +bad-functions=apply,input + + +[FORMAT] +# Maximum number of characters on a single line. +max-line-length=120 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +#additional-builtins= + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,TERMIOS,Bastion,rexec + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=BaseException \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8557dda..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -simplejson -requests>=2.4.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f43dfb2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,10 @@ +[bdist_wheel] +# This flag says that the code is written to work on both Python 2 and Python +# 3. If at all possible, it is good practice to do this. If you cannot, you +# will need to generate wheels for each Python version that you support. +universal=1 + +[pep8] + +max-line-length=100 +ignore=E501,E12 \ No newline at end of file diff --git a/setup.py b/setup.py index 22795ca..f8869f0 100644 --- a/setup.py +++ b/setup.py @@ -15,52 +15,54 @@ # limitations under the License. # -import os import sys from setuptools import setup, find_packages -from pip.req import parse_requirements -from pip.download import PipSession def test_python_version(): - if float("%d.%d" % sys.version_info[:2]) < 2.6: - print('Your Python version {0}.{1}.{2} is not supported.'.format( - *sys.version_info[:3])) - print('stackdio requires Python 2.6 or newer.') + major = sys.version_info[0] + minor = sys.version_info[1] + micro = sys.version_info[2] + if (major, minor) < (2, 7): + err_msg = ('Your Python version {0}.{1}.{2} is not supported.\n' + 'stackdio-server requires Python 2.7 or newer.\n'.format(major, minor, micro)) + sys.stderr.write(err_msg) sys.exit(1) - -def load_pip_requirements(fp): - reqs, deps = [], [] - for r in parse_requirements(fp, session=PipSession()): - if r.url is not None: - deps.append(str(r.url)) - reqs.append(str(r.req)) - return reqs, deps - # Set version __version__ = '0.0.0' # Explicit default -execfile("stackdio/client/version.py") +with open('stackdio/client/version.py') as f: + exec(f.read()) SHORT_DESCRIPTION = ('A cloud deployment, automation, and orchestration ' 'platform for everyone.') -LONG_DESCRIPTION = SHORT_DESCRIPTION -# If we have a README.md file, use its contents as the long description -if os.path.isfile('README.md'): - with open('README.md') as f: - LONG_DESCRIPTION = f.read() +# Use the README.md as the long description +with open('README.rst') as f: + LONG_DESCRIPTION = f.read() + +requirements = [ + 'Jinja2>=2.7', + 'PyYAML>=3.10', + 'click>=6.0,<7.0', + 'click-shell>=0.4', + 'colorama>=0.3,<0.4', + 'keyring==3.7', + 'requests>=2.4.0', + 'simplejson==3.4.0', +] +testing_requirements = [ + 'coveralls', + 'pep8', + 'pylint<=1.2.0', +] -if __name__ == "__main__": - # build our list of requirements and dependency links based on our - # requirements.txt file - reqs, deps = load_pip_requirements('requirements.txt') +if __name__ == '__main__': + test_python_version() - # Call the setup method from setuptools that does all the heavy lifting - # of packaging stackdio setup( name='stackdio', version=__version__, @@ -73,19 +75,30 @@ def load_pip_requirements(fp): include_package_data=True, packages=find_packages(), zip_safe=False, - install_requires=reqs, - dependency_links=deps, + install_requires=requirements, + dependency_links=[], + extras_require={ + 'testing': testing_requirements, + }, + entry_points={ + 'console_scripts': [ + 'stackdio-cli=stackdio.cli:main', + 'blueprint-generator=stackdio.cli.blueprints:main', + ], + }, classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Environment :: Web Environment', - 'Framework :: Django', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: Apache Software License', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'Topic :: System :: Clustering', 'Topic :: System :: Distributed Computing', ] diff --git a/stackdio/cli/__init__.py b/stackdio/cli/__init__.py new file mode 100644 index 0000000..84744b1 --- /dev/null +++ b/stackdio/cli/__init__.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python + +import os + +import click +import click_shell + +from stackdio.cli.mixins import blueprints, formulas, stacks +from stackdio.cli.utils import pass_client +from stackdio.client import StackdioClient +from stackdio.client.config import CFG_DIR +from stackdio.client.version import __version__ + + +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) + + +@click_shell.shell(context_settings=CONTEXT_SETTINGS, prompt='stackdio > ', + intro='stackdio-cli, v{0}'.format(__version__)) +@click.version_option(__version__, '-v', '--version') +@click.option('-c', '--config-dir', help='The config directory to use.', + type=click.Path(dir_okay=True, file_okay=False), default=CFG_DIR, + envvar='STACKDIO_CONFIG_DIR') +@click.pass_context +def stackdio(ctx, config_dir): + # Create a client instance + client = StackdioClient(cfg_file=os.path.join(config_dir, 'client.cfg')) + + # Set this hist file + ctx.command.hist_file = os.path.join(config_dir, 'cli-history') + + # Throw an error if we're not configured already + if ctx.invoked_subcommand not in ('configure', None) and not client.usable(): + raise click.UsageError('It looks like you haven\'t used this CLI before. Please run ' + '`stackdio-cli configure`') + + # Put the client in the obj so other commands can pick it up + ctx.obj = client + + +@stackdio.command(name='configure') +@pass_client +def configure(client): + """ + Configure the client + """ + client.config.prompt_for_config() + + +@stackdio.command(name='server-version') +@pass_client +def server_version(client): + """ + Print the version of the server + """ + click.echo('stackdio-server, version {0}'.format(client.get_version())) + + +# Add all our other commands +stackdio.add_command(blueprints.blueprints) +stackdio.add_command(stacks.stacks) +stackdio.add_command(formulas.formulas) + + +def main(): + # Just run our CLI tool + stackdio() + + +if __name__ == '__main__': + main() diff --git a/stackdio/cli/blueprints/__init__.py b/stackdio/cli/blueprints/__init__.py new file mode 100644 index 0000000..5cfa4af --- /dev/null +++ b/stackdio/cli/blueprints/__init__.py @@ -0,0 +1,43 @@ +from __future__ import print_function + +import os +import json +import sys + +import click + +from stackdio.cli.blueprints.generator import BlueprintException, BlueprintGenerator + + +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.argument('template_file') +@click.argument('var_files', nargs=-1, type=click.File('r')) +@click.option('-p', '--prompt', is_flag=True, default=False, + help='Prompt user for missing variables') +@click.option('-d', '--debug', is_flag=True, default=False, + help='Print out json string before parsing the json') +def main(template_file, var_files, prompt, debug): + + try: + # Throw all output to stderr + gen = BlueprintGenerator([os.path.curdir, + os.path.join(os.path.curdir, 'templates'), + os.path.dirname(os.path.abspath(template_file))], + output_stream=sys.stderr) + + # Generate the blueprint + blueprint = gen.generate(template_file, + var_files=var_files, + prompt=prompt, + debug=debug) + except BlueprintException: + raise click.Abort('Error processing blueprint') + + click.echo(json.dumps(blueprint, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/stackdio/cli/blueprints/generator.py b/stackdio/cli/blueprints/generator.py new file mode 100644 index 0000000..b59c935 --- /dev/null +++ b/stackdio/cli/blueprints/generator.py @@ -0,0 +1,306 @@ +from __future__ import print_function, unicode_literals + +import json +import os +import sys + +import click +import yaml +from jinja2 import Environment, FileSystemLoader, StrictUndefined, meta +from jinja2.exceptions import TemplateNotFound, TemplateSyntaxError, UndefinedError +from jinja2.filters import do_replace, evalcontextfilter +from jinja2.nodes import Assign, Block, Const, If, Not + + +class BlueprintException(Exception): + pass + + +class BlueprintGenerator(object): + """ + Blueprint generator class. Uses Jinja2 to generate blueprints from JSON + templates, with inheritance. + """ + + def __init__(self, templates_path, output_stream=sys.stdout): + """ + Need to create the jinja2 environment + + :param templates_path: A list of directories in which to look for templates + :return: + """ + self.settings = { + 'template_dir': os.path.expanduser('~/.stackdio-blueprints/templates') + } + + self.sentinel = object() + + self.out_stream = output_stream + + templates_path.append(self.settings['template_dir']) + + self.env = Environment( + loader=FileSystemLoader(templates_path), + undefined=StrictUndefined) + + # Add a filter for json - then we can put lists, etc in our templates + self.env.filters['json'] = lambda value: json.dumps(value) # pylint: disable=unnecessary-lambda + + # Add a filter for newline escaping. Essentially just a wrapper around the + # replace('\n', '\\n') filter + # Need the evalcontext filter decorator b/c do_replace uses it too and needs a ctx arg + self.env.filters['longstring'] = evalcontextfilter( + lambda ctx, s: do_replace(ctx, s, '\n', '\\n') + ) + + def error_exit(self, message, newlines=1): + """ + Prints an error message in red and exits with an error code + :param message: The error message + :param newlines: the number of newlines to print at the end + :return: None + """ + click.secho(message, file=self.out_stream, nl=False, fg='red') + click.echo('\n' * newlines, nl=False) + raise BlueprintException() + + def warning(self, message, newlines=1): + """ + Prints a warning message in brown + :param message: The warning message to print + :param newlines: The number of newlines to print at the end + :return: None + """ + click.secho(message, file=self.out_stream, nl=False, fg='yellow') + click.echo('\n' * newlines, nl=False) + + def prompt(self, message): + """ + Prompts the user for an input. Prints the prompt to the configured output stream in green + :param message: the prompt message + :return: the value the user inputted + """ + click.secho(message, file=self.out_stream, nl=False, fg='green') + raw = sys.stdin.readline().strip() + + # This should work nicely - if yaml can't parse it properly, then it should be fine to just + # return the raw string + try: + yaml_parsed = yaml.safe_load(raw) + + # safe_load returns None if the input is the empty string, so we want to put it back + # to the empty string + if yaml_parsed is None: + return '' + else: + return yaml_parsed + except Exception: + # yaml couldn't parse it + return raw + + def find_set_vars(self, ast): + """ + We need this due to the fact that jinja DOES NOT allow you to override + variables in base templates from derived templates. This pulls out the + assignments in derived templates so that we can set them in the context + later. + + :param ast: the jinja2 parsed abstract syntax tree + :return: the dict of all set variables + :rtype: dict + """ + ret = {} + + # TODO maybe use a set here. I had originally used a dict because set tags in derived + # templates would not get picked up by base templates. The Jinja2 documentation even says + # that this is the case. So I was going to pick out the values from the set statements and + # pass them in under the template context so they would get picked up by base templates. + # + # As soon as I took the time to write this whole function to do the above, I ran the + # generator again, and my set statements in derived templates were getting picked up by the + # base, and I have no clue why. So none of the values in this dict are getting utilized, + # just the keys. + # + # EDIT: Turns out we do need the values. When you do a template include, those values are + # not allowed to be set by the template that did the include. + + for tag in ast.body: + if isinstance(tag, Assign): + if isinstance(tag.node, Const): + ret[tag.target.name] = tag.node.value + else: + # This is OK - RHS is an expr instead of a constant. Keep track of these so we + # don't get an error later + ret[tag.target.name] = self.sentinel + + elif isinstance(tag, If): + # This is a simple naive check to see if we care about the variable being set. + # Basically, if there is a variable inside an "if is not undefined" block, + # It is OK if that variable is not set in a derived template or var file since it + # is an optional configuration value + if isinstance(tag.test, Not) and tag.test.node.name == 'undefined': + ret[tag.test.node.node.name] = None + + elif isinstance(tag, Block): + # Found a block, could be if statements within the block. Recursive call + # TODO: not sure if this will cause a bug, since set statements are local to blocks + ret.update(self.find_set_vars(tag)) + + return ret + + def validate(self, template_file): + """ + Find all available and overridden vars in a template. Recursively checks all + super templates. + + :param template_file: The name of the template file + :return: the set and unset variables + :rtype: tuple + """ + # Get all the info for the CURRENT template + # Get the source of the template + template_source = self.env.loader.get_source(self.env, template_file)[0] + # parse it into an abstract syntax tree + ast = self.env.parse(template_source) + + # the UNSET variables in the current template + unset_vars = meta.find_undeclared_variables(ast) + + # the SET variables in the current template + set_vars = self.find_set_vars(ast) + + # validate the super templates + super_templates = meta.find_referenced_templates(ast) + + for template in super_templates: + # Get all the information about the super template recursively + super_unset, super_set = self.validate(template) + + # We do it this way so values in derived templates override those in base templates + super_set.update(set_vars) + set_vars = super_set + + unset_vars = unset_vars.union(super_unset) + + return unset_vars, set_vars + + def generate(self, template_file, var_files=(), variables=None, + prompt=False, debug=False, suppress_warnings=False): + """ + Generate the rendered blueprint and return it as a python dict + + The var_file is loaded first. Anything in the variables dict *WILL OVERRIDE* the value in + the var_vile. + + :param template_file: The relative location of the template. It must be in one of the + directories you specified when creating the Generator object. + :param var_files: The location of the variable file(s) (relative or absolute) + :param variables: A dict of variables to put in the template. + :param prompt: Option to prompt for missing variables + :param debug: Print the output of the template before trying to parse it as JSON + :return: the generated blueprint object + :rtype: dict + """ + try: + # Validate the template + all_vars, set_vars = self.validate(template_file) + + context = {} + for var_file in var_files: + yaml_parsed = yaml.safe_load(var_file) + if yaml_parsed: + context.update(yaml_parsed) + + # Add in the variables + if variables: + context.update(variables) + + # Find the null variables in the var file + null_vars = set() + + for name, value in context.items(): + if value is None: + null_vars.add(name) + context[name] = '' + + # the missing vars should be the subset of all the variables + # with the set of set variables and set of context variables taken + # out + missing_vars = all_vars - set(set_vars) - set(context) + + if missing_vars: + if prompt: + # Prompt for missing vars + for var in sorted(missing_vars): + context[var] = self.prompt('{}: '.format(var)) + else: + # Print an error + error_str = 'Missing variables:\n' + for var in sorted(missing_vars): + error_str += ' {}\n'.format(var) + self.error_exit(error_str, 0) + + # Find the set of optional variables (ones inside of a 'if is not undefined' + # block). They were set to None in the set_vars dict inside the validate method + optional_vars = set() + + for var, val in set_vars.items(): + if val is None: + optional_vars.add(var) + # Need to get rid of this now so it doesn't cause problems later + del set_vars[var] + elif val == self.sentinel: + # These are valid assignments, but we don't need to throw them in to the context + del set_vars[var] + + # If it is set elsewhere, it's not an issue + optional_vars = optional_vars - set(context) + + if null_vars and not suppress_warnings: + warn_str = '\nWARNING: Null variables (replaced with empty string):\n' + for var in null_vars: + warn_str += ' {}\n'.format(var) + self.warning(warn_str, 0) + + # Print a warning if there's unset optional variables + if optional_vars and not suppress_warnings: + warn_str = '\nWARNING: Missing optional variables:\n' + for var in sorted(optional_vars): + warn_str += ' {}\n'.format(var) + self.warning(warn_str, 0) + + # Generate the blueprint + template = self.env.get_template(template_file) + + # Put the set vars into the context + set_vars.update(context) + context = set_vars + + rendered_template = template.render(**context) + + if debug: + click.echo('\n') + click.echo(rendered_template) + click.echo('\n') + + template_extension = template_file.split('.')[-1] + + if template_extension in ('json',): + # Return a dict object rather than a string + return json.loads(rendered_template) + elif template_extension in ('yaml', 'yml'): + return yaml.safe_load(rendered_template) + else: + self.error_exit('Template extension {} is not valid.'.format(template_extension)) + + except TemplateNotFound: + self.error_exit('Your template file {} was not found.'.format(template_file)) + except TemplateSyntaxError as e: + self.error_exit('Invalid template error at line {}:\n{}'.format( + e.lineno, + str(e) + )) + except UndefinedError as e: + self.error_exit('Missing variable: {}'.format(str(e))) + # except ValueError: + # self.error_exit('Invalid JSON. Check your template file.') diff --git a/stackdio/cli/mixins/__init__.py b/stackdio/cli/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stackdio/cli/mixins/blueprints.py b/stackdio/cli/mixins/blueprints.py new file mode 100644 index 0000000..6f18ceb --- /dev/null +++ b/stackdio/cli/mixins/blueprints.py @@ -0,0 +1,246 @@ + +import json +import os + +import click +import yaml + +from stackdio.cli.blueprints.generator import BlueprintGenerator, BlueprintException +from stackdio.cli.utils import print_summary, pass_client + + +class BlueprintNotFound(Exception): + pass + + +@click.group() +def blueprints(): + """ + Perform actions on blueprints + """ + pass + + +@blueprints.command(name='list') +@pass_client +def list_blueprints(client): + """ + List all blueprints + """ + click.echo('Getting blueprints ... ') + print_summary('Blueprint', client.list_blueprints()) + + +def _recurse_dir(dirname, extensions, prefix=''): + for template in os.listdir(dirname): + if os.path.isdir(os.path.join(dirname, template)): + # Recursively look at the subdirectories + _recurse_dir(os.path.join(dirname, template), + extensions, + prefix + template + os.sep) + elif template.split('.')[-1] in extensions and not template.startswith('_'): + click.echo(' {0}'.format(prefix + template)) + + +@blueprints.command(name='list-templates') +@pass_client +def list_templates(client): + """ + List all the blueprint templates + """ + if 'blueprint_dir' not in client.config: + click.echo('Missing blueprint directory config') + return + + try: + blueprint_dir = os.path.expanduser(client.config['blueprint_dir']) + except KeyError: + raise click.UsageError('Missing \'blueprint_dir\' in config. Please run `configure`.') + + click.echo('Template mappings:') + mapping = yaml.safe_load(open(os.path.join(blueprint_dir, 'mappings.yaml'), 'r')) + if mapping: + for blueprint in mapping: + click.echo(' {0}'.format(blueprint)) + + click.echo() + + click.echo('Templates:') + _recurse_dir(os.path.join(blueprint_dir, 'templates'), ['json']) + + click.echo() + + click.echo('Var files:') + _recurse_dir(os.path.join(blueprint_dir, 'var_files'), ['yaml', 'yml']) + + +def _create_single_blueprint(config, template_file, var_files, no_prompt, + extra_vars=None, suppress_warnings=False): + blueprint_dir = os.path.expanduser(config['blueprint_dir']) + + gen = BlueprintGenerator([os.path.join(blueprint_dir, 'templates')]) + + if not os.path.exists(os.path.join(blueprint_dir, 'templates', template_file)): + click.secho('You gave an invalid template', fg='red') + return + + if template_file.startswith('_'): + click.secho('WARNING: Templates beginning with \'_\' are generally not meant to ' + 'be used directly. Please be sure this is really what you want.\n', + fg='magenta') + + final_var_files = [] + + # Build a list with full paths in it instead of relative paths + for var_file in var_files: + var_file = os.path.join(blueprint_dir, 'var_files', var_file) + if os.path.exists(var_file): + final_var_files.append(open(var_file, 'r')) + else: + click.secho('WARNING: Variable file {0} was not found. Ignoring.'.format(var_file), + fg='magenta') + + # Generate the JSON for the blueprint + return gen.generate(template_file, + final_var_files, # Pass in a list + variables=extra_vars, + prompt=no_prompt, + suppress_warnings=suppress_warnings) + + +@blueprints.command(name='create') +@pass_client +@click.option('-m', '--mapping', + help='The entry in the map file to use') +@click.option('-t', '--template', + help='The template file to use') +@click.option('-v', '--var-file', multiple=True, + help='The variable files to use. You may pass in more than one. They ' + 'will be loaded from left to right, so variables in the rightmost ' + 'var files will override those in var files to the left.') +@click.option('-n', '--no-prompt', is_flag=True, default=True, + help='Don\'t prompt for missing variables in the template') +def create_blueprint(client, mapping, template, var_file, no_prompt): + """ + Create a blueprint + """ + if not template and not mapping: + raise click.UsageError('You must specify either a template or a mapping.') + + click.secho('Advanced users only - use the web UI if this isn\'t you!\n', fg='green') + + try: + blueprint_dir = client.config['blueprint_dir'] + except KeyError: + raise click.UsageError('Missing \'blueprint_dir\' in config. Please run `configure`.') + + if mapping: + mappings = yaml.safe_load(open(os.path.join(blueprint_dir, 'mappings.yaml'), 'r')) + if not mappings or mapping not in mappings: + click.secho('You gave an invalid mapping.', fg='red') + return + else: + template = mappings[mapping].get('template') + var_file = mappings[mapping].get('var_files', []) + if not template: + click.secho('Your mapping must specify a template.', fg='red') + return + + bp_json = _create_single_blueprint(client.config, template, var_file, no_prompt) + + if not bp_json: + # There was an error with the blueprint creation, and there should already be an + # error message printed + return + + click.echo('Creating blueprint') + + r = client.create_blueprint(bp_json, raise_for_status=False) + click.echo(json.dumps(r, indent=2)) + + +@blueprints.command(name='create-all') +@pass_client +@click.confirmation_option('-y', '--yes', prompt='Really create all blueprints?') +def create_all_blueprints(client): + """ + Create all the blueprints in the map file + """ + try: + blueprint_dir = os.path.expanduser(client.config['blueprint_dir']) + except KeyError: + raise click.UsageError('Missing \'blueprint_dir\' in config. Please run `configure`.') + mapping = yaml.safe_load(open(os.path.join(blueprint_dir, 'mappings.yaml'), 'r')) + + blueprints = client.list_blueprints() + + blueprint_titles = [blueprint['title'] for blueprint in blueprints] + + for name, vals in mapping.items(): + if name in blueprint_titles: + click.secho('Skipping creation of {0}, it already exists.'.format(name), fg='yellow') + continue + + try: + bp_json = _create_single_blueprint(client.config, vals['template'], + vals['var_files'], False, {'title': name}, + suppress_warnings=True) + client.create_blueprint(bp_json) + click.secho('Created blueprint {0}'.format(name), fg='green') + except BlueprintException: + click.secho('Blueprint {0} NOT created\n'.format(name), fg='magenta') + + +def get_blueprint_id(client, blueprint_title): + found_blueprints = client.list_blueprints(title=blueprint_title) + + if len(found_blueprints) == 0: + raise click.Abort('Blueprint "{0}" does not exist'.format(blueprint_title)) + elif len(found_blueprints) > 1: + raise click.Abort('Multiple blueprints matching "{0}" were found'.format(blueprint_title)) + else: + return found_blueprints[0]['id'] + + +@blueprints.command(name='delete') +@pass_client +@click.argument('title') +def delete_blueprint(client, title): + """ + Delete a blueprint + """ + blueprint_id = get_blueprint_id(client, title) + + click.confirm('Really delete blueprint {0}?'.format(title), abort=True) + + click.echo('Deleting {0}'.format(title)) + client.delete_blueprint(blueprint_id) + + +@blueprints.command(name='delete-all') +@pass_client +@click.confirmation_option('-y', '--yes', prompt='Really delete all blueprints? This is ' + 'completely destructive, and you will never get them back.') +def delete_all_blueprints(client): + """ + Delete all blueprints + """ + for blueprint in client.list_blueprints(): + client.delete_blueprint(blueprint['id']) + click.secho('Deleted blueprint {0}'.format(blueprint['title']), fg='magenta') + + +@blueprints.command(name='create-label') +@pass_client +@click.argument('title') +@click.argument('key') +@click.argument('value') +def create_label(client, title, key, value): + """ + Create a key:value label on a blueprint + """ + blueprint_id = get_blueprint_id(client, title) + + client.add_blueprint_label(blueprint_id, key, value) + + click.echo('Created label on {0}'.format(title)) diff --git a/stackdio/cli/mixins/formulas.py b/stackdio/cli/mixins/formulas.py new file mode 100644 index 0000000..ec4d47c --- /dev/null +++ b/stackdio/cli/mixins/formulas.py @@ -0,0 +1,65 @@ +from __future__ import print_function + +import click + +from stackdio.cli.utils import pass_client, print_summary + + +@click.group() +def formulas(): + """ + Perform actions on formulas + """ + pass + + +@formulas.command(name='list') +@pass_client +def list_formulas(client): + """ + List all formulas + """ + click.echo('Getting formulas ... ') + print_summary('Formula', client.list_formulas()) + + +@formulas.command(name='import') +@pass_client +@click.argument('uri') +@click.option('-u', '--username', type=click.STRING, help='Git username') +@click.option('-p', '--password', type=click.STRING, prompt=True, hide_input=True, + help='Git password') +def import_formula(client, uri, username, password): + """ + Import a formula + """ + if username and not password: + raise click.UsageError('You must provide a password when providing a username') + + click.echo('Importing formula from {0}'.format(uri)) + formula = client.import_formula(uri, git_username=username, git_password=password) + + click.echo('Detail: {0}'.format(formula['status_detail'])) + + +def get_formula_id(client, formula_uri): + found_formulas = client.list_formulas(uri=formula_uri) + + if len(found_formulas) == 0: + raise click.Abort('Formula "{0}" does not exist'.format(formula_uri)) + else: + return found_formulas[0]['id'] + + +@formulas.command(name='delete') +@pass_client +@click.argument('uri') +def delete_formula(client, uri): + """ + Delete a formula + """ + formula_id = get_formula_id(client, uri) + + click.confirm('Really delete formula {0}?'.format(uri), abort=True) + + client.delete_formula(formula_id) diff --git a/stackdio/cli/mixins/stacks.py b/stackdio/cli/mixins/stacks.py new file mode 100644 index 0000000..cea0c30 --- /dev/null +++ b/stackdio/cli/mixins/stacks.py @@ -0,0 +1,294 @@ +from __future__ import print_function + +import click + +from stackdio.cli.mixins.blueprints import get_blueprint_id +from stackdio.cli.utils import pass_client, print_summary, poll_and_wait +from stackdio.client.exceptions import StackException + + +REQUIRE_ACTION_CONFIRMATION = ['terminate'] + + +@click.group() +def stacks(): + """ + Perform actions on stacks + """ + pass + + +@stacks.command(name='list') +@pass_client +def list_stacks(client): + """ + List all stacks + """ + click.echo('Getting stacks ... ') + print_summary('Stack', client.list_stacks()) + + +@stacks.command(name='launch') +@pass_client +@click.argument('blueprint_title') +@click.argument('stack_title') +def launch_stack(client, blueprint_title, stack_title): + """ + Launch a stack from a blueprint + """ + blueprint_id = get_blueprint_id(client, blueprint_title) + + click.echo('Launching stack "{0}" from blueprint "{1}"'.format(stack_title, + blueprint_title)) + + stack_data = { + 'blueprint': blueprint_id, + 'title': stack_title, + 'description': 'Launched from blueprint %s' % (blueprint_title), + 'namespace': stack_title, + } + results = client.create_stack(stack_data) + click.echo('Stack launch results:\n{0}'.format(results)) + + +def get_stack_id(client, stack_title): + found_stacks = client.list_stacks(title=stack_title) + + if len(found_stacks) == 0: + raise click.Abort('Stack "{0}" does not exist'.format(stack_title)) + elif len(found_stacks) > 1: + raise click.Abort('Multiple stacks matching "{0}" were found'.format(stack_title)) + else: + return found_stacks[0]['id'] + + +@stacks.command(name='history') +@pass_client +@click.argument('stack_title') +@click.option('-l', '--length', type=click.INT, default=20, help='The number of entries to show') +def stack_history(client, stack_title, length): + """ + Print recent history for a stack + """ + stack_id = get_stack_id(client, stack_title) + history = client.get_stack_history(stack_id) + for event in history[0:min(length, len(history))]: + click.echo('[{created}] {message}'.format(**event)) + + +@stacks.command(name='hostnames') +@pass_client +@click.argument('stack_title') +def stack_hostnames(client, stack_title): + """ + Print hostnames for a stack + """ + stack_id = get_stack_id(client, stack_title) + hosts = client.get_stack_hosts(stack_id) + + click.echo('Hostnames:') + for host in hosts: + click.echo(' - {0} ({1})'.format(host['fqdn'], host['state'])) + + +@stacks.command(name='delete') +@pass_client +@click.argument('stack_title') +def delete_stack(client, stack_title): + """ + Delete a stack. PERMANENT AND DESTRUCTIVE!!! + """ + stack_id = get_stack_id(client, stack_title) + + click.confirm('Really delete stack {0}?'.format(stack_title), abort=True) + + results = client.delete_stack(stack_id) + click.echo('Delete stack results: \n{0}'.format(results)) + click.secho('Run "stacks history {0}" to monitor status of the deletion'.format(stack_title), + fg='green') + + +@stacks.command(name='action') +@pass_client +@click.argument('stack_title') +@click.argument('action') +def perform_action(client, stack_title, action): + """ + Perform an action on a stack + """ + stack_id = get_stack_id(client, stack_title) + + # Prompt for confirmation if need be + if action in REQUIRE_ACTION_CONFIRMATION: + click.confirm('Really {0} stack {1}?'.format(action, stack_title), abort=True) + + try: + client.do_stack_action(stack_id, action) + except StackException as e: + raise click.UsageError(e.message) + + +def print_command_output(json_blob): + for host in sorted(json_blob['std_out'], key=lambda x: x['host']): + click.secho('{0}:'.format(host['host']), fg='green') + click.echo(host['output']) + click.echo() + + +@stacks.command(name='run') +@pass_client +@click.pass_context +@click.argument('stack_title') +@click.argument('host_target') +@click.argument('command') +@click.option('-w', '--wait', is_flag=True, default=False, + help='Wait for the command to finish running') +@click.option('-t', '--timeout', type=click.INT, default=120, + help='The amount of time to wait for the command in seconds. ' + 'Ignored if used without the -w option.') +def run_command(ctx, client, stack_title, host_target, command, wait, timeout): + """ + Run a command on all hosts in the stack + """ + stack_id = get_stack_id(client, stack_title) + + resp = client.run_command(stack_id, host_target, command) + + if not wait: + # Grab the parent info name + name = ctx.parent.parent.info_name + + click.echo('Command "{0}" running on "{1}" hosts. ' + 'Check the status by running:'.format(command, host_target)) + click.echo() + click.secho(' {0} stacks command-output {1}'.format(name, resp['id']), fg='yellow') + click.echo() + return + + @poll_and_wait + def check_status(): + command_out = client.get_command(resp['id']) + + if command_out['status'] != 'finished': + return False + + click.echo() + print_command_output(command_out) + + return True + + check_status(max_time=timeout) + + +@stacks.command(name='command-output') +@pass_client +@click.argument('command_id') +def get_command_output(client, command_id): + """ + Get the status and output of a command + """ + resp = client.get_command(command_id) + + if resp['status'] != 'finished': + click.secho('Status: {0}'.format(resp['status']), fg='yellow') + return + + print_command_output(resp) + + +def print_logs(client, stack_id): + logs = client.list_stack_logs(stack_id) + + click.echo('Latest:') + for log in logs['latest']: + click.echo(' {0}'.format(log.split('/')[-1])) + + click.echo() + + click.echo('Historical:') + for log in logs['historical']: + click.echo(' {0}'.format(log.split('/')[-1])) + + +@stacks.command(name='list-logs') +@pass_client +@click.argument('stack_title') +def list_stack_logs(client, stack_title): + """ + Get a list of stack logs + """ + stack_id = get_stack_id(client, stack_title) + + print_logs(client, stack_id) + + +@stacks.command(name='logs') +@pass_client +@click.argument('stack_title') +@click.argument('log_type') +@click.option('-l', '--lines', type=click.INT, default=25, help='number of lines to tail') +def stack_logs(client, stack_title, log_type, lines): + """ + Get logs for a stack + """ + stack_id = get_stack_id(client, stack_title) + + split_arg = log_type.split('.') + + valid_log = True + + if len(split_arg) != 3: + valid_log = False + + if valid_log: + try: + log_text = client.get_logs(stack_id, log_type=split_arg[0], level=split_arg[1], + date=split_arg[2], tail=lines) + click.echo(log_text) + except StackException: + valid_log = True + + if not valid_log: + click.echo('Please use one of these logs:\n') + + print_logs(client, stack_id) + + raise click.UsageError('Invalid log') + + +@stacks.group(name='access-rules') +def stack_access_rules(): + """ + Perform actions on stack access rules + """ + pass + + +def print_access_rules(components): + title = 'Access Rule' + num_components = len(components) + + if num_components != 1: + title += 's' + + click.echo('## {0} {1}'.format(num_components, title)) + + for item in components: + click.echo('- Name: {0}'.format(item.get('name'))) + click.echo(' Description: {0}'.format(item['description'])) + click.echo(' Group ID: {0}'.format(item['group_id'])) + click.echo(' Host Definition: {0}'.format(item['blueprint_host_definition']['title'])) + + # Print a newline after each entry + click.echo() + + +@stack_access_rules.command(name='list') +@pass_client +@click.argument('stack_title') +def list_access_rules(client, stack_title): + stack_id = get_stack_id(client, stack_title) + + rules = client.list_access_rules(stack_id) + + print_access_rules(rules) diff --git a/stackdio/cli/utils.py b/stackdio/cli/utils.py new file mode 100644 index 0000000..6ace295 --- /dev/null +++ b/stackdio/cli/utils.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- + +# Copyright 2014, Digital Reasoning +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import time +from functools import update_wrapper + +import click + +from stackdio.client import StackdioClient + + +class TimeoutException(Exception): + pass + + +# Create our decorator +pass_client = click.make_pass_decorator(StackdioClient) + + +def print_summary(title, components): + num_components = len(components) + + if num_components != 1: + title += 's' + + click.echo('## {0} {1}'.format(num_components, title)) + + for item in components: + click.echo('- Title: {0}'.format( + item.get('title'))) + + if 'description' in item: + click.echo(' Description: {0}'.format(item['description'])) + + if 'status' in item: + click.echo(' Status: {0}'.format(item['status'])) + + if 'status_detail' in item: + click.echo(' Status Detail: {0}'.format(item['status_detail'])) + + # Print a newline after each entry + click.echo() + + +def poll_and_wait(func): + """ + Execute func in increments of sleep_time for no more than max_time. + Raise TimeoutException if we're not successful in max_time + """ + def decorator(args=None, sleep_time=2, max_time=120): + args = args or [] + + current_time = 0 + + click.echo('.', nl=False, file=sys.stderr) + success = func(*args) + while not success and current_time < max_time: + current_time += sleep_time + time.sleep(sleep_time) + click.echo('.', nl=False, file=sys.stderr) + success = func(*args) + + if not success: + raise TimeoutException() + + return update_wrapper(decorator, func) diff --git a/stackdio/client/__init__.py b/stackdio/client/__init__.py index df3fcec..e982ebb 100644 --- a/stackdio/client/__init__.py +++ b/stackdio/client/__init__.py @@ -15,101 +15,104 @@ # limitations under the License. # -import json -import logging +from __future__ import unicode_literals -from .http import use_admin_auth, endpoint -from .exceptions import BlueprintException, StackException +import logging +from pkg_resources import parse_version +from .account import AccountMixin from .blueprint import BlueprintMixin +from .config import StackdioConfig +from .exceptions import ( + BlueprintException, + StackException, + IncompatibleVersionException, + MissingUrlException +) from .formula import FormulaMixin -from .profile import ProfileMixin -from .provider import ProviderMixin +from .http import HttpMixin, get, post, patch +from .image import ImageMixin from .region import RegionMixin from .settings import SettingsMixin from .stack import StackMixin - -from .version import _parse_version_string +from .snapshot import SnapshotMixin logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +class StackdioClient(BlueprintMixin, FormulaMixin, AccountMixin, ImageMixin, + RegionMixin, StackMixin, SettingsMixin, SnapshotMixin, HttpMixin): + + def __init__(self, url=None, username=None, password=None, verify=None, cfg_file=None): + self.config = StackdioConfig(cfg_file) + + self._password = self.config.get_password() + if url is not None: + self.config['url'] = url -class StackdIO(BlueprintMixin, FormulaMixin, ProfileMixin, - ProviderMixin, RegionMixin, StackMixin, SettingsMixin): + if username is not None and password is not None: + self.config['username'] = username + self._password = password - def __init__(self, protocol="https", host="localhost", port=443, - base_url=None, auth=None, auth_admin=None, - verify=True): - """auth_admin is optional, only needed for creating provider, profile, - and base security groups""" + if verify is not None: + self.config['verify'] = verify - super(StackdIO, self).__init__(auth=auth, verify=verify) - if base_url: - self.url = base_url if base_url.endswith('/') else "%s/" % base_url - else: - self.url = "{protocol}://{host}:{port}/api/".format( - protocol=protocol, - host=host, - port=port) + super(StackdioClient, self).__init__() - self.auth = auth - self.auth_admin = auth_admin + if self.usable(): + try: + raw_version = self.get_version(raise_for_status=False) + self.version = parse_version(raw_version) + except MissingUrlException: + raw_version = None + self.version = None - _, self.version = _parse_version_string(self.get_version()) + if self.version and (int(self.version[0]) != 0 or int(self.version[1]) != 8): + raise IncompatibleVersionException( + 'Server version {0} not supported. Please upgrade ' + 'stackdio-cli to {1}.{2}.0 or higher.'.format(raw_version, *self.version) + ) - @endpoint("") + @property + def url(self): + return self.config.get('url') + + @property + def username(self): + return self.config.get('username') + + @property + def password(self): + return self._password or self.config.get_password() + + @property + def verify(self): + return self.config.get('verify', True) + + def usable(self): + return self.url and self.username and self.password + + @get('') def get_root(self): - """Get the api root""" - return self._get(endpoint, jsonify=True) + pass - @endpoint("version/") + @get('version/') def get_version(self): - return self._get(endpoint, jsonify=True)['version'] - - @use_admin_auth - @endpoint("security_groups/") - def create_security_group(self, name, description, cloud_provider, is_default=True): - """Create a security group""" - - data = { - "name": name, - "description": description, - "cloud_provider": cloud_provider, - "is_default": is_default - } - return self._post(endpoint, data=json.dumps(data), jsonify=True) - - @endpoint("settings/") - def get_public_key(self): - """Get the public key for the logged in uesr""" - return self._get(endpoint, jsonify=True)['public_key'] + pass - @endpoint("settings/") - def set_public_key(self, public_key): - """Upload a public key for our user. public_key can be the actual key, a - file handle, or a path to a key file""" + @get_version.response + def get_version(self, resp): + return resp['version'] - if isinstance(public_key, file): - public_key = public_key.read() - elif isinstance(public_key, str) and os.path.exists(public_key): - public_key = open(public_key, "r").read() + @post('cloud/security_groups/') + def create_security_group(self, name, description, cloud_account, group_id, is_default=True): - data = { - "public_key": public_key + return { + 'name': name, + 'description': description, + 'cloud_account': cloud_account, + 'group_id': group_id, + 'is_default': is_default } - return self._put(endpoint, data=json.dumps(data), jsonify=True) - - @endpoint("instance_sizes/") - def get_instance_id(self, instance_id, provider_type="ec2"): - """Get the id for an instance_id. The instance_id parameter is the - provider name (e.g. m1.large). The id returned is the stackd.io id - for use in API calls (e.g. 1).""" - - result = self._get(endpoint, jsonify=True) - for instance in result['results']: - if instance.get("instance_id") == instance_id and \ - instance.get("provider_type") == provider_type: - return instance.get("id") - - raise StackException("Instance type %s from provider %s not found" % - (instance_id, provider_type)) diff --git a/stackdio/client/account.py b/stackdio/client/account.py new file mode 100644 index 0000000..f43b522 --- /dev/null +++ b/stackdio/client/account.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +# Copyright 2014, Digital Reasoning +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from .http import HttpMixin, get, post, delete + + +class AccountMixin(HttpMixin): + + @get('cloud/providers/', paginate=True) + def list_providers(self, **kwargs): + """List all providers""" + pass + + @post('cloud/accounts/') + def create_account(self, **kwargs): + """Create an account""" + + form_data = { + "title": None, + "account_id": None, + "provider": None, + "access_key_id": None, + "secret_access_key": None, + "keypair": None, + "security_groups": None, + "route53_domain": None, + "default_availability_zone": None, + "private_key": None + } + + for key in form_data.keys(): + form_data[key] = kwargs.get(key) + + return form_data + + @get('cloud/accounts/', paginate=True) + def list_accounts(self, **kwargs): + """List all account""" + pass + + @get('cloud/accounts/{account_id}/') + def get_account(self, account_id): + """Return the account that matches the given id""" + pass + + @delete('cloud/accounts/{account_id}/') + def delete_account(self, account_id): + """List all accounts""" + pass diff --git a/stackdio/client/blueprint.py b/stackdio/client/blueprint.py index 263c9dc..a0fcc7e 100644 --- a/stackdio/client/blueprint.py +++ b/stackdio/client/blueprint.py @@ -15,68 +15,88 @@ # limitations under the License. # -import json - -from .exceptions import StackException -from .http import HttpMixin, endpoint -from .version import accepted_versions, deprecated +from .exceptions import BlueprintException +from .http import HttpMixin, get, post, put, patch, delete class BlueprintMixin(HttpMixin): - @endpoint("blueprints/") - def create_blueprint(self, blueprint, provider="ec2"): + @post('blueprints/') + def create_blueprint(self, blueprint): """Create a blueprint""" - # check the provided blueprint to see if we need to look up any ids - for host in blueprint["hosts"]: - if isinstance(host["size"], basestring): - host["size"] = self.get_instance_id(host["size"], provider) - - # zone isn't required if you provide a subnet_id - if 'zone' in host and isinstance(host["zone"], basestring): - host["zone"] = self.get_zone_id(host["zone"], provider) - - if isinstance(host["cloud_profile"], basestring): - host["cloud_profile"] = self.get_profile_id(host["cloud_profile"], title=True) # noqa + if 'host_definitions' not in blueprint: + raise BlueprintException('Blueprints must contain a list of host_definitions') - for component in host["formula_components"]: - if not component.get("sls_path") and isinstance(component["id"], (tuple, list)): - formula_id = self.get_formula_id(component["id"][0]) + formula_map = {} - component["id"] = self.get_component_id( - self.get_formula(formula_id), - component["id"][1]) + if 'formula_versions' in blueprint: + all_formulas = self.list_formulas() - return self._post(endpoint, data=json.dumps(blueprint), jsonify=True) + used_formulas = [] - @endpoint("blueprints/") - def list_blueprints(self): - """Return info for a specific blueprint_id""" - return self._get(endpoint, jsonify=True)['results'] + for formula_version in blueprint['formula_versions']: + for formula in all_formulas: + if formula['uri'] == formula_version['formula']: + formula['version'] = formula_version['version'] + used_formulas.append(formula) + break - @endpoint("blueprints/{blueprint_id}/") - def get_blueprint(self, blueprint_id, none_on_404=False): - """Return info for a specific blueprint_id""" - return self._get(endpoint, jsonify=True, none_on_404=none_on_404) + for formula in used_formulas: + components = self.list_components_for_version(formula['id'], formula['version']) + for component in components: + formula_map[component['sls_path']] = formula['uri'] - @endpoint("blueprints/") - def search_blueprints(self, **kwargs): - """Return info for a specific blueprint_id""" - return self._get(endpoint, params=kwargs, jsonify=True)['results'] - - @endpoint("blueprints/{blueprint_id}") - def delete_blueprint(self, blueprint_id): - return self._delete(endpoint, jsonify=True) + # check the provided blueprint to see if we need to look up any ids + for host in blueprint['host_definitions']: + for component in host.get('formula_components', []): + if component['sls_path'] in formula_map: + component['formula'] = formula_map[component['sls_path']] - @deprecated - @accepted_versions("<0.7") - def get_blueprint_id(self, title): - """Get the id for a blueprint that matches title""" + return blueprint - blueprints = self.search_blueprints(title=title) + @get('blueprints/', paginate=True) + def list_blueprints(self, **kwargs): + pass - if not len(blueprints): - raise StackException("Blueprint %s not found" % title) + @get('blueprints/{blueprint_id}/') + def get_blueprint(self, blueprint_id): + pass - return blueprints[0]['id'] + @delete('blueprints/{blueprint_id}/') + def delete_blueprint(self, blueprint_id): + pass + + @get('blueprints/{blueprint_id}/host_definitions/', paginate=True) + def get_blueprint_host_definitions(self, blueprint_id): + pass + + @get('blueprints/{blueprint_id}/properties/') + def get_blueprint_properties(self, blueprint_id): + pass + + @put('blueprints/{blueprint_id}/properties/') + def update_blueprint_properties(self, blueprint_id, properties): + return properties + + @patch('blueprints/{blueprint_id}/properties/') + def partial_update_blueprint_properties(self, blueprint_id, properties): + return properties + + @post('blueprints/{blueprint_id}/labels/') + def add_blueprint_label(self, blueprint_id, key, value): + return { + 'key': key, + 'value': value, + } + + @put('blueprints/{blueprint_id}/labels/{key}/') + def update_blueprint_label(self, blueprint_id, key, value): + return { + 'key': key, + 'value': value, + } + + @delete('blueprints/{blueprint_id}/labels/{key}/') + def delete_blueprint_label(self, blueprint_id, key): + pass diff --git a/stackdio/client/compat.py b/stackdio/client/compat.py new file mode 100644 index 0000000..70a0ff1 --- /dev/null +++ b/stackdio/client/compat.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +# Copyright 2014, Digital Reasoning +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +try: + # Python 2 + from ConfigParser import ConfigParser, NoOptionError +except ImportError: + # Python 3 + from configparser import ConfigParser, NoOptionError diff --git a/stackdio/client/config.py b/stackdio/client/config.py new file mode 100644 index 0000000..5d1ab49 --- /dev/null +++ b/stackdio/client/config.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- + +# Copyright 2014, Digital Reasoning +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os + +import click +import keyring +import requests +from requests.exceptions import ConnectionError, MissingSchema + +from stackdio.client.compat import ConfigParser, NoOptionError + + +CFG_DIR = os.path.join(os.path.expanduser('~'), '.stackdio') +CFG_FILE = os.path.join(CFG_DIR, 'client.cfg') + + +class UserPath(click.Path): + + def convert(self, value, param, ctx): + return super(UserPath, self).convert(os.path.expanduser(value), param, ctx) + + +class StackdioConfig(object): + """ + A wrapper around python's ConfigParser class + """ + + KEYRING_SERVICE = 'stackdio_cli' + + BOOL_MAP = { + str(True): True, + str(False): False, + } + + def __init__(self, config_file=None, section='stackdio'): + super(StackdioConfig, self).__init__() + + self.section = section + + self._cfg_file = os.path.abspath(config_file or CFG_FILE) + + self._config = ConfigParser() + + self.usable_file = os.path.isfile(self._cfg_file) + + if self.usable_file: + self._config.read(self._cfg_file) + + self.usable_section = self._config.has_section(self.section) + self.usable_config = self.usable_file and self.usable_section + + if not self.usable_section: + self._config.add_section(section) + + def save(self): + full_path = os.path.dirname(self._cfg_file) + + if not os.path.isdir(full_path): + os.makedirs(full_path) + + with open(self._cfg_file, 'w') as f: + self._config.write(f) + + def get_password(self, username=None): + username = username or self.get('username') + + if username is not None: + return keyring.get_password(self.KEYRING_SERVICE, username) + else: + return None + + def set_password(self, new_password): + username = self.get('username') + + if username is None: + raise KeyError('Not username provided') + + keyring.set_password(self.KEYRING_SERVICE, username, new_password) + + def __contains__(self, item): + try: + self._config.get(self.section, item) + return True + except NoOptionError: + return False + + def __getitem__(self, item): + try: + ret = self._config.get(self.section, item) + if ret in self.BOOL_MAP: + return self.BOOL_MAP[ret] + else: + return str(ret) + except NoOptionError: + raise KeyError(item) + + def __setitem__(self, key, value): + if isinstance(value, bool): + value = str(value) + self._config.set(self.section, key, value) + + def get(self, item, default=None): + try: + return self[item] + except KeyError: + return default + + def items(self): + return self._config.items(self.section) + + def prompt_for_config(self): + self.get_url() + self.get_username() + self.get_blueprint_dir() + + # Save when we're done + self.save() + + def _test_url(self, url): + try: + r = requests.get(url, verify=self.get('verify', True)) + return (200 <= r.status_code < 300) or r.status_code == 403 + except ConnectionError: + return False + except MissingSchema: + click.echo('You might have forgotten http:// or https://') + return False + + def _test_credentials(self, username, password): + try: + r = requests.get(self['url'], + verify=self.get('verify', True), + auth=(username, password)) + return 200 <= r.status_code < 300 + except (ConnectionError, MissingSchema): + click.echo('There is something wrong with your URL.') + return False + + def get_url(self): + if self.get('url') is not None: + if click.confirm('Keep existing url ({0})?'.format(self['url']), default=True): + return + + self['verify'] = not click.confirm('Does your stackd.io server have a self-signed ' + 'SSL certificate?') + + new_url = None + + while new_url is None: + url = click.prompt('What is the URL of your stackd.io server', prompt_suffix='? ') + if url.endswith('api'): + url += '/' + elif url.endswith('api/'): + pass + elif url.endswith('/'): + url += 'api/' + else: + url += '/api/' + if self._test_url(url): + new_url = url + else: + click.echo('There was an error while attempting to contact that server. ' + 'Try again.') + + self['url'] = new_url + + def get_username(self): + valid_creds = False + + while not valid_creds: + keep_username = False + + username = self.get('username') + + if username is not None: + if click.confirm('Keep existing username ({0})?'.format(username), default=True): + keep_username = True + + if not keep_username: + username = click.prompt('What is your stackd.io username', prompt_suffix='? ') + + password = self.get_password(username) + + keep_password = False + + if password is not None: + if click.confirm('Keep existing password for user {0}?'.format(username), + default=True): + keep_password = True + + if not keep_password: + password = click.prompt('What is the password for {0}'.format(username), + prompt_suffix='? ', hide_input=True) + + if self._test_credentials(username, password): + self['username'] = username + self.set_password(password) + valid_creds = True + else: + click.echo('Invalid credentials. Please try again.') + valid_creds = False + + def get_blueprint_dir(self): + blueprints = self.get('blueprint_dir') + + if blueprints is not None: + if click.confirm('Keep existing blueprints directory ({0})?'.format(blueprints), + default=True): + return + + self['blueprint_dir'] = click.prompt('Where are your blueprints stored', + prompt_suffix='? ', + type=UserPath(exists=True, file_okay=False, + resolve_path=True)) + + +# FOR BACKWARDS COMPATIBILITY!!! To be removed at some point. +@click.command(context_settings=dict(help_option_names=['-h', '--help'])) +@click.option('-o', '--old-file', default=os.path.expanduser('~/.stackdio-cli/config.json'), + type=click.Path(exists=True), help='Path to the old JSON config file') +@click.option('-n', '--new-file', default=CFG_FILE, type=click.Path(), + help='Path to the new cfg format file. Must not already exist.') +def migrate_old_config(old_file, new_file): + """ + Used to migrate an old JSON config file to a new one + """ + if os.path.exists(new_file): + raise click.UsageError('The new config file must not already exist') + + old_keys = ['username', 'url', 'verify', 'blueprint_dir'] + + config = StackdioConfig(new_file) + + import json + with open(old_file, 'r') as f: + old_config = json.load(f) + + for key in old_keys: + if key in old_config: + config[key] = old_config[key] + + if 'url' not in config: + config.get_url() + + if 'username' not in config or config.get_password() is None: + config.get_username() + + if 'blueprint_dir' not in config: + config.get_blueprint_dir() + + config.save() + + +def main(): + migrate_old_config() + + +if __name__ == '__main__': + main() diff --git a/stackdio/client/exceptions.py b/stackdio/client/exceptions.py index b831cdb..b003e39 100644 --- a/stackdio/client/exceptions.py +++ b/stackdio/client/exceptions.py @@ -15,15 +15,16 @@ # limitations under the License. # -class StackException(Exception): + +class MissingUrlException(Exception): pass -class BlueprintException(Exception): +class StackException(Exception): pass -class NoAdminException(Exception): +class BlueprintException(Exception): pass diff --git a/stackdio/client/formula.py b/stackdio/client/formula.py index 587214d..3d819b7 100644 --- a/stackdio/client/formula.py +++ b/stackdio/client/formula.py @@ -15,63 +15,47 @@ # limitations under the License. # -import json - -from .exceptions import StackException -from .http import HttpMixin, endpoint +from .http import HttpMixin, get, post, delete class FormulaMixin(HttpMixin): - @endpoint("formulas/") - def import_formula(self, formula_uri, public=True): + @post('formulas/') + def import_formula(self, formula_uri, git_username=None, git_password=None, access_token=None): """Import a formula""" data = { - "uri": formula_uri, - "public": public, + 'uri': formula_uri, } - return self._post(endpoint, data=json.dumps(data), jsonify=True) - @endpoint("formulas/") - def list_formulas(self): + if git_username: + data['git_username'] = git_username + data['git_password'] = git_password + data['access_token'] = False + elif access_token: + data['git_username'] = access_token + data['access_token'] = True + + return data + + @get('formulas/', paginate=True) + def list_formulas(self, **kwargs): """Return all formulas""" - return self._get(endpoint, jsonify=True)['results'] + pass - @endpoint("formulas/{formula_id}/") - def get_formula(self, formula_id, none_on_404=False): + @get('formulas/{formula_id}/') + def get_formula(self, formula_id): """Get a formula with matching id""" - return self._get(endpoint, jsonify=True, none_on_404=none_on_404) + pass - @endpoint("formulas/") - def search_formulas(self, **kwargs): - """Get a formula with matching id""" - return self._get(endpoint, params=kwargs, jsonify=True)['results'] + @get('formulas/{formula_id}/components/?version={version}', paginate=True) + def list_components_for_version(self, formula_id, version): + pass - @endpoint("formulas/{formula_id}/") + @delete('formulas/{formula_id}/') def delete_formula(self, formula_id): """Delete formula with matching id""" - return self._delete(endpoint, jsonify=True) + pass - @endpoint("formulas/{formula_id}/action/") + @post('formulas/{formula_id}/action/') def update_formula(self, formula_id): - """Delete formula with matching id""" - return self._post(endpoint, json.dumps({"action": "update"}), jsonify=True) - - def get_formula_id(self, title): - """Find a stack id""" - - formulas = self.list_formulas() - for formula in formulas: - if formula.get("title") == title: - return formula.get("id") - - raise StackException("Formula %s not found" % title) - - def get_component_id(self, formula, component_title): - """Get the id for a component from formula_id that matches title""" - - for component in formula.get("components"): - if component.get("title") == component_title: - return component.get("id") - - raise StackException("Component %s not found for formula %s" % - (component_title, formula.get("title"))) + """Update the formula""" + return {"action": "update"} diff --git a/stackdio/client/http.py b/stackdio/client/http.py index 4584c1c..35e2bf7 100644 --- a/stackdio/client/http.py +++ b/stackdio/client/http.py @@ -17,162 +17,217 @@ from __future__ import print_function +import json import logging -import requests - -from functools import wraps +from functools import update_wrapper from inspect import getcallargs -from .exceptions import NoAdminException +import requests + +from .exceptions import MissingUrlException logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) -HTTP_INSECURE_MESSAGE = "\n".join([ +HTTP_INSECURE_MESSAGE = '\n'.join([ "You have chosen not to verify ssl connections.", "This is insecure, but it's your choice.", - "This has been your single warning."]) + "This has been your single warning." +]) -def use_admin_auth(func): +class HttpMixin(object): + """Add HTTP request features to an object""" - @wraps(func) - def wrapper(obj, *args, **kwargs): - # Save and set the auth to use the admin auth - auth = obj._http_options['auth'] - try: - obj._http_options['auth'] = obj._http_options['admin'] - except KeyError: - raise NoAdminException("No admin credentials were specified") + HEADERS = { + 'json': {'content-type': 'application/json'}, + 'xml': {'content-type': 'application/xml'}, + } - # Call the original function - output = func(*args, **kwargs) + def __init__(self): + super(HttpMixin, self).__init__() + self._http_log = logger - # Set the auth back to the original - obj._http_options['auth'] = auth - return output - return wrapper + if not self.verify: + if self._http_log.handlers: + self._http_log.warn(HTTP_INSECURE_MESSAGE) + else: + print(HTTP_INSECURE_MESSAGE) + from requests.packages.urllib3 import disable_warnings + disable_warnings() -def endpoint(path): - """Takes a path extension and appends to the known API base url. - The result of this is then added to the decorated functions global - scope as a variable named 'endpoint""" - def decorator(func): - @wraps(func) - def wrapper(obj, *args, **kwargs): + @property + def url(self): + raise NotImplementedError() - # Get what locals() would return directly after calling - # 'func' with the given args and kwargs - future_locals = getcallargs(func, *((obj,) + args), **kwargs) + @property + def username(self): + raise NotImplementedError() - # Build the variable we'll inject - url = "{url}{path}".format( - url=obj.url, - path=path.format(**future_locals)) + @property + def password(self): + raise NotImplementedError() - # Grab the global context for the passed function - g = func.__globals__ + @property + def verify(self): + raise NotImplementedError() - # Create a unique default object so we can accurately determine - # if we replaced a value - sentinel = object() - oldvalue = g.get('endpoint', sentinel) + def usable(self): + raise NotImplementedError() - # Inject our variable into the global scope - g['endpoint'] = url - # Logging and function call - if oldvalue: - logger.debug("Value %s for 'endpoint' replaced in global scope " - "for function %s" % (oldvalue, func.__name__)) - logger.debug("%s.__globals__['endpoint'] = %s" % (func.__name__, url)) +def default_response(obj, response): + return response - result = func(obj, *args, **kwargs) - # Replace the previous value, if it existed - if oldvalue is not sentinel: - g['endpoint'] = oldvalue +def request(path, method, paginate=False, jsonify=True, **req_kwargs): - return result - return wrapper - return decorator + # Define a class here that uses the path / method we want. We need it inside this function + # so we have access to the path / method. + class Request(object): + def __init__(self, dfunc=None, rfunc=None, quiet=False): + super(Request, self).__init__() + if dfunc: + update_wrapper(self, dfunc) -class HttpMixin(object): - """Add HTTP request features to an object""" + self.obj = None - HEADERS = { - 'json': {"content-type": "application/json"}, - 'xml': {"content-type": "application/xml"} - } + self.data_func = dfunc + self.response_func = rfunc or default_response - def __init__(self, auth=None, verify=True): - self._http_options = { - 'auth': auth, - 'verify': verify, - } - self._http_log = logging.getLogger(__name__) + self.quiet = quiet - if not verify: - if self._http_log.handlers: - self._http_log.warn(HTTP_INSECURE_MESSAGE) + self.headers = req_kwargs.get('headers', HttpMixin.HEADERS['json']) + + self._http_log = logging.getLogger(__name__) + + def data(self, dfunc): + return type(self)(dfunc, self.response_func, self.quiet) + + def response(self, rfunc): + return type(self)(self.data_func, rfunc, self.quiet) + + def __repr__(self): + if self.obj: + return (''.format(method, path, repr(self.obj))) else: - print(HTTP_INSECURE_MESSAGE) + return super(Request, self).__repr__() - from requests.packages.urllib3 import disable_warnings - disable_warnings() + # We need this so we can save the client object as an attribute, and then it can be used + # in __call__ + def __get__(self, obj, objtype=None): + if objtype: + assert issubclass(objtype, HttpMixin) + assert isinstance(obj, HttpMixin) + + self.obj = obj + return self + + # Here's how the request actually happens + def __call__(self, *args, **kwargs): + assert isinstance(self.obj, HttpMixin) + + if not self.obj.usable(): + raise MissingUrlException('No url is set. Please run `configure`.') + + none_on_404 = kwargs.pop('none_on_404', False) + raise_for_status = kwargs.pop('raise_for_status', True) + + # Get what locals() would return directly after calling + # 'func' with the given args and kwargs + future_locals = getcallargs(self.data_func, *((self.obj,) + args), **kwargs) + + # Build the variable we'll inject + url = '{url}{path}'.format( + url=self.obj.url, + path=path.format(**future_locals) + ) + + if not self.quiet: + self._http_log.info("%s: %s", method, url) + + data = None + if self.data_func: + data = json.dumps(self.data_func(self.obj, *args, **kwargs)) + + result = requests.request(method, + url, + data=data, + auth=(self.obj.username, self.obj.password), + headers=self.headers, + params=kwargs, + verify=self.obj.verify) + + # Handle special conditions + if none_on_404 and result.status_code == 404: + return None + + elif result.status_code == 204: + return None + + elif raise_for_status: + try: + result.raise_for_status() + except Exception: + logger.error(result.text) + raise + + if jsonify: + response = result.json() + else: + response = result.text + + if method == 'GET' and paginate and jsonify: + res = response['results'] + + next_url = response.get('next') + + while next_url: + next_page = requests.request(method, + next_url, + data=data, + auth=(self.obj.username, self.obj.password), + headers=self.headers, + params=kwargs, + verify=self.obj.verify).json() + res.extend(next_page['results']) + next_url = next_page.get('next') + + response = res + + # now process the result + return self.response_func(self.obj, response) + return Request - def _request(self, verb, url, quiet=False, - none_on_404=False, jsonify=False, raise_for_status=True, - *args, **kwargs): - """Generic request method""" - if not quiet: - self._http_log.info("{0}: {1}".format(verb, url)) +# Define the decorators for all the methods +def get(path, paginate=False, jsonify=True): + return request(path, 'GET', paginate=paginate, jsonify=jsonify) - headers = kwargs.get('headers', HttpMixin.HEADERS['json']) - result = requests.request(verb, url, - auth=self._http_options['auth'], - headers=headers, - verify=self._http_options['verify'], - *args, **kwargs) +def head(path): + return request(path, 'HEAD') - # Handle special conditions - if none_on_404 and result.status_code == 404: - return None - elif result.status_code == 204: - return None +def options(path): + return request(path, 'OPTIONS') - elif raise_for_status: - try: - result.raise_for_status() - except Exception: - logger.error(result.text) - raise - # return - if jsonify: - return result.json() - else: - return result +def post(path): + return request(path, 'POST') - def _head(self, url, *args, **kwargs): - return self._request("HEAD", url, *args, **kwargs) - def _get(self, url, *args, **kwargs): - return self._request("GET", url, *args, **kwargs) +def put(path): + return request(path, 'PUT') - def _delete(self, url, *args, **kwargs): - return self._request("DELETE", url, *args, **kwargs) - def _post(self, url, data=None, *args, **kwargs): - return self._request("POST", url, data=data, *args, **kwargs) +def patch(path): + return request(path, 'PATCH') - def _put(self, url, data=None, *args, **kwargs): - return self._request("PUT", url, data=data, *args, **kwargs) - def _patch(self, url, data=None, *args, **kwargs): - return self._request("PATCH", url, data=data, *args, **kwargs) +def delete(path): + return request(path, 'DELETE') diff --git a/stackdio/client/image.py b/stackdio/client/image.py new file mode 100644 index 0000000..80402f2 --- /dev/null +++ b/stackdio/client/image.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- + +# Copyright 2014, Digital Reasoning +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from .http import HttpMixin, get, post, delete + + +class ImageMixin(HttpMixin): + + @post('cloud/images/') + def create_image(self, title, image_id, ssh_user, cloud_provider, default_instance_size=None): + """Create a image""" + return { + "title": title, + "image_id": image_id, + "ssh_user": ssh_user, + "cloud_provider": cloud_provider, + "default_instance_size": default_instance_size + } + + @get('cloud/images/', paginate=True) + def list_images(self, **kwargs): + """List all images""" + pass + + @get('cloud/images/{image_id}/') + def get_image(self, image_id): + """Return the image that matches the given id""" + pass + + @delete('cloud/images/{image_id}/') + def delete_image(self, image_id): + """Delete the image with the given id""" + pass diff --git a/stackdio/client/profile.py b/stackdio/client/profile.py deleted file mode 100644 index d16cac9..0000000 --- a/stackdio/client/profile.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright 2014, Digital Reasoning -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -import json - -from .exceptions import StackException -from .http import HttpMixin, endpoint, use_admin_auth -from .version import accepted_versions, deprecated - - -class ProfileMixin(HttpMixin): - - @use_admin_auth - @endpoint("profile/") - def create_profile(self, title, image_id, ssh_user, cloud_provider, - default_instance_size=None): - """Create a profile""" - data = { - "title": title, - "image_id": image_id, - "ssh_user": ssh_user, - "cloud_provider": cloud_provider, - "default_instance_size": default_instance_size - } - return self._post(endpoint, data=json.dumps(data), jsonify=True) - - - @endpoint("profiles/") - def list_profiles(self): - """List all profiles""" - return self._get(endpoint, jsonify=True)['results'] - - - @endpoint("profiles/{profile_id}/") - def get_profile(self, profile_id, none_on_404=False): - """Return the profile that matches the given id""" - return self._get(endpoint, jsonify=True, none_on_404=none_on_404) - - - @accepted_versions(">=0.6.1") - @endpoint("profiles/") - def search_profiles(self, profile_id): - """List all profiles""" - return self._get(endpoint, jsonify=True)['results'] - - - @endpoint("profiles/{profile_id}/") - def delete_profile(self, profile_id): - """Delete the profile with the given id""" - return self._delete(endpoint, jsonify=True)['results'] - - - @deprecated - @accepted_versions("<0.7") - def get_profile_id(self, slug, title=False): - """Get the id for a profile that matches slug. If title is True will look - at title instead.""" - - profiles = self.list_profiles() - for profile in profiles: - if profile.get("slug" if not title else "title") == slug: - return profile.get("id") - - raise StackException("Profile %s not found" % slug) diff --git a/stackdio/client/provider.py b/stackdio/client/provider.py deleted file mode 100644 index fb6de89..0000000 --- a/stackdio/client/provider.py +++ /dev/null @@ -1,115 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright 2014, Digital Reasoning -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -import json - -from .exceptions import StackException -from .http import HttpMixin, endpoint, use_admin_auth -from .version import accepted_versions, deprecated - - -class ProviderMixin(HttpMixin): - - @endpoint("provider_types/") - def list_provider_types(self): - """List all providers""" - return self._get(endpoint, jsonify=True)['results'] - - - @accepted_versions(">=0.6.1") - @endpoint("provider_types/") - def search_provider_types(self, provider_id): - """List all provider_types""" - return self._get(endpoint, jsonify=True)['results'] - - - @deprecated - @accepted_versions("<0.7") - @endpoint("provider_types/") - def get_provider_type_id(self, type_name): - """Get the id for the provider specified by type_name""" - - result = self._get(endpoint, jsonify=True) - for provider_type in result['results']: - if provider_type.get("type_name") == type_name: - return provider_type.get("id") - - raise StackException("Provider type %s not found" % type_name) - - - @use_admin_auth - @endpoint("providers/") - def create_provider(self, **kwargs): - """Create a provider""" - - form_data = { - "title": None, - "account_id": None, - "provider_type": None, - "access_key_id": None, - "secret_access_key": None, - "keypair": None, - "security_groups": None, - "route53_domain": None, - "default_availability_zone": None, - "private_key": None - } - - for key in form_data.keys(): - form_data[key] = kwargs.get(key) - - return self._post(endpoint, data=json.dumps(form_data), jsonify=True) - - - @endpoint("providers/") - def list_providers(self): - """List all providers""" - return self._get(endpoint, jsonify=True)['results'] - - - @endpoint("providers/{provider_id}/") - def get_provider(self, provider_id, none_on_404=False): - """Return the provider that matches the given id""" - return self._get(endpoint, jsonify=True, none_on_404=none_on_404) - - - @accepted_versions(">=0.6.1") - @endpoint("providers/") - def search_providers(self, provider_id): - """List all providers""" - return self._get(endpoint, jsonify=True)['results'] - - - @endpoint("providers/{provider_id}/") - def delete_provider(self, provider_id): - """List all providers""" - return self._delete(endpoint, jsonify=True)['results'] - - - @deprecated - @accepted_versions("<0.7") - def get_provider_id(self, slug, title=False): - """Get the id for a provider that matches slug. If title is True will - look at title instead.""" - - providers = self.list_providers() - - for provider in providers: - if provider.get("slug" if not title else "title") == slug: - return provider.get("id") - - raise StackException("Provider %s not found" % slug) diff --git a/stackdio/client/region.py b/stackdio/client/region.py index 9c87da6..e69237a 100644 --- a/stackdio/client/region.py +++ b/stackdio/client/region.py @@ -15,83 +15,22 @@ # limitations under the License. # -from .exceptions import StackException -from .http import HttpMixin, endpoint -from .version import accepted_versions, deprecated +from .http import HttpMixin, get class RegionMixin(HttpMixin): + @get('cloud/providers/{provider_name}/regions/', paginate=True) + def list_regions(self, provider_name, **kwargs): + pass + @get('cloud/providers/{provider_name}/regions/{region_id}/') + def get_region(self, provider_name, region_id): + pass - @accepted_versions(">=0.6.1") - @endpoint("regions/") - def list_regions(self): - return self._get(endpoint, jsonify=True)['results'] + @get('cloud/providers/{provider_name}/zones/', paginate=True) + def list_zones(self, provider_name, **kwargs): + pass - - @endpoint("regions/{region_id}") - def get_region(self, region_id, none_on_404=False): - return self._get(endpoint, jsonify=True, none_on_404=none_on_404) - - - @accepted_versions(">=0.6.1") - @endpoint("regions/") - def search_regions(self, **kwargs): - return self._get(endpoint, params=kwargs, jsonify=True)['results'] - - - @deprecated - @accepted_versions(">=0.6", "<0.7") - @endpoint("regions/") - def get_region_id(self, title, type_name="ec2"): - """Get a zone id for title""" - - provider_type = self.get_provider_type(type_name) - params = { - "title": title, - "provider_type": provider_type["id"] - } - result = self._get(endpoint, params=params, jsonify=True) - if len(result['results']) == 1: - return result['results'][0]['id'] - - raise StackException("Zone %s not found for %s" % (title, type_name)) - - - @accepted_versions("!=0.6") - @endpoint("zones/") - def list_zones(self): - return self._get(endpoint, jsonify=True)['results'] - - @endpoint("zones/{zone_id}") - def get_zone(self, zone_id, none_on_404=False): - return self._get(endpoint, jsonify=True, none_on_404=none_on_404) - - @accepted_versions(">=0.6.1") - @endpoint("zones/") - def search_zones(self, **kwargs): - return self._get(endpoint, params=kwargs, jsonify=True)['results'] - - - @deprecated - @accepted_versions("!=0.6", "<0.7") - @endpoint("zones/") - def get_zone_id(self, title, type_name="ec2"): - """Get a zone id for title""" - - result = self._get(endpoint, jsonify=True) - - type_id = self.get_provider_type_id(type_name) - for zone in result['results']: - if zone.get("title") == title: - if 'region' in zone: - # For version 0.6.1 - region = self._get(zone['region'], jsonify=True) - if region.get("provider_type") == type_id: - return zone.get("id") - elif 'provider_type' in zone: - # For versions 0.5.* - if zone['provider_type'] == type_id: - return zone.get("id") - - raise StackException("Zone %s not found for %s" % (title, type_name)) + @get('cloud/providers/{provider_name}/zones/{zone_id}') + def get_zone(self, provider_name, zone_id): + pass diff --git a/stackdio/client/settings.py b/stackdio/client/settings.py index 4e45650..bfc57d7 100644 --- a/stackdio/client/settings.py +++ b/stackdio/client/settings.py @@ -15,26 +15,34 @@ # limitations under the License. # -import json import os -from .http import HttpMixin, endpoint +from .http import HttpMixin, get, patch class SettingsMixin(HttpMixin): - @endpoint("settings/") + @get('user/') + def get_public_key(self): + """Get the public key for the logged in user""" + pass + + @get_public_key.response + def get_public_key(self, resp): + return resp['settings']['public_key'] + + @patch('user/') def set_public_key(self, public_key): """Upload a public key for our user. public_key can be the actual key, a file handle, or a path to a key file""" if isinstance(public_key, file): public_key = public_key.read() - elif isinstance(public_key, str) and os.path.exists(public_key): public_key = open(public_key, "r").read() - data = { - "public_key": public_key + return { + 'settings': { + 'public_key': public_key, + } } - return self._put(endpoint, data=json.dumps(data), jsonify=True) diff --git a/stackdio/client/snapshot.py b/stackdio/client/snapshot.py new file mode 100644 index 0000000..6264f8b --- /dev/null +++ b/stackdio/client/snapshot.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +# Copyright 2014, Digital Reasoning +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from .http import HttpMixin, get, post, delete + + +class SnapshotMixin(HttpMixin): + + @post('cloud/snapshots/') + def create_snapshot(self, snapshot): + """Create a snapshot""" + return snapshot + + @get('cloud/snapshots/', paginate=True) + def list_snapshots(self, **kwargs): + pass + + @get('cloud/snapshots/{snapshot_id}/') + def get_snapshot(self, snapshot_id): + pass + + @delete('cloud/snapshots/{snapshot_id}/') + def delete_snapshot(self, snapshot_id): + pass diff --git a/stackdio/client/stack.py b/stackdio/client/stack.py index f78e80e..396fe3d 100644 --- a/stackdio/client/stack.py +++ b/stackdio/client/stack.py @@ -15,156 +15,151 @@ # limitations under the License. # -import json - from .exceptions import StackException -from .http import HttpMixin, endpoint -from .version import accepted_versions, deprecated +from .http import HttpMixin, get, post, put, patch, delete class StackMixin(HttpMixin): VALID_LOG_TYPES = { - "provisioning": ['log', 'err'], - "global-orchestration": ['log', 'err'], - "orchestration": ['log', 'err'], - "launch": ['log'], + 'provisioning': ['log', 'err'], + 'global-orchestration': ['log', 'err'], + 'orchestration': ['log', 'err'], + 'launch': ['log'], } - @endpoint("stacks/") + @post('stacks/') def create_stack(self, stack_data): """Launch a stack as described by stack_data""" - return self._post(endpoint, data=json.dumps(stack_data), jsonify=True) + return stack_data - @endpoint("stacks/") - def list_stacks(self): + @get('stacks/', paginate=True) + def list_stacks(self, **kwargs): """Return a list of all stacks""" - return self._get(endpoint, jsonify=True)['results'] + pass - @endpoint("stacks/{stack_id}/") - def get_stack(self, stack_id, none_on_404=False): + @get('stacks/{stack_id}/') + def get_stack(self, stack_id): """Get stack info""" - return self._get(endpoint, jsonify=True, none_on_404=none_on_404) - - @endpoint("stacks/") - def search_stacks(self, **kwargs): - """Search for stacks that match the given criteria""" - return self._get(endpoint, params=kwargs, jsonify=True)['results'] + pass - @endpoint("stacks/{stack_id}/") + @delete('stacks/{stack_id}/') def delete_stack(self, stack_id): """Destructively delete a stack forever.""" - return self._delete(endpoint, jsonify=True) + pass - @endpoint("stacks/{stack_id}/action/") + @get('stacks/{stack_id}/action/') def get_valid_stack_actions(self, stack_id): - return self._get(endpoint, jsonify=True)['available_actions'] + pass + + @get_valid_stack_actions.response + def get_valid_stack_actions(self, resp): + return resp['available_actions'] - @endpoint("stacks/{stack_id}/action/") + @post('stacks/{stack_id}/action/') def do_stack_action(self, stack_id, action): """Execute an action on a stack""" valid_actions = self.get_valid_stack_actions(stack_id) if action not in valid_actions: - raise StackException("Invalid action, must be one of %s" % - ", ".join(valid_actions)) - - data = {"action": action} - - return self._post(endpoint, data=json.dumps(data), jsonify=True) - - @endpoint("stacks/{stack_id}/history/") + raise StackException('Invalid action, must be one of %s' % + ', '.join(valid_actions)) + + return {'action': action} + + @post('stacks/{stack_id}/commands/') + def run_command(self, stack_id, host_target, command): + """ + Run a command on all stacks + """ + return { + 'host_target': host_target, + 'command': command, + } + + @get('stacks/{stack_id}/commands/') + def get_stack_commands(self, stack_id): + """ + Get all commands for a stack + """ + pass + + @get('commands/{command_id}/') + def get_command(self, command_id): + """ + Get information about a command + """ + pass + + @get('stacks/{stack_id}/history/', paginate=True) def get_stack_history(self, stack_id): """Get stack info""" - result = self._get(endpoint, none_on_404=True, jsonify=True) - if result is None: - raise StackException("Stack %s not found" % stack_id) - else: - return result + pass - @deprecated - @accepted_versions("<0.7") - def get_stack_id(self, title): - """Find a stack id""" + @get_stack_history.response + def get_stack_history(self, resp): + return reversed(resp) - stacks = self.list_stacks() - for stack in stacks: - if stack.get("title") == title: - return stack.get("id") - - raise StackException("Stack %s not found" % title) - - @endpoint("stacks/{stack_id}/hosts/") + @get('stacks/{stack_id}/hosts/', paginate=True) def get_stack_hosts(self, stack_id): """Get a list of all stack hosts""" - return self._get(endpoint, jsonify=True)['results'] - - @endpoint("stacks/{stack_id}/hosts/") - def describe_hosts(self, stack_id, key="fqdn", ec2=False): - """Retrieve a list of info about a stack. Defaults to the id for each - host, but you can specify any available key. Setting ec2=True will - force it to inspect the ec2_metadata field.""" - - EC2 = "ec2_metadata" - result = self._get(endpoint, jsonify=True) - - stack_details = [] - - for host in result['results']: - if not ec2: - host_details = host.get(key) - else: - host_details = host.get(EC2).get(key) - - if host_details is not None: - stack_details.append(host_details) - - if stack_details: - return stack_details - - raise StackException("Key %s for stack %s not available" % (key, stack_id)) - - @endpoint("stacks/{stack_id}/logs/{log_type}.{level}.{date}") + pass + + @put('stacks/{stack_id}/properties/') + def update_stack_properties(self, stack_id, properties): + return properties + + @patch('stacks/{stack_id}/properties/') + def partial_update_stack_properties(self, stack_id, properties): + return properties + + @post('stacks/{stack_id}/labels/') + def add_stack_label(self, stack_id, key, value): + return { + 'key': key, + 'value': value, + } + + @put('stacks/{stack_id}/labels/{key}/') + def update_stack_label(self, stack_id, key, value): + return { + 'key': key, + 'value': value, + } + + @delete('stacks/{stack_id}/labels/{key}/') + def delete_stack_label(self, stack_id, key): + pass + + @get('stacks/{stack_id}/logs/') + def list_stack_logs(self, stack_id): + """Get a list of stack logs""" + pass + + @get('stacks/{stack_id}/logs/{log_type}.{level}.{date}?tail={tail}', jsonify=False) def get_logs(self, stack_id, log_type, level='log', date='latest', tail=None): """Get logs for a stack""" if log_type and log_type not in self.VALID_LOG_TYPES: - raise StackException("Invalid log type, must be one of %s" % - ", ".join(self.VALID_LOG_TYPES.keys())) + raise StackException('Invalid log type, must be one of %s' % + ', '.join(self.VALID_LOG_TYPES.keys())) if level not in self.VALID_LOG_TYPES[log_type]: - raise StackException("Invalid log level, must be one of %s" % - ", ".join(self.VALID_LOG_TYPES[log_type])) - - return self._get(endpoint, params={'tail': tail}).text + raise StackException('Invalid log level, must be one of %s' % + ', '.join(self.VALID_LOG_TYPES[log_type])) - @endpoint("stacks/{stack_id}/security_groups/") + @get('stacks/{stack_id}/security_groups/', paginate=True) def list_access_rules(self, stack_id): - """Get Access rules for a stack""" - - return self._get(endpoint, jsonify=True)['results'] + """ + Get Access rules for a stack + :rtype: list + """ + pass - @deprecated - @accepted_versions("<0.7") - def get_access_rule_id(self, stack_id, title): - """Find an access rule id""" - - rules = self.list_access_rules(stack_id) - - try: - for group in rules: - if group.get("blueprint_host_definition").get("title") == title: - return group.get("id") - except TypeError: - pass - - raise StackException("Access Rule %s not found" % title) - - @endpoint("security_groups/{group_id}/rules/") + @get('security_groups/{group_id}/rules/', paginate=True) def list_rules_for_group(self, group_id): - return self._get(endpoint, jsonify=True) + pass - @endpoint("security_groups/{group_id}/rules/") + @put('security_groups/{group_id}/rules/') def edit_access_rule(self, group_id, data=None): """Add an access rule to a group""" - - return self._put(endpoint, jsonify=True, data=json.dumps(data)) + return data diff --git a/stackdio/client/version.py b/stackdio/client/version.py index 2bbfa7d..73cb64f 100644 --- a/stackdio/client/version.py +++ b/stackdio/client/version.py @@ -15,95 +15,92 @@ # limitations under the License. # -__version__ = "0.6.0.client.3" - -from functools import wraps -import operator -import re +import datetime +import os +import subprocess import warnings +from functools import wraps # for setup.py try: - from .exceptions import (IncompatibleVersionException, - InvalidVersionStringException) -except: + from .exceptions import IncompatibleVersionException, InvalidVersionStringException +except Exception: pass +VERSION = (0, 8, 0, 'dev', 0) -def _unsupported_function(func, current_version, accepted_versions): - raise IncompatibleVersionException("%s: %s is not one of %s" % - (func.__name__, - ".".join([str(v) for v in current_version]), - list(accepted_versions))) +def get_version(version): + """ + Returns a PEP 440-compliant version number from VERSION. -def _parse_version_string(version_string): - original_version_string = version_string - comparisons = { - "=": operator.eq, - "!=": operator.ne, - "<": operator.lt, - ">": operator.gt, - "<=": operator.le, - ">=": operator.ge - } + Created by modifying django.utils.version.get_version + """ - # Determine the comparison function - comp_string = "=" - if version_string[0] in ["<", ">", "=", "!"]: - offset = 1 - if version_string[1] == "=": - offset += 1 + # Now build the two parts of the version number: + # major = X.Y[.Z] + # sub = .devN - for development releases + # | {a|b|rc}N - for alpha, beta and rc releases + # | .postN - for post-release releases - comp_string = version_string[:offset] - version_string = version_string[offset:] + assert len(version) == 5 - # Check if the version appears compatible - try: - int(version_string[0]) - except ValueError: - raise InvalidVersionStringException(original_version_string) + version_parts = version[:3] - # String trailing info - version_string = re.split("[a-zA-Z]", version_string)[0] - version = version_string.split(".") + # Build the first part of the version + major = '.'.join(str(x) for x in version_parts) - # Pad length to 3 - version += [0] * (3 - len(version)) + # Just return it if this is a final release version + if version[3] == 'final': + return major - # Convert to ints - version = [int(num) for num in version] + # Add the rest + sub = ''.join(str(x) for x in version[3:5]) - try: - return comparisons[comp_string], tuple(version) - except KeyError: - raise InvalidVersionStringException(original_version_string) + if version[3] == 'dev': + # Override the sub part. Add in a timestamp + timestamp = get_git_changeset() + sub = 'dev%s' % (timestamp if timestamp else '') + return '%s.%s' % (major, sub) + if version[3] == 'post': + # We need a dot for post + return '%s.%s' % (major, sub) + elif version[3] in ('a', 'b', 'rc'): + # No dot for these + return '%s%s' % (major, sub) + else: + raise ValueError('Invalid version: %s' % str(version)) -def accepted_versions(*versions): - def decorator(func): - if not versions: - return func +# Borrowed directly from django +def get_git_changeset(): + """Returns a numeric identifier of the latest git changeset. - parsed_versions = [_parse_version_string(version_string) - for version_string in versions] + The result is the UTC timestamp of the changeset in YYYYMMDDHHMMSS format. + This value isn't guaranteed to be unique, but collisions are very unlikely, + so it's sufficient for generating the development version numbers. + """ + repo_dir = os.path.dirname(os.path.abspath(__file__)) + git_log = subprocess.Popen('git log --pretty=format:%ct --quiet -1 HEAD', + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + shell=True, cwd=repo_dir, universal_newlines=True) + timestamp = git_log.communicate()[0] + try: + timestamp = datetime.datetime.utcfromtimestamp(int(timestamp)) + return timestamp.strftime('%Y%m%d%H%M%S') + except ValueError: + return None - @wraps(func) - def wrapper(obj, *args, **kwargs): - for parsed_version in parsed_versions: - comparison, version = parsed_version - if comparison(obj.version, version): - return func(obj, *args, **kwargs) - return _unsupported_function(func, obj.version, versions) - return wrapper - return decorator +__version__ = get_version(VERSION) def deprecated(func): - '''This is a decorator which can be used to mark functions + """ + This is a decorator which can be used to mark functions as deprecated. It will result in a warning being emitted - when the function is used.''' + when the function is used. + """ @wraps(func) def wrapper(*args, **kwargs): diff --git a/vbox_setup.sh b/vbox_setup.sh new file mode 100644 index 0000000..99f507d --- /dev/null +++ b/vbox_setup.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +apt-get update +apt-get install -y python-setuptools vim +easy_install pip +pip install virtualenvwrapper + +# Build the bash_profile +echo "source ~/.profile" >> /home/vagrant/.bash_profile +echo "source /usr/local/bin/virtualenvwrapper.sh" >> /home/vagrant/.bash_profile + +chown vagrant:vagrant /home/vagrant/.bash_profile