diff --git a/doc/source/conf.py b/doc/source/conf.py index b93008a..1b8358e 100755 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -22,7 +22,7 @@ sys.path.insert(0, os.path.abspath('../..')) # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', - #'sphinx.ext.intersphinx', + # 'sphinx.ext.intersphinx', 'oslosphinx' ] @@ -72,4 +72,4 @@ latex_documents = [ ] # Example configuration for intersphinx: refer to the Python standard library. -#intersphinx_mapping = {'http://docs.python.org/': None} +# intersphinx_mapping = {'http://docs.python.org/': None} diff --git a/requirements.txt b/requirements.txt index 4f6a277..7c48020 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ netaddr +configparser>=3.3.0 diff --git a/test-requirements.txt b/test-requirements.txt index 6d5294e..eca58ee 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,3 +4,5 @@ oslosphinx>=2.5.0,!=3.4.0 # Apache-2.0 sphinx>=1.2.1,!=1.3b1,<1.3 # BSD hacking>=0.10.2 +pytest>=2.8.0 +mock>=1.3.0 diff --git a/tests/test_inventory.py b/tests/test_inventory.py new file mode 100644 index 0000000..c9061cc --- /dev/null +++ b/tests/test_inventory.py @@ -0,0 +1,209 @@ +# Copyright 2016 Mirantis, Inc. +# +# 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 unittest + +from collections import OrderedDict +import sys + +path = "./utils/kargo" +if path not in sys.path: + sys.path.append(path) + +import inventory + + +class TestInventory(unittest.TestCase): + def setUp(self): + super(TestInventory, self).setUp() + self.data = ['10.90.3.2', '10.90.3.3', '10.90.3.4'] + self.inv = inventory.KargoInventory() + + def test_get_ip_from_opts(self): + optstring = "ansible_ssh_host=10.90.3.2 ip=10.90.3.2" + expected = "10.90.3.2" + result = self.inv.get_ip_from_opts(optstring) + self.assertEqual(expected, result) + + def test_get_ip_from_opts_invalid(self): + optstring = "notanaddr=value something random!chars:D" + self.assertRaisesRegexp(ValueError, "IP parameter not found", + self.inv.get_ip_from_opts, optstring) + + def test_ensure_required_groups(self): + groups = ['group1', 'group2'] + self.inv.ensure_required_groups(groups) + for group in groups: + self.assertTrue(group in self.inv.config.sections()) + + def test_get_host_id(self): + hostnames = ['node99', 'no99de01', '01node01', 'node1.domain', + 'node3.xyz123.aaa'] + expected = [99, 1, 1, 1, 3] + for hostname, expected in zip(hostnames, expected): + result = self.inv.get_host_id(hostname) + self.assertEqual(expected, result) + + def test_get_host_id_invalid(self): + bad_hostnames = ['node', 'no99de', '01node', 'node.111111'] + for hostname in bad_hostnames: + self.assertRaisesRegexp(ValueError, "Host name must end in an", + self.inv.get_host_id, hostname) + + def test_build_hostnames_add_one(self): + changed_hosts = ['10.90.0.2'] + expected = OrderedDict([('node1', + 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2')]) + result = self.inv.build_hostnames(changed_hosts) + self.assertEqual(expected, result) + + def test_build_hostnames_add_duplicate(self): + changed_hosts = ['10.90.0.2'] + expected = OrderedDict([('node1', + 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2')]) + self.inv.config['all'] = expected + result = self.inv.build_hostnames(changed_hosts) + self.assertEqual(expected, result) + + def test_build_hostnames_add_two(self): + changed_hosts = ['10.90.0.2', '10.90.0.3'] + expected = OrderedDict([ + ('node1', 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2'), + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3')]) + self.inv.config['all'] = OrderedDict() + result = self.inv.build_hostnames(changed_hosts) + self.assertEqual(expected, result) + + def test_build_hostnames_delete_first(self): + changed_hosts = ['-10.90.0.2'] + existing_hosts = OrderedDict([ + ('node1', 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2'), + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3')]) + self.inv.config['all'] = existing_hosts + expected = OrderedDict([ + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3')]) + result = self.inv.build_hostnames(changed_hosts) + self.assertEqual(expected, result) + + def test_exists_hostname_positive(self): + hostname = 'node1' + expected = True + existing_hosts = OrderedDict([ + ('node1', 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2'), + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3')]) + result = self.inv.exists_hostname(existing_hosts, hostname) + self.assertEqual(expected, result) + + def test_exists_hostname_negative(self): + hostname = 'node99' + expected = False + existing_hosts = OrderedDict([ + ('node1', 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2'), + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3')]) + result = self.inv.exists_hostname(existing_hosts, hostname) + self.assertEqual(expected, result) + + def test_exists_ip_positive(self): + ip = '10.90.0.2' + expected = True + existing_hosts = OrderedDict([ + ('node1', 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2'), + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3')]) + result = self.inv.exists_ip(existing_hosts, ip) + self.assertEqual(expected, result) + + def test_exists_ip_negative(self): + ip = '10.90.0.200' + expected = False + existing_hosts = OrderedDict([ + ('node1', 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2'), + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3')]) + result = self.inv.exists_ip(existing_hosts, ip) + self.assertEqual(expected, result) + + def test_delete_host_by_ip_positive(self): + ip = '10.90.0.2' + expected = OrderedDict([ + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3')]) + existing_hosts = OrderedDict([ + ('node1', 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2'), + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3')]) + self.inv.delete_host_by_ip(existing_hosts, ip) + self.assertEqual(expected, existing_hosts) + + def test_delete_host_by_ip_negative(self): + ip = '10.90.0.200' + existing_hosts = OrderedDict([ + ('node1', 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2'), + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3')]) + self.assertRaisesRegexp(ValueError, "Unable to find host", + self.inv.delete_host_by_ip, existing_hosts, ip) + + def test_purge_invalid_hosts(self): + proper_hostnames = ['node1', 'node2'] + bad_host = 'doesnotbelong2' + existing_hosts = OrderedDict([ + ('node1', 'ansible_ssh_host=10.90.0.2 ip=10.90.0.2'), + ('node2', 'ansible_ssh_host=10.90.0.3 ip=10.90.0.3'), + ('doesnotbelong2', 'whateveropts=ilike')]) + self.inv.config['all'] = existing_hosts + self.inv.purge_invalid_hosts(proper_hostnames) + self.assertTrue(bad_host not in self.inv.config['all'].keys()) + + def test_add_host_to_group(self): + group = 'etcd' + host = 'node1' + opts = 'ip=10.90.0.2' + + self.inv.add_host_to_group(group, host, opts) + self.assertEqual(self.inv.config[group].get(host), opts) + + def test_set_kube_master(self): + group = 'kube-master' + host = 'node1' + + self.inv.set_kube_master([host]) + self.assertTrue(host in self.inv.config[group]) + + def test_set_all(self): + group = 'all' + hosts = OrderedDict([ + ('node1', 'opt1'), + ('node2', 'opt2')]) + + self.inv.set_all(hosts) + for host, opt in hosts.items(): + self.assertEqual(self.inv.config[group].get(host), opt) + + def test_set_k8s_cluster(self): + group = 'k8s-cluster:children' + expected_hosts = ['kube-node', 'kube-master'] + + self.inv.set_k8s_cluster() + for host in expected_hosts: + self.assertTrue(host in self.inv.config[group]) + + def test_set_kube_node(self): + group = 'kube-node' + host = 'node1' + + self.inv.set_kube_node([host]) + self.assertTrue(host in self.inv.config[group]) + + def test_set_etcd(self): + group = 'etcd' + host = 'node1' + + self.inv.set_etcd([host]) + self.assertTrue(host in self.inv.config[group]) diff --git a/tox.ini b/tox.ini index c448871..0718d18 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,17 @@ [tox] minversion = 1.6 skipsdist = True -envlist = bashate,pep8 +envlist = bashate, pep8, py27 [testenv] +whitelist_externals = py.test +usedevelop = True deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt +setenv = VIRTUAL_ENV={envdir} +passenv = http_proxy HTTP_PROXY https_proxy HTTPS_PROXY no_proxy NO_PROXY +commands = py.test -vv #{posargs:tests} [testenv:doc8] commands = doc8 doc @@ -38,7 +43,7 @@ commands = bash -c "find {toxinidir} -type f -name '*.sh' -not -path '*/.tox/*' usedevelop = False whitelist_externals = bash commands = - bash -c "find {toxinidir} -type f name '*.py' -print0 | xargs -0 flake8" + bash -c "find {toxinidir}/* -type f -name '*.py' -print0 | xargs -0 flake8" [testenv:venv] commands = {posargs} diff --git a/utils/jenkins/kargo_deploy.sh b/utils/jenkins/kargo_deploy.sh index 8c05342..6da8a6a 100755 --- a/utils/jenkins/kargo_deploy.sh +++ b/utils/jenkins/kargo_deploy.sh @@ -98,15 +98,13 @@ for slaveip in ${SLAVE_IPS[@]}; do # Add VM label: ssh $SSH_OPTIONS $ADMIN_USER@$slaveip "echo $VM_LABEL > /home/${ADMIN_USER}/vm_label" - deploy_args+=" node${current_slave}[ansible_ssh_host=${slaveip},ip=${slaveip}]" + inventory_args+=" ${slaveip}" ((current_slave++)) done echo "Setting up required dependencies..." ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP sudo apt-get update -ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP sudo apt-get install -y git python-dev python3-dev python-pip gcc libssl-dev libffi-dev vim software-properties-common -ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP "sudo easy_install setuptools" -ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP "sudo pip install 'cryptography>=1.3.2' 'cffi>=1.6.0'" +ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP sudo apt-get install -y git vim software-properties-common echo "Setting up ansible..." case $NODE_BASE_OS in @@ -126,18 +124,15 @@ case $NODE_BASE_OS in esac ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP 'sudo sh -c "apt-get install -y ansible"' -echo "Setting up kargo-cli..." -ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP git clone https://github.com/kubespray/kargo-cli.git -# Workaround for kargo prepare bug -ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP "sudo sh -c 'cd kargo-cli && git checkout 4fabe51301ba805f57024d5a511c78f58b6d9aa9'" -ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP "sudo sh -c 'cd kargo-cli && python setup.py install'" - echo "Checking out kargo playbook..." ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP git clone $KARGO_REPO ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP "sh -c 'cd kargo && git checkout $KARGO_COMMIT'" -echo "Preparing kargo node..." -ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP kargo prepare -y --noclone --nodes $deploy_args +echo "Setting up primary node for deployment..." +scp $SSH_OPTIONS ${BASH_SOURCE%/*}/../kargo/inventory.py $ADMIN_USER@$ADMIN_IP:inventory.py +ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP chmod +x inventory.py +ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP env CONFIG_FILE=kargo/inventory/inventory.cfg python3 inventory.py ${SLAVE_IPS[@]} + cat $WORKSPACE/id_rsa | ssh $SSH_OPTIONS $ADMIN_USER@${SLAVE_IPS[0]} "cat - > .ssh/id_rsa" ssh $SSH_OPTIONS $ADMIN_USER@$ADMIN_IP chmod 600 .ssh/id_rsa diff --git a/utils/kargo/inventory.py b/utils/kargo/inventory.py new file mode 100644 index 0000000..d65bb48 --- /dev/null +++ b/utils/kargo/inventory.py @@ -0,0 +1,184 @@ +#!/usr/bin/python +# Usage: kargo_inventory.py ip1 [ip2 ...] +# Examples: kargo_inventory.py 10.10.1.3 10.10.1.4 10.10.1.5 +# +# Advanced usage: +# Add another host after initial creation: kargo_inventory.py 10.10.1.5 +# Delete a host: kargo_inventory.py -10.10.1.3 +# Delete a host by id: kargo_inventory.py -node1 + +from collections import OrderedDict +try: + import configparser +except ImportError: + import ConfigParser as configparser + +import os +import re +import sys + +ROLES = ['kube-master', 'all', 'k8s-cluster:children', 'kube-node', 'etcd'] +PROTECTED_NAMES = ROLES +_boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True, + '0': False, 'no': False, 'false': False, 'off': False} + + +def get_var_as_bool(name, default): + value = os.environ.get(name, '') + return _boolean_states.get(value.lower(), default) + +CONFIG_FILE = os.environ.get("CONFIG_FILE", "./inventory.cfg") +DEBUG = get_var_as_bool("DEBUG", True) +HOST_PREFIX = os.environ.get("HOST_PREFIX", "node") + + +class KargoInventory(object): + + def __init__(self, changed_hosts=None, config_file=None): + self.config = configparser.ConfigParser(allow_no_value=True, + delimiters=('\t', ' ')) + if config_file: + self.config.read(config_file) + + self.ensure_required_groups(ROLES) + + if changed_hosts: + self.hosts = self.build_hostnames(changed_hosts) + self.purge_invalid_hosts(self.hosts.keys(), PROTECTED_NAMES) + self.set_kube_master(list(self.hosts.keys())[:2]) + self.set_all(self.hosts) + self.set_k8s_cluster() + self.set_kube_node(self.hosts.keys()) + self.set_etcd(list(self.hosts.keys())[:3]) + + if config_file: + with open(config_file, 'w') as f: + self.config.write(f) + + def debug(self, msg): + if DEBUG: + print("DEBUG: {0}".format(msg)) + + def get_ip_from_opts(self, optstring): + opts = optstring.split(' ') + for opt in opts: + if '=' not in opt: + continue + k, v = opt.split('=') + if k == "ip": + return v + raise ValueError("IP parameter not found in options") + + def ensure_required_groups(self, groups): + for group in groups: + try: + self.config.add_section(group) + except configparser.DuplicateSectionError: + pass + + def get_host_id(self, host): + '''Returns integer host ID (without padding) from a given hostname.''' + try: + short_hostname = host.split('.')[0] + return int(re.findall("\d+$", short_hostname)[-1]) + except IndexError: + raise ValueError("Host name must end in an integer") + + def build_hostnames(self, changed_hosts): + existing_hosts = OrderedDict() + highest_host_id = 0 + try: + for host, opts in self.config.items('all'): + existing_hosts[host] = opts + host_id = self.get_host_id(host) + if host_id > highest_host_id: + highest_host_id = host_id + except configparser.NoSectionError: + pass + + # FIXME(mattymo): Fix condition where delete then add reuses highest id + next_host_id = highest_host_id + 1 + + all_hosts = existing_hosts.copy() + for host in changed_hosts: + if host[0] == "-": + realhost = host[1:] + if self.exists_hostname(all_hosts, realhost): + self.debug("Marked {0} for deletion.".format(realhost)) + all_hosts.pop(realhost) + elif self.exists_ip(all_hosts, realhost): + self.debug("Marked {0} for deletion.".format(realhost)) + self.delete_host_by_ip(all_hosts, realhost) + elif host[0].isdigit(): + if self.exists_hostname(all_hosts, host): + self.debug("Skipping existing host {0}.".format(host)) + continue + elif self.exists_ip(all_hosts, host): + self.debug("Skipping existing host {0}.".format(host)) + continue + + next_host = "{0}{1}".format(HOST_PREFIX, next_host_id) + next_host_id += 1 + all_hosts[next_host] = "ansible_ssh_host={0} ip={1}".format( + host, host) + elif host[0].isalpha(): + raise Exception("Adding hosts by hostname is not supported.") + + return all_hosts + + def exists_hostname(self, existing_hosts, hostname): + return hostname in existing_hosts.keys() + + def exists_ip(self, existing_hosts, ip): + for host_opts in existing_hosts.values(): + if ip == self.get_ip_from_opts(host_opts): + return True + return False + + def delete_host_by_ip(self, existing_hosts, ip): + for hostname, host_opts in existing_hosts.items(): + if ip == self.get_ip_from_opts(host_opts): + del existing_hosts[hostname] + return + raise ValueError("Unable to find host by IP: {0}".format(ip)) + + def purge_invalid_hosts(self, hostnames, protected_names=[]): + for role in self.config.sections(): + for host, _ in self.config.items(role): + if host not in hostnames and host not in protected_names: + self.debug("Host {0} removed from role {1}".format(host, + role)) + self.config.remove_option(role, host) + + def add_host_to_group(self, group, host, opts=""): + self.debug("adding host {0} to group {1}".format(host, group)) + self.config.set(group, host, opts) + + def set_kube_master(self, hosts): + for host in hosts: + self.add_host_to_group('kube-master', host) + + def set_all(self, hosts): + for host, opts in hosts.items(): + self.add_host_to_group('all', host, opts) + + def set_k8s_cluster(self): + self.add_host_to_group('k8s-cluster:children', 'kube-node') + self.add_host_to_group('k8s-cluster:children', 'kube-master') + + def set_kube_node(self, hosts): + for host in hosts: + self.add_host_to_group('kube-node', host) + + def set_etcd(self, hosts): + for host in hosts: + self.add_host_to_group('etcd', host) + + +def main(argv=None): + if not argv: + argv = sys.argv[1:] + KargoInventory(argv, CONFIG_FILE) + +if __name__ == "__main__": + sys.exit(main()) diff --git a/utils/packer/debian8.5/scripts/packages.sh b/utils/packer/debian8.5/scripts/packages.sh index 15e7825..6b2fb95 100644 --- a/utils/packer/debian8.5/scripts/packages.sh +++ b/utils/packer/debian8.5/scripts/packages.sh @@ -50,5 +50,4 @@ python-setuptools apt-get -y install $PACKAGES #Installer/CCP tools -pip install git+https://github.com/kubespray/kargo-cli.git --upgrade pip install git+https://git.openstack.org/openstack/fuel-ccp.git --upgrade diff --git a/utils/packer/ubuntu16.04/scripts/packages.sh b/utils/packer/ubuntu16.04/scripts/packages.sh index a6e5eb1..33b3860 100644 --- a/utils/packer/ubuntu16.04/scripts/packages.sh +++ b/utils/packer/ubuntu16.04/scripts/packages.sh @@ -25,5 +25,4 @@ ansible apt-get -y install $PACKAGES #Installer/CCP tools -pip install git+https://github.com/kubespray/kargo-cli.git --upgrade pip install git+https://git.openstack.org/openstack/fuel-ccp.git --upgrade