diff --git a/README.md b/README.md index 8e37a66..4f79011 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,40 @@ sudo snap enable microstack ## Raising a Bug Please report bugs to the microstack project on launchpad: https://bugs.launchpad.net/microstack + +## Clustering Preview + +The latests --edge version of the clustering snap contains a preview of microstack's clustering functionality. If you're interested in building a small "edge" cloud with microstack, please take a look at the notes below. Keep in mind that this is preview functionality. Interfaces may not be stable, and the security of the preview is light, and not suitable for production use! + +To setup a cluster, you first must setup a control node. Do so with the following commands: + +``` +sudo snap install microstack +sudo microstack.init +``` + +Answer the questions in the interactive prompt as follows: + +``` +Clustering: yes +Role: control +IP Address: Note and accept the default +``` + +On a second machine, run: + +``` +sudo snap install microstack +sudo microstack.init +``` + +Answer the questions in the interactive prompt as follows: + +``` +Setup clustering: yes +Role: compute +Control IP: the ip address noted above +Compute IP: accept the default +``` + +You should now have a small, two node cloud, with the first node serving as both the control plane and a hypvervisor, and the second node serving as a hypervisor. You can create vms on both. diff --git a/snap-overlay/bin/setup-br-ex b/snap-overlay/bin/setup-br-ex index 61c364e..0bb507c 100755 --- a/snap-overlay/bin/setup-br-ex +++ b/snap-overlay/bin/setup-br-ex @@ -9,7 +9,7 @@ set -ex -extcidr=$(snapctl get questions.ext-cidr) +extcidr=$(snapctl get config.network.ext-cidr) # Create external integration bridge ovs-vsctl --retry --may-exist add-br br-ex diff --git a/snap-overlay/images/cirros-0.4.0-x86_64-disk.img b/snap-overlay/images/cirros-0.4.0-x86_64-disk.img new file mode 100644 index 0000000..2e39ad8 Binary files /dev/null and b/snap-overlay/images/cirros-0.4.0-x86_64-disk.img differ diff --git a/snap-overlay/snap-openstack.yaml b/snap-overlay/snap-openstack.yaml index f3ea3c7..a901837 100644 --- a/snap-overlay/snap-openstack.yaml +++ b/snap-overlay/snap-openstack.yaml @@ -52,15 +52,22 @@ setup: neutron.keystone.conf.j2: "{snap_common}/etc/neutron/neutron.conf.d/keystone.conf" neutron.nova.conf.j2: "{snap_common}/etc/neutron/neutron.conf.d/nova.conf" neutron.database.conf.j2: "{snap_common}/etc/neutron/neutron.conf.d/database.conf" + neutron.conf.d.rabbitmq.conf.j2: "{snap_common}/etc/neutron/neutron.conf.d/rabbitmq.conf" chmod: "{snap_common}/instances": 0755 "{snap_common}/etc/microstack.rc": 0644 snap-config-keys: - ospassword: 'questions.os-password' - extgateway: 'questions.ext-gateway' - extcidr: 'questions.ext-cidr' - dns: 'questions.dns' + ospassword: 'config.credentials.os-password' + nova_password: 'config.credentials.nova-password' + neutron_password: 'config.credentials.neutron-password' + placement_password: 'config.credentials.placement-password' + glance_password: 'config.credentials.glance-password' + control_ip: 'config.network.control-ip' + compute_ip: 'config.network.compute-ip' + extgateway: 'config.network.ext-gateway' + extcidr: 'config.network.ext-cidr' + dns: 'config.network.dns' entry_points: keystone-manage: binary: "{snap}/bin/keystone-manage" diff --git a/snap-overlay/templates/clustering-nginx.conf b/snap-overlay/templates/clustering-nginx.conf new file mode 100644 index 0000000..a25e454 --- /dev/null +++ b/snap-overlay/templates/clustering-nginx.conf @@ -0,0 +1,10 @@ +server { + listen 8011; + error_log syslog:server=unix:/dev/log; + access_log syslog:server=unix:/dev/log; + location / { + include {{ snap }}/usr/conf/uwsgi_params; + uwsgi_param SCRIPT_NAME ''; + uwsgi_pass unix://{{ snap_common }}/run/keystone-api.sock; + } +} diff --git a/snap-overlay/templates/glance.conf.d.keystone.conf.j2 b/snap-overlay/templates/glance.conf.d.keystone.conf.j2 index 34ef497..cf7e5d2 100644 --- a/snap-overlay/templates/glance.conf.d.keystone.conf.j2 +++ b/snap-overlay/templates/glance.conf.d.keystone.conf.j2 @@ -1,13 +1,13 @@ [keystone_authtoken] -auth_uri = http://{{ extgateway }}:5000 -auth_url = http://{{ extgateway }}:5000 -memcached_servers = {{ extgateway }}:11211 +auth_uri = http://{{ control_ip }}:5000 +auth_url = http://{{ control_ip }}:5000 +memcached_servers = {{ control_ip }}:11211 auth_type = password project_domain_name = default user_domain_name = default project_name = service username = glance -password = glance +password = {{ glance_password }} [paste_deploy] flavor = keystone diff --git a/snap-overlay/templates/glance.database.conf.j2 b/snap-overlay/templates/glance.database.conf.j2 index 016fa6b..bb9a32b 100644 --- a/snap-overlay/templates/glance.database.conf.j2 +++ b/snap-overlay/templates/glance.database.conf.j2 @@ -1,2 +1,2 @@ [database] -connection = mysql+pymysql://glance:glance@{{ extgateway }}/glance +connection = mysql+pymysql://glance:glance@{{ control_ip }}/glance diff --git a/snap-overlay/templates/keystone.database.conf.j2 b/snap-overlay/templates/keystone.database.conf.j2 index f9a2da4..ad29228 100644 --- a/snap-overlay/templates/keystone.database.conf.j2 +++ b/snap-overlay/templates/keystone.database.conf.j2 @@ -1,2 +1,2 @@ [database] -connection = mysql+pymysql://keystone:keystone@{{ extgateway }}/keystone +connection = mysql+pymysql://keystone:keystone@{{ control_ip }}/keystone diff --git a/snap-overlay/templates/microstack.rc.j2 b/snap-overlay/templates/microstack.rc.j2 index e7eb75b..1522f9b 100644 --- a/snap-overlay/templates/microstack.rc.j2 +++ b/snap-overlay/templates/microstack.rc.j2 @@ -3,7 +3,7 @@ export OS_USER_DOMAIN_NAME=default export OS_PROJECT_NAME=admin export OS_USERNAME=admin export OS_PASSWORD={{ ospassword }} -export OS_AUTH_URL=http://{{ extgateway }}:5000 +export OS_AUTH_URL=http://{{ control_ip }}:5000 export OS_IDENTITY_API_VERSION=3 export OS_IMAGE_API_VERSION=2 diff --git a/snap-overlay/templates/neutron.conf.d.rabbitmq.conf.j2 b/snap-overlay/templates/neutron.conf.d.rabbitmq.conf.j2 new file mode 100644 index 0000000..95406d0 --- /dev/null +++ b/snap-overlay/templates/neutron.conf.d.rabbitmq.conf.j2 @@ -0,0 +1,2 @@ +[DEFAULT] +transport_url = rabbit://openstack:rabbitmq@{{ control_ip }} diff --git a/snap-overlay/templates/neutron.database.conf.j2 b/snap-overlay/templates/neutron.database.conf.j2 index 710ab68..8d22851 100644 --- a/snap-overlay/templates/neutron.database.conf.j2 +++ b/snap-overlay/templates/neutron.database.conf.j2 @@ -1,2 +1,2 @@ [database] -connection = mysql+pymysql://neutron:neutron@{{ extgateway }}/neutron +connection = mysql+pymysql://neutron:neutron@{{ control_ip }}/neutron diff --git a/snap-overlay/templates/neutron.keystone.conf.j2 b/snap-overlay/templates/neutron.keystone.conf.j2 index 2898c4d..11ec56c 100644 --- a/snap-overlay/templates/neutron.keystone.conf.j2 +++ b/snap-overlay/templates/neutron.keystone.conf.j2 @@ -2,12 +2,12 @@ auth_strategy = keystone [keystone_authtoken] -auth_uri = http://{{ extgateway }}:5000 -auth_url = http://{{ extgateway }}:5000 -memcached_servers = {{ extgateway }}:11211 +auth_uri = http://{{ control_ip }}:5000 +auth_url = http://{{ control_ip }}:5000 +memcached_servers = {{ control_ip }}:11211 auth_type = password project_domain_name = default user_domain_name = default project_name = service username = neutron -password = neutron +password = {{ neutron_password }} diff --git a/snap-overlay/templates/neutron.nova.conf.j2 b/snap-overlay/templates/neutron.nova.conf.j2 index b0c25b6..495dd6f 100644 --- a/snap-overlay/templates/neutron.nova.conf.j2 +++ b/snap-overlay/templates/neutron.nova.conf.j2 @@ -3,11 +3,11 @@ notify_nova_on_port_status_changes = True notify_nova_on_port_data_changes = True [nova] -auth_url = http://{{ extgateway }}:5000 +auth_url = http://{{ control_ip }}:5000 auth_type = password project_domain_name = default user_domain_name = default region_name = microstack project_name = service username = nova -password = nova +password = {{ nova_password }} diff --git a/snap-overlay/templates/nova.conf.d.database.conf.j2 b/snap-overlay/templates/nova.conf.d.database.conf.j2 index c11a22c..3eaddf9 100644 --- a/snap-overlay/templates/nova.conf.d.database.conf.j2 +++ b/snap-overlay/templates/nova.conf.d.database.conf.j2 @@ -1,5 +1,5 @@ [database] -connection = mysql+pymysql://nova:nova@{{ extgateway }}/nova +connection = mysql+pymysql://nova:nova@{{ control_ip }}/nova [api_database] -connection = mysql+pymysql://nova_api:nova_api@{{ extgateway }}/nova_api +connection = mysql+pymysql://nova_api:nova_api@{{ control_ip }}/nova_api diff --git a/snap-overlay/templates/nova.conf.d.glance.conf.j2 b/snap-overlay/templates/nova.conf.d.glance.conf.j2 index 5ef4e17..2373f27 100644 --- a/snap-overlay/templates/nova.conf.d.glance.conf.j2 +++ b/snap-overlay/templates/nova.conf.d.glance.conf.j2 @@ -1,2 +1,2 @@ [glance] -api_servers = http://{{ extgateway }}:9292 +api_servers = http://{{ control_ip }}:9292 diff --git a/snap-overlay/templates/nova.conf.d.keystone.conf.j2 b/snap-overlay/templates/nova.conf.d.keystone.conf.j2 index d0d64c2..ab62c88 100644 --- a/snap-overlay/templates/nova.conf.d.keystone.conf.j2 +++ b/snap-overlay/templates/nova.conf.d.keystone.conf.j2 @@ -1,13 +1,13 @@ [keystone_authtoken] -auth_uri = http://{{ extgateway }}:5000 -auth_url = http://{{ extgateway }}:5000 -memcached_servers = {{ extgateway }}:11211 +auth_uri = http://{{ control_ip }}:5000 +auth_url = http://{{ control_ip }}:5000 +memcached_servers = {{ control_ip }}:11211 auth_type = password project_domain_name = default user_domain_name = default project_name = service username = nova -password = nova +password = {{ nova_password }} [paste_deploy] flavor = keystone diff --git a/snap-overlay/templates/nova.conf.d.neutron.conf.j2 b/snap-overlay/templates/nova.conf.d.neutron.conf.j2 index f6c0505..6e609f9 100644 --- a/snap-overlay/templates/nova.conf.d.neutron.conf.j2 +++ b/snap-overlay/templates/nova.conf.d.neutron.conf.j2 @@ -1,13 +1,13 @@ [neutron] -url = http://{{ extgateway }}:9696 -auth_url = http://{{ extgateway }}:5000 -memcached_servers = {{ extgateway }}:11211 +url = http://{{ control_ip }}:9696 +auth_url = http://{{ control_ip }}:5000 +memcached_servers = {{ control_ip }}:11211 auth_type = password project_domain_name = default user_domain_name = default region_name = microstack project_name = service username = neutron -password = neutron +password = {{ neutron_password }} service_metadata_proxy = True metadata_proxy_shared_secret = supersecret diff --git a/snap-overlay/templates/nova.conf.d.nova-placement.conf.j2 b/snap-overlay/templates/nova.conf.d.nova-placement.conf.j2 index 8089679..c6c33c3 100644 --- a/snap-overlay/templates/nova.conf.d.nova-placement.conf.j2 +++ b/snap-overlay/templates/nova.conf.d.nova-placement.conf.j2 @@ -4,6 +4,6 @@ project_domain_name = default project_name = service auth_type = password user_domain_name = default -auth_url = http://{{ extgateway }}:5000 +auth_url = http://{{ control_ip }}:5000 username = placement -password = placement +password = {{ placement_password }} diff --git a/snap-overlay/templates/nova.conf.d.rabbitmq.conf.j2 b/snap-overlay/templates/nova.conf.d.rabbitmq.conf.j2 index b1988c0..95406d0 100644 --- a/snap-overlay/templates/nova.conf.d.rabbitmq.conf.j2 +++ b/snap-overlay/templates/nova.conf.d.rabbitmq.conf.j2 @@ -1,2 +1,2 @@ [DEFAULT] -transport_url = rabbit://openstack:rabbitmq@{{ extgateway }} +transport_url = rabbit://openstack:rabbitmq@{{ control_ip }} diff --git a/snap/hooks/install b/snap/hooks/install index c74ae91..fc09d03 100755 --- a/snap/hooks/install +++ b/snap/hooks/install @@ -1,29 +1,62 @@ #!/bin/bash set -ex -# Config -# Set default answers to the questions that microstack.init asks. +# Initialize Config # TODO: put this in a nice yaml format, and parse it. + +# General snap-wide settings snapctl set \ - questions.ip-forwarding=true \ - questions.dns=1.1.1.1 \ - questions.ext-gateway=10.20.20.1 \ - questions.ext-cidr=10.20.20.1/24 \ - questions.os-password=keystone \ - questions.rabbit-mq=true \ - questions.database-setup=true \ - questions.nova-setup=true \ - questions.neutron-setup=true \ - questions.glance-setup=true \ - questions.key-pair="id_microstack" \ - questions.security-rules=true \ - questions.post-setup=true \ + config.clustered=false \ + config.post-setup=true \ + ; + +# Networking related settings. +snapctl set \ + config.network.dns=1.1.1.1 \ + config.network.ext-gateway=10.20.20.1 \ + config.network.control-ip=10.20.20.1 \ + config.network.compute-ip=10.20.20.1 \ + config.network.ext-cidr=10.20.20.1/24 \ + config.network.security-rules=true \ + ; + +# Passwords, certs, etc. +snapctl set \ + config.credentials.os-password=keystone \ + config.credentials.key-pair=id_microstack \ + config.credentials.nova-password=nova \ + config.credentials.neutron-password=neutron \ + config.credentials.placement-password=placement \ + config.credentials.glance-password=glance \ + ; + +# Host optimizations and fixes. +snapctl set \ + config.host.ip-forwarding=true \ + config.host.check-qemu=true \ + ; + +# Enable or disable groups of services. +snapctl set \ + config.services.control-plane=true \ + config.services.hypervisor=true \ + ; + +# Clustering roles +snapctl set \ + cluster.role=control \ + cluster.password=null \ + ; # MySQL snapshot for speedy install # snapshot is a mysql data dir with # rocky keystone,nova,glance,neutron dbs. mkdir -p ${SNAP_COMMON}/lib +# Put cirros (and potentially other) images in a user writeable place. +mkdir -p ${SNAP_COMMON}/images +cp ${SNAP}/images/* ${SNAP_COMMON}/images/ + # Install conf.d configuration from snap for db etc echo "Installing configuration for OpenStack Services" for project in neutron nova keystone glance; do diff --git a/snapcraft.yaml b/snapcraft.yaml index 7d8367a..6f50647 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -307,6 +307,19 @@ apps: # plugs: # - network + # Cluster + cluster-server: + command: flask run -p 10002 --host=0.0.0.0 # TODO: run as a uwsgi app + daemon: simple + environment: + LC_ALL: C.UTF-8 # Makes flask happy + LANG: C.UTF-8 # Makes flask happy + FLASK_APP: ${SNAP}/lib/python3.6/site-packages/cluster/daemon.py + + join: + command: python3 ${SNAP}/lib/python3.6/site-packages/cluster/client.py + + parts: # Add Ubuntu Cloud Archive sources. # Allows us to fetch things such as updated libvirt. @@ -828,3 +841,11 @@ parts: requirements: - requirements.txt source: tools/launch + + # Clustering client and server + cluster: + plugin: python + python-version: python3 + requirements: + - requirements.txt + source: tools/cluster diff --git a/tests/test_basic.py b/tests/test_basic.py index ae5c56b..399ccd3 100755 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -14,10 +14,8 @@ Web IDE. """ -import json import os import sys -import time import unittest import xvfbwrapper from selenium import webdriver @@ -52,7 +50,7 @@ class TestBasics(Framework): """ launch = '/snap/bin/microstack.launch' - openstack = '/snap/bin/microstack.openstack' + # openstack = '/snap/bin/microstack.openstack' print("Testing microstack.launch ...") @@ -68,45 +66,6 @@ class TestBasics(Framework): # Endpoints should not contain localhost self.assertFalse("localhost" in endpoints) - # Verify that microstack.launch completed successfully - - # Ping the instance - ip = None - servers = check_output(*self.PREFIX, openstack, - 'server', 'list', '--format', 'json') - servers = json.loads(servers) - for server in servers: - if server['Name'] == 'breakfast': - ip = server['Networks'].split(",")[1].strip() - break - - self.assertTrue(ip) - - pings = 1 - max_pings = 600 # ~10 minutes! - while not call(*self.PREFIX, 'ping', '-c1', '-w1', ip): - pings += 1 - if pings > max_pings: - self.assertFalse(True, msg='Max pings reached!') - - print("Testing instances' ability to connect to the Internet") - # Test Internet connectivity - attempts = 1 - max_attempts = 300 # ~10 minutes! - username = check_output(*self.PREFIX, 'whoami') - - while not call( - *self.PREFIX, - 'ssh', - '-oStrictHostKeyChecking=no', - '-i', '/home/{}/.ssh/id_microstack'.format(username), - 'cirros@{}'.format(ip), - '--', 'ping', '-c1', '91.189.94.250'): - attempts += 1 - if attempts > max_attempts: - self.assertFalse(True, msg='Unable to access the Internet!') - time.sleep(1) - if 'multipass' in self.PREFIX: print("Opening {}:80 up to the outside world".format( self.HORIZON_IP)) diff --git a/tests/test_cluster.py b/tests/test_cluster.py new file mode 100755 index 0000000..815977b --- /dev/null +++ b/tests/test_cluster.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +""" +cluster_test.py + +This is a test to verify that we can setup a small, two node cluster. + +The host running this test must have at least 16GB of RAM, four cpu +cores, a large amount of disk space, and the ability to run multipass +vms. + +""" + +import json +import os +import petname +import sys +import unittest + +sys.path.append(os.getcwd()) + +from tests.framework import Framework, check, check_output, call # noqa E402 + + +os.environ['MULTIPASS'] = 'true' # TODO better way to do this. + + +class TestCluster(Framework): + + INIT_FLAG = 'control' + + def _compute_node(self, channel='dangerous'): + """Make a compute node. + + TODO: refactor framework so that we can fold a lot of this + into the parent framework. There's a lot of dupe code here. + + """ + machine = petname.generate() + prefix = ['multipass', 'exec', machine, '--'] + + check('multipass', 'launch', '--cpus', '2', '--mem', '8G', + self.DISTRO, '--name', machine) + + check('multipass', 'copy-files', self.SNAP, '{}:'.format(machine)) + check(*prefix, 'sudo', 'snap', 'install', '--classic', + '--{}'.format(channel), self.SNAP) + + return machine, prefix + + def test_cluster(self): + + # After the setUp step, we should have a control node running + # in a multipass vm. Let's look up its cluster password and ip + # address. + + openstack = '/snap/bin/microstack.openstack' + + cluster_password = check_output(*self.PREFIX, 'sudo', 'snap', + 'get', 'microstack', + 'config.cluster.password') + control_ip = check_output(*self.PREFIX, 'sudo', 'snap', + 'get', 'microstack', + 'config.network.control-ip') + + self.assertTrue(cluster_password) + self.assertTrue(control_ip) + + compute_machine, compute_prefix = self._compute_node() + + # TODO add the following to args for init + check(*compute_prefix, 'sudo', 'snap', 'set', 'microstack', + 'config.network.control-ip={}'.format(control_ip)) + + check(*compute_prefix, 'sudo', 'microstack.init', '--compute', + '--cluster-password', cluster_password, '--debug') + + # Verify that our services look setup properly on compute node. + services = check_output( + *compute_prefix, 'systemctl', 'status', 'snap.microstack.*', + '--no-page') + + self.assertTrue('nova-compute' in services) + self.assertFalse('keystone-' in services) + + check(*compute_prefix, '/snap/bin/microstack.launch', 'cirros', + '--name', 'breakfast', '--retry', + '--availability-zone', 'nova:{}'.format(compute_machine)) + + # TODO: verify horizon dashboard on control node. + + # Verify endpoints + compute_ip = check_output(*compute_prefix, 'sudo', 'snap', + 'get', 'microstack', + 'config.network.compute-ip') + self.assertFalse(compute_ip == control_ip) + + # Ping the instance + ip = None + servers = check_output(*compute_prefix, openstack, + 'server', 'list', '--format', 'json') + servers = json.loads(servers) + for server in servers: + if server['Name'] == 'breakfast': + ip = server['Networks'].split(",")[1].strip() + break + + self.assertTrue(ip) + + pings = 1 + max_pings = 60 # ~1 minutes + # Ping the machine from the control node (we don't have + # networking wired up for the other nodes). + while not call(*self.PREFIX, 'ping', '-c1', '-w1', ip): + pings += 1 + if pings > max_pings: + self.assertFalse( + True, + msg='Max pings reached for instance on {}!'.format( + compute_machine)) + + self.passed = True + + # Compute machine cleanup + check('sudo', 'multipass', 'delete', compute_machine) + + +if __name__ == '__main__': + # Run our tests, ignoring deprecation warnings and warnings about + # unclosed sockets. (TODO: setup a selenium server so that we can + # move from PhantomJS, which is deprecated, to to Selenium headless.) + unittest.main(warnings='ignore') diff --git a/tests/test_control.py b/tests/test_control.py index 30c371f..7023a5a 100755 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -26,6 +26,10 @@ class TestControlNode(Framework): INIT_FLAG = 'control' def test_control_node(self): + """A control node has all services running, so this shouldn't be any + different than our standard setup. + + """ print("Checking output of services ...") services = check_output( @@ -35,7 +39,6 @@ class TestControlNode(Framework): print("services: @@@") print(services) - self.assertFalse('nova-' in services) self.assertTrue('neutron-' in services) self.assertTrue('keystone-' in services) diff --git a/tools/cluster/cluster/__init__.py b/tools/cluster/cluster/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/cluster/cluster/client.py b/tools/cluster/cluster/client.py new file mode 100644 index 0000000..3334566 --- /dev/null +++ b/tools/cluster/cluster/client.py @@ -0,0 +1,41 @@ +import json + +import requests + +from cluster.shell import check, check_output, write_tunnel_config + + +def join(): + """Join an existing cluster as a compute node.""" + config = json.loads(check_output('snapctl', 'get', 'config')) + + password = config['cluster']['password'] + control_ip = config['network']['control-ip'] + my_ip = config['network']['compute-ip'] + + if not password: + raise Exception("No cluster password specified!") + + resp = requests.post( + 'http://{}:10002/join'.format(control_ip), + json={'password': password, 'ip_address': my_ip}) + if resp.status_code != 200: + # TODO better error and formatting. + raise Exception('Failed to get info from control node: {}'.format( + resp.json)) + resp = resp.json() + + # TODO: add better error handling to the below + os_password = resp['config']['credentials']['os-password'] + + # Write out tunnel config and restart neutron openvswitch agent. + write_tunnel_config(my_ip) + check('snapctl', 'restart', 'microstack.neutron-openvswitch-agent') + + # Set passwords and such + check('snapctl', 'set', 'config.credentials.os-password={}'.format( + os_password)) + + +if __name__ == '__main__': + join() diff --git a/tools/cluster/cluster/daemon.py b/tools/cluster/cluster/daemon.py new file mode 100644 index 0000000..867d4a2 --- /dev/null +++ b/tools/cluster/cluster/daemon.py @@ -0,0 +1,55 @@ +import json + +from flask import Flask, request + +from cluster.shell import check, check_output, write_tunnel_config + + +app = Flask(__name__) + + +class Unauthorized(Exception): + pass + + +def join_info(password, ip_address): + our_password = check_output('snapctl', 'get', 'config.cluster.password') + + if password.strip() != our_password.strip(): + raise Unauthorized() + + # Load config + # TODO: be selective about what we return. For now, we just get everything. + config = json.loads(check_output('snapctl', 'get', 'config')) + + # Write out tunnel config and restart neutron openvswitch agent. + write_tunnel_config(config['network']['control-ip']) + check('snapctl', 'restart', 'microstack.neutron-openvswitch-agent') + + info = {'config': config} + return info + + +@app.route('/') +def home(): + status = { + 'status': 'running', + 'info': 'Microstack clustering daemon.' + + } + return json.dumps(status) + + +@app.route('/join', methods=['POST']) +def join(): + req = request.json # TODO: better error messages on failed parse. + + password = req.get('password') + ip_address = req.get('ip_address') + if not password: + return 'No password specified', 500 + + try: + return json.dumps(join_info(password, ip_address)) + except Unauthorized: + return (json.dumps({'error': 'Incorrect password.'}), 500) diff --git a/tools/cluster/cluster/shell.py b/tools/cluster/cluster/shell.py new file mode 100644 index 0000000..e6daee5 --- /dev/null +++ b/tools/cluster/cluster/shell.py @@ -0,0 +1,50 @@ +import os +import pymysql +import subprocess + + +def sql(cmd) -> None: + """Execute some SQL! + + Really simply wrapper around a pymysql connection, suitable for + passing the limited CREATE and GRANT commands that we need to pass + here. + + :param cmd: sql to execute. + + # TODO: move this into a shared shell library. + + """ + mysql_conf = '${SNAP_USER_COMMON}/etc/mysql/my.cnf'.format(**os.environ) + connection = pymysql.connect(host='localhost', user='root', + read_default_file=mysql_conf) + + with connection.cursor() as cursor: + cursor.execute(cmd) + + +def check_output(*args): + """Execute a shell command, returning the output of the command.""" + return subprocess.check_output(args, env=os.environ, + universal_newlines=True).strip() + + +def check(*args): + """Execute a shell command, raising an error on failed excution. + + :param args: strings to be composed into the bash call. + + """ + return subprocess.check_call(args, env=os.environ) + + +def write_tunnel_config(local_ip): + """Write tunnel config file for neutron agent.""" + + path_ = '{SNAP_COMMON}/etc/neutron/neutron.conf.d/tunnel.conf'.format( + **os.environ) + with open(path_, 'w') as file_: + file_.write("""\ +[OVS] +local_ip = {local_ip} +""".format(local_ip=local_ip)) diff --git a/tools/cluster/requirements.txt b/tools/cluster/requirements.txt new file mode 100644 index 0000000..30692b7 --- /dev/null +++ b/tools/cluster/requirements.txt @@ -0,0 +1,2 @@ +flask +requests diff --git a/tools/cluster/setup.py b/tools/cluster/setup.py new file mode 100644 index 0000000..c972882 --- /dev/null +++ b/tools/cluster/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup, find_packages + +setup( + name="microstack_cluster", + description="Clustering client and server.", + packages=find_packages(exclude=("tests",)), + version="0.0.1", + entry_points={ + 'console_scripts': [ + 'microstack_join = cluster.client:join', + ], + } +) diff --git a/tools/init/init/main.py b/tools/init/init/main.py index 4089830..69d2409 100644 --- a/tools/init/init/main.py +++ b/tools/init/init/main.py @@ -28,23 +28,72 @@ See the License for the specific language governing permissions and limitations under the License. """ + +import argparse +import logging +import secrets +import string import sys from init.config import log - -from init import questions from init.shell import check -# Figure out whether to prompt for user input, and which type of node -# we're running. -# TODO drop in argparse and formalize this. -COMPUTE = '--compute' in sys.argv -CONTROL = '--control' in sys.argv -AUTO = ('--auto' in sys.argv) or COMPUTE or CONTROL +from init import questions + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument('--auto', '-a', action='store_true', + help='Run non interactively.') + parser.add_argument('--cluster-password') + parser.add_argument('--compute', action='store_true') + parser.add_argument('--control', action='store_true') + parser.add_argument('--debug', action='store_true') + args = parser.parse_args() + return args + + +def process_args(args): + """Look through our args object and set the proper default config + values in our snap config, based on those args. + + """ + auto = args.auto or args.control or args.compute + + if args.compute or args.control: + check('snapctl', 'set', 'config.clustered=true') + + if args.compute: + check('snapctl', 'set', 'config.cluster.role=compute') + + if args.control: + # If both compute and control are passed for some reason, we + # wind up with the role of 'control', which is best, as a + # control node also serves as a compute node in our hyper + # converged architecture. + check('snapctl', 'set', 'config.cluster.role=control') + + if args.cluster_password: + check('snapctl', 'set', 'config.cluster.password={}'.format( + args.cluster_password)) + + if auto and not args.cluster_password: + alphabet = string.ascii_letters + string.digits + password = ''.join(secrets.choice(alphabet) for i in range(10)) + check('snapctl', 'set', 'config.cluster.password={}'.format(password)) + + if args.debug: + log.setLevel(logging.DEBUG) + + return auto def main() -> None: + args = parse_args() + auto = process_args(args) + question_list = [ + questions.Clustering(), questions.Dns(), questions.ExtGateway(), questions.ExtCidr(), @@ -56,36 +105,19 @@ def main() -> None: # questions.FileHandleLimits(), questions.RabbitMq(), questions.DatabaseSetup(), - questions.NovaSetup(), - questions.NeutronSetup(), + questions.NovaHypervisor(), + questions.NovaControlPlane(), + questions.NeutronControlPlane(), questions.GlanceSetup(), questions.KeyPair(), questions.SecurityRules(), questions.PostSetup(), ] - # If we are setting up a "control" or "compute" node, override - # some of the default yes/no questions. - # TODO: move this into a nice little yaml parsing lib, and - # allow people to pass in a config file from the command line. - if CONTROL: - check('snapctl', 'set', 'questions.nova-setup=false') - check('snapctl', 'set', 'questions.key-pair=nil') - check('snapctl', 'set', 'questions.security-rules=false') - - if COMPUTE: - check('snapctl', 'set', 'questions.rabbit-mq=false') - check('snapctl', 'set', 'questions.database-setup=false') - check('snapctl', 'set', 'questions.neutron-setup=false') - check('snapctl', 'set', 'questions.glance-setup=false') - for question in question_list: - if AUTO: - # If we are automatically answering questions, replace the - # prompt for user input with a function that returns None, - # causing the question to fall back to the already set - # default - question._input_func = lambda prompt: None + if auto: + # Force all questions to be non-interactive if we passed --auto. + question.interactive = False try: question.ask() diff --git a/tools/init/init/questions.py b/tools/init/init/questions/__init__.py similarity index 73% rename from tools/init/init/questions.py rename to tools/init/init/questions/__init__.py index d657bf9..96bc0b3 100644 --- a/tools/init/init/questions.py +++ b/tools/init/init/questions/__init__.py @@ -30,7 +30,8 @@ from os import path from init.shell import (check, call, check_output, shell, sql, nc_wait, log_wait, restart, download) from init.config import Env, log -from init.question import Question +from init.questions.question import Question +from init.questions import clustering _env = Env().get_env() @@ -43,6 +44,69 @@ class ConfigError(Exception): """ +class Clustering(Question): + """Possibly setup clustering.""" + + _type = 'boolean' + _question = 'Do you want to setup clustering?' + config_key = 'config.clustered' + interactive = True + + def yes(self, answer: bool): + + log.info('Configuring clustering ...') + + questions = [ + clustering.Role(), + clustering.Password(), + clustering.ControlIp(), + clustering.ComputeIp(), # Automagically skipped role='control' + ] + for question in questions: + if not self.interactive: + question.interactive = False + question.ask() + + role = check_output('snapctl', 'get', 'config.cluster.role') + control_ip = check_output('snapctl', 'get', + 'config.network.control-ip') + password = check_output('snapctl', 'get', 'config.cluster.password') + + log.debug('Role: {}, IP: {}, Password: {}'.format( + role, control_ip, password)) + + # TODO: raise an exception if any of the above are None (can + # happen if we're automatig and mess up our params.) + + if role == 'compute': + log.info('I am a compute node.') + # Gets config info and sets local env vals. + check_output('microstack_join') + + # Set default question answers. + check('snapctl', 'set', 'config.services.control-plane=false') + check('snapctl', 'set', 'config.services.hypervisor=true') + + if role == 'control': + log.info('I am a control node.') + check('snapctl', 'set', 'config.services.control-plane=true') + # We want to run a hypervisor on our control plane nodes + # -- this is essentially a hyper converged cloud. + check('snapctl', 'set', 'config.services.hypervisor=true') + + # TODO: if this is run after init has already been called, + # need to restart services. + + # Write templates + check('snap-openstack', 'setup') + + def no(self, answer: bool): + # Turn off cluster server + # TODO: it would be more secure to reverse this -- only enable + # to service if we are doing clustering. + check('systemctl', 'disable', 'snap.microstack.cluster-server') + + class ConfigQuestion(Question): """Question class that simply asks for and sets a config value. @@ -78,6 +142,7 @@ class Dns(Question): _type = 'string' _question = 'DNS to use' + config_key = 'config.network.dns' def yes(self, answer: str): """Override the default dhcp_agent.ini file.""" @@ -105,11 +170,21 @@ class ExtGateway(ConfigQuestion): _type = 'string' _question = 'External Gateway' + config_key = 'config.network.ext-gateway' def yes(self, answer): - # Preserve old behavior. - # TODO: update this - _env['extgateway'] = answer + clustered = check_output('snapctl', 'get', 'config.clustered') + if clustered.lower() != 'true': + check('snapctl', 'set', 'config.network.control-ip={}'.format( + answer)) + check('snapctl', 'set', 'config.network.compute-ip={}'.format( + answer)) + _env['control_ip'] = _env['compute_ip'] = answer + else: + _env['control_ip'] = check_output('snapctl', 'get', + 'config.network.control-ip') + _env['compute_ip'] = check_output('snapctl', 'get', + 'config.network.compute-ip') class ExtCidr(ConfigQuestion): @@ -117,20 +192,18 @@ class ExtCidr(ConfigQuestion): _type = 'string' _question = 'External Ip Range' + config_key = 'config.network.ext-cidr' def yes(self, answer): - # Preserve old behavior. - # TODO: update this _env['extcidr'] = answer class OsPassword(ConfigQuestion): _type = 'string' _question = 'Openstack Admin Password' + config_key = 'config.credentials.os-password' def yes(self, answer): - # Preserve old behavior. - # TODO: update this _env['ospassword'] = answer # TODO obfuscate the password! @@ -141,6 +214,7 @@ class IpForwarding(Question): _type = 'boolean' # Auto for now, to maintain old behavior. _question = 'Do you wish to setup ip forwarding? (recommended)' + config_key = 'config.host.ip-forwarding' def yes(self, answer: str) -> None: """Use sysctl to setup ip forwarding.""" @@ -150,7 +224,8 @@ class IpForwarding(Question): class ForceQemu(Question): - _type = 'auto' + _type = 'boolean' + config_key = 'config.host.check-qemu' def yes(self, answer: str) -> None: """Possibly force us to use qemu emulation rather than kvm.""" @@ -204,9 +279,10 @@ class RabbitMq(Question): """Wait for Rabbit to start, then setup permissions.""" _type = 'boolean' + config_key = 'config.services.control-plane' def _wait(self) -> None: - nc_wait(_env['extgateway'], '5672') + nc_wait(_env['control_ip'], '5672') log_file = '{SNAP_COMMON}/log/rabbitmq/startup_log'.format(**_env) log_wait(log_file, 'completed') @@ -230,31 +306,42 @@ class RabbitMq(Question): self._configure() log.info('RabbitMQ Configured!') + def no(self, answer: str): + log.info('Disabling local rabbit ...') + check('systemctl', 'disable', 'snap.microstack.rabbitmq-server') + class DatabaseSetup(Question): """Setup keystone permissions, then setup all databases.""" _type = 'boolean' + config_key = 'config.services.control-plane' def _wait(self) -> None: - nc_wait(_env['extgateway'], '3306') + nc_wait(_env['control_ip'], '3306') log_wait('{SNAP_COMMON}/log/mysql/error.log'.format(**_env), 'mysqld: ready for connections.') def _create_dbs(self) -> None: + # TODO: actually use passwords here. for db in ('neutron', 'nova', 'nova_api', 'nova_cell0', 'cinder', 'glance', 'keystone'): sql("CREATE DATABASE IF NOT EXISTS {db};".format(db=db)) sql( - "GRANT ALL PRIVILEGES ON {db}.* TO {db}@{extgateway} \ + "GRANT ALL PRIVILEGES ON {db}.* TO {db}@{control_ip} \ IDENTIFIED BY '{db}';".format(db=db, **_env)) + # Grant nova user access to cell0 + sql( + "GRANT ALL PRIVILEGES ON nova_cell0.* TO 'nova'@'{control_ip}' \ + IDENTIFIED BY \'nova';".format(**_env)) + def _bootstrap(self) -> None: if call('openstack', 'user', 'show', 'admin'): return - bootstrap_url = 'http://{extgateway}:5000/v3/'.format(**_env) + bootstrap_url = 'http://{control_ip}:5000/v3/'.format(**_env) check('snap-openstack', 'launch', 'keystone-manage', 'bootstrap', '--bootstrap-password', _env['ospassword'], @@ -300,11 +387,45 @@ class DatabaseSetup(Question): log.info('Keystone configured!') + def no(self, answer: str): + # We assume that the control node has a connection setup for us. + check('snapctl', 'set', 'database.ready=true') -class NovaSetup(Question): - """Create all relevant nova users and services.""" + log.info('Disabling local MySQL ...') + check('systemctl', 'disable', 'snap.microstack.mysqld') + + +class NovaHypervisor(Question): + """Run the nova compute hypervisor.""" _type = 'boolean' + config_key = 'config.services.hypervisor' + + def yes(self, answer): + log.info('Configuring nova compute hypervisor ...') + + if not call('openstack', 'service', 'show', 'compute'): + check('openstack', 'service', 'create', '--name', 'nova', + '--description', '"Openstack Compute"', 'compute') + # TODO make sure that we are the control plane before executing + # TODO if control plane is not hypervisor, still create this + for endpoint in ['public', 'internal', 'admin']: + call('openstack', 'endpoint', 'create', '--region', + 'microstack', 'compute', endpoint, + 'http://{compute_ip}:8774/v2.1'.format(**_env)) + + check('snapctl', 'start', 'microstack.nova-compute') + + def no(self, answer): + log.info('Disabling nova compute service ...') + check('systemctl', 'disable', 'snap.microstack.nova-compute') + + +class NovaControlPlane(Question): + """Create all control plane nova users and services.""" + + _type = 'boolean' + config_key = 'config.services.control-plane' def _flavors(self) -> None: """Create default flavors.""" @@ -327,7 +448,7 @@ class NovaSetup(Question): 'm1.xlarge') def yes(self, answer: str) -> None: - log.info('Configuring nova ...') + log.info('Configuring nova control plane services ...') if not call('openstack', 'user', 'show', 'nova'): check('openstack', 'user', 'create', '--domain', @@ -341,14 +462,6 @@ class NovaSetup(Question): check('openstack', 'role', 'add', '--project', 'service', '--user', 'placement', 'admin') - if not call('openstack', 'service', 'show', 'compute'): - check('openstack', 'service', 'create', '--name', 'nova', - '--description', '"Openstack Compute"', 'compute') - for endpoint in ['public', 'internal', 'admin']: - call('openstack', 'endpoint', 'create', '--region', - 'microstack', 'compute', endpoint, - 'http://{extgateway}:8774/v2.1'.format(**_env)) - if not call('openstack', 'service', 'show', 'placement'): check('openstack', 'service', 'create', '--name', 'placement', '--description', '"Placement API"', @@ -357,12 +470,7 @@ class NovaSetup(Question): for endpoint in ['public', 'internal', 'admin']: call('openstack', 'endpoint', 'create', '--region', 'microstack', 'placement', endpoint, - 'http://{extgateway}:8778'.format(**_env)) - - # Grant nova user access to cell0 - sql( - "GRANT ALL PRIVILEGES ON nova_cell0.* TO 'nova'@'{extgateway}' \ - IDENTIFIED BY \'nova';".format(**_env)) + 'http://{control_ip}:8778'.format(**_env)) # Use snapctl to start nova services. We need to call them # out manually, because systemd doesn't know about them yet. @@ -371,7 +479,6 @@ class NovaSetup(Question): for service in [ 'microstack.nova-api', 'microstack.nova-api-metadata', - 'microstack.nova-compute', 'microstack.nova-conductor', 'microstack.nova-scheduler', 'microstack.nova-uwsgi', @@ -396,18 +503,31 @@ class NovaSetup(Question): restart('nova-*') - nc_wait(_env['extgateway'], '8774') + nc_wait(_env['compute_ip'], '8774') sleep(5) # TODO: log_wait log.info('Creating default flavors...') self._flavors() + def no(self, answer): + log.info('Disabling nova control plane services ...') -class NeutronSetup(Question): + for service in [ + 'snap.microstack.nova-uwsgi', + 'snap.microstack.nova-api', + 'snap.microstack.nova-conductor', + 'snap.microstack.nova-scheduler', + 'snap.microstack.nova-api-metadata']: + + check('systemctl', 'disable', service) + + +class NeutronControlPlane(Question): """Create all relevant neutron services and users.""" _type = 'boolean' + config_key = 'config.services.control-plane' def yes(self, answer: str) -> None: log.info('Configuring Neutron') @@ -424,7 +544,7 @@ class NeutronSetup(Question): for endpoint in ['public', 'internal', 'admin']: call('openstack', 'endpoint', 'create', '--region', 'microstack', 'network', endpoint, - 'http://{extgateway}:9696'.format(**_env)) + 'http://{control_ip}:9696'.format(**_env)) for service in [ 'microstack.neutron-api', @@ -440,7 +560,7 @@ class NeutronSetup(Question): restart('neutron-*') - nc_wait(_env['extgateway'], '9696') + nc_wait(_env['control_ip'], '9696') sleep(5) # TODO: log_wait @@ -467,27 +587,49 @@ class NeutronSetup(Question): check('openstack', 'router', 'set', '--external-gateway', 'external', 'test-router') + def no(self, answer): + """Create endpoints pointed at control node if we're not setting up + neutron on this machine. + + """ + # Make sure that the agent is running. + for service in [ + 'microstack.neutron-openvswitch-agent', + ]: + check('snapctl', 'start', service) + + # Disable the other services. + for service in [ + 'snap.microstack.neutron-api', + 'snap.microstack.neutron-dhcp-agent', + 'snap.microstack.neutron-metadata-agent', + 'snap.microstack.neutron-l3-agent', + ]: + check('systemctl', 'disable', service) + class GlanceSetup(Question): """Setup glance, and download an initial Cirros image.""" _type = 'boolean' + config_key = 'config.services.control-plane' def _fetch_cirros(self) -> None: if call('openstack', 'image', 'show', 'cirros'): return + log.info('Adding cirros image ...') + env = dict(**_env) env['VER'] = '0.4.0' env['IMG'] = 'cirros-{VER}-x86_64-disk.img'.format(**env) - log.info('Fetching cirros image ...') - cirros_path = '{SNAP_COMMON}/images/{IMG}'.format(**env) if not path.exists(cirros_path): check('mkdir', '-p', '{SNAP_COMMON}/images'.format(**env)) + log.info('Downloading cirros image ...') download( 'http://download.cirros-cloud.net/{VER}/{IMG}'.format(**env), '{SNAP_COMMON}/images/{IMG}'.format(**env)) @@ -513,11 +655,11 @@ class GlanceSetup(Question): for endpoint in ['internal', 'admin', 'public']: check('openstack', 'endpoint', 'create', '--region', 'microstack', 'image', endpoint, - 'http://{extgateway}:9292'.format(**_env)) + 'http://{compute_ip}:9292'.format(**_env)) for service in [ 'microstack.glance-api', - 'microstack.registry', # TODO rename this to glance-registery + 'microstack.registry', # TODO rename to glance-registery ]: check('snapctl', 'start', service) @@ -525,12 +667,16 @@ class GlanceSetup(Question): restart('glance*') - nc_wait(_env['extgateway'], '9292') + nc_wait(_env['compute_ip'], '9292') sleep(5) # TODO: log_wait self._fetch_cirros() + def no(self, answer): + check('systemctl', 'disable', 'snap.microstack.glance-api') + check('systemctl', 'disable', 'snap.microstack.registry') + class KeyPair(Question): """Create a keypair for ssh access to instances. @@ -541,6 +687,7 @@ class KeyPair(Question): questions at the beginning.) """ _type = 'string' + config_key = 'config.credentials.key-pair' def yes(self, answer: str) -> None: @@ -566,6 +713,7 @@ class SecurityRules(Question): """Setup default security rules.""" _type = 'boolean' + config_key = 'config.network.security-rules' def yes(self, answer: str) -> None: # Create security group rules @@ -596,6 +744,8 @@ class SecurityRules(Question): class PostSetup(Question): """Sneak in any additional cleanup, then set the initialized state.""" + config_key = 'config.post-setup' + def yes(self, answer: str) -> None: log.info('restarting libvirt and virtlogd ...') diff --git a/tools/init/init/questions/clustering.py b/tools/init/init/questions/clustering.py new file mode 100644 index 0000000..8d7f114 --- /dev/null +++ b/tools/init/init/questions/clustering.py @@ -0,0 +1,87 @@ +from getpass import getpass + +from init.questions.question import Question, InvalidAnswer +from init.shell import check, check_output, fetch_ip_address + + +class Role(Question): + _type = 'string' + config_key = 'config.cluster.role' + _question = "What is this machines' role? (control/compute)" + _valid_roles = ('control', 'compute') + interactive = True + + def _input_func(self, prompt): + if not self.interactive: + return + + for _ in range(0, 3): + role = input("{} > ".format(self._question)) + if role in self._valid_roles: + return role + + print('Role must be either "control" or "compute"') + + raise InvalidAnswer('Too many failed attempts.') + + +class Password(Question): + _type = 'string' # TODO: type password support + config_key = 'config.cluster.password' + _question = 'Please enter a cluster password > ' + interactive = True + + def _input_func(self, prompt): + if not self.interactive: + return + + # Get rid of 'default=' string the parent class has added to prompt. + prompt = self._question + + for _ in range(0, 3): + password0 = getpass(prompt) + password1 = getpass('Please re-enter password > ') + if password0 == password1: + return password0 + + print("Passwords don't match!") + + raise InvalidAnswer('Too many failed attempts.') + + +class ControlIp(Question): + _type = 'string' + config_key = 'config.network.control-ip' + _question = 'Please enter the ip address of the control node' + interactive = True + + def _load(self): + if check_output( + 'snapctl', 'get', 'config.cluster.role') == 'control': + return fetch_ip_address() or super()._load() + + return super()._load() + + +class ComputeIp(Question): + _type = 'string' + config_key = 'config.network.compute-ip' + _question = 'Please enter the ip address of this node' + interactive = True + + def _load(self): + if check_output( + 'snapctl', 'get', 'config.cluster.role') == 'compute': + return fetch_ip_address() or super().load() + + return super()._load() + + def ask(self): + # If we are a control node, skip this question. + role = check_output('snapctl', 'get', Role.config_key) + if role == 'control': + ip = check_output('snapctl', 'get', ControlIp.config_key) + check('snapctl', 'set', '{}={}'.format(self.config_key, ip)) + return + + return super().ask() diff --git a/tools/init/init/question.py b/tools/init/init/questions/question.py similarity index 88% rename from tools/init/init/question.py rename to tools/init/init/questions/question.py index a742e91..20aee4b 100644 --- a/tools/init/init/question.py +++ b/tools/init/init/questions/question.py @@ -24,8 +24,6 @@ limitations under the License. from typing import Tuple -import inflection - from init import shell @@ -56,8 +54,6 @@ class Question(): Contains a support for always defaulting to yes. - TODO: Add support for finding answers in a config.yaml. - """ _valid_types = [ 'boolean', # Yes or No, and variants thereof @@ -66,10 +62,11 @@ class Question(): ] _question = '(required)' + config_key = None # Must be overriden + interactive = False _type = 'auto' # can be boolean, string or auto _invalid_prompt = 'Please answer Yes or No.' _retries = 3 - _input_func = input def __init__(self): @@ -77,6 +74,17 @@ class Question(): raise InvalidQuestion( 'Invalid type {} specified'.format(self._type)) + if self.config_key is None: + raise InvalidQuestion( + "No config key specified. " + "We don't know how to load or save this question!") + + def _input_func(self, prompt): + + if not self.interactive: + return + return input(prompt) + def _validate(self, answer: str) -> Tuple[str, bool]: """Validate an answer. @@ -110,13 +118,8 @@ class Question(): operator specified settings during updates. """ - # Translate the CamelCase name of this class to the dash - # seperated name of a key in the snapctl config. - key = inflection.dasherize( - inflection.underscore(self.__class__.__name__)) - answer = shell.check_output( - 'snapctl', 'get', 'questions.{key}'.format(key=key) + 'snapctl', 'get', '{key}'.format(key=self.config_key) ) # Convert boolean values in to human friendly "yes" or "no" # values. @@ -125,6 +128,10 @@ class Question(): if answer.strip().lower() == 'false': answer = 'no' + # Convert null to None + if answer.strip().lower() == 'null': + answer = None + return answer def _save(self, answer): @@ -134,17 +141,17 @@ class Question(): namespace in the snap config. """ - key = inflection.dasherize( - inflection.underscore(self.__class__.__name__)) - # By this time in the process 'yes' or 'no' answers will have # been converted to booleans. Convert them to a lowercase # 'true' or 'false' string for storage in the snapctl config. if self._type == 'boolean': answer = str(answer).lower() - shell.check('snapctl', 'set', 'questions.{key}={val}'.format( - key=key, val=answer)) + if answer is None: + answer = 'null' + + shell.check('snapctl', 'set', '{key}={val}'.format( + key=self.config_key, val=answer)) return answer diff --git a/tools/init/init/set_network_info.py b/tools/init/init/set_network_info.py new file mode 100644 index 0000000..0c8e13c --- /dev/null +++ b/tools/init/init/set_network_info.py @@ -0,0 +1,24 @@ +#!/usr/bin/env/python3 +from init.shell import default_network, check + +from init.config import log # TODO name log. + + +def main(): + try: + ip, gate, cidr = default_network() + except Exception: + # TODO: more specific exception handling. + log.exception( + 'Could not determine default network info. ' + 'Falling back on 10.20.20.1') + return + + check('snapctl', 'set', 'config.network.ext-gateway={}'.format(gate)) + check('snapctl', 'set', 'config.network.ext-cidr={}'.format(cidr)) + check('snapctl', 'set', 'config.network.control-ip={}'.format(ip)) + check('snapctl', 'set', 'config.network.control-ip={}'.format(ip)) + + +if __name__ == '__main__': + main() diff --git a/tools/init/init/shell.py b/tools/init/init/shell.py index 9a77e97..63073e8 100644 --- a/tools/init/init/shell.py +++ b/tools/init/init/shell.py @@ -27,6 +27,8 @@ import subprocess from time import sleep from typing import Dict, List +import netaddr +import netifaces import pymysql import wget @@ -163,6 +165,42 @@ def restart(service: str) -> None: check('systemctl', 'restart', 'snap.microstack.{}'.format(service)) +def disable(service: str) -> None: + """Disable and mask a service. + + :param service: the service(s) to be disabled. Can contain wild cards. + e.g. *rabbit* + + """ + check('systemctl', 'disable', 'snap.microstack.{}'.format(service)) + check('systemctl', 'mask', 'snap.microstack.{}'.format(service)) + + def download(url: str, output: str) -> None: """Download a file to a path""" wget.download(url, output) + + +def fetch_ip_address(): + try: + interface = netifaces.gateways()['default'][netifaces.AF_INET][1] + return netifaces.ifaddresses(interface)[netifaces.AF_INET][0]['addr'] + except (KeyError, IndexError): + log.exception('Failed to get ip address!') + return None + + +def default_network(): + """Get info about the default netowrk. + + """ + gateway, interface = netifaces.gateways()['default'][netifaces.AF_INET] + netmask = netifaces.ifaddresses(interface)[netifaces.AF_INET][0]['netmask'] + ip_address = netifaces.ifaddresses(interface)[netifaces.AF_INET][0]['addr'] + bits = netaddr.IPAddress(netmask).netmask_bits() + # TODO: better way to do this! + cidr = gateway.split('.') + cidr[-1] = '0/{}'.format(bits) + cidr = '.'.join(cidr) + + return ip_address, gateway, cidr diff --git a/tools/init/requirements.txt b/tools/init/requirements.txt index 34e81ca..ce73c29 100644 --- a/tools/init/requirements.txt +++ b/tools/init/requirements.txt @@ -1,3 +1,4 @@ +netaddr +netifaces pymysql wget -inflection diff --git a/tools/init/setup.py b/tools/init/setup.py index 3fd73ce..889c786 100644 --- a/tools/init/setup.py +++ b/tools/init/setup.py @@ -8,6 +8,7 @@ setup( entry_points={ 'console_scripts': [ 'microstack_init = init.main:main', + 'set_network_info = init.set_network_info:main', ], }, ) diff --git a/tools/init/tests/test_question.py b/tools/init/tests/test_question.py index 5bfbfb2..e2fc36a 100644 --- a/tools/init/tests/test_question.py +++ b/tools/init/tests/test_question.py @@ -7,8 +7,8 @@ import mock # TODO: drop in test runner and get rid of this line. sys.path.append(os.getcwd()) # noqa -from init.question import (Question, InvalidQuestion, InvalidAnswer, - AnswerNotImplemented) +from init.questions.question import (Question, InvalidQuestion, InvalidAnswer, + AnswerNotImplemented) ############################################################################## @@ -20,10 +20,12 @@ from init.question import (Question, InvalidQuestion, InvalidAnswer, class InvalidTypeQuestion(Question): _type = 'foo' + config_key = 'invalid-type' class GoodAutoQuestion(Question): _type = 'auto' + config_key = 'good-auto-question' def yes(self, answer): return 'I am a good question!' @@ -31,6 +33,7 @@ class GoodAutoQuestion(Question): class GoodBooleanQuestion(Question): _type = 'boolean' + config_key = 'good-bool-question' def yes(self, answer): return True @@ -48,6 +51,7 @@ class GoodStringQuestion(Question): """ _type = 'string' + config_key = 'good-string-question' def yes(self, answer): return answer @@ -77,8 +81,8 @@ class TestQuestionClass(unittest.TestCase): self.assertTrue(GoodBooleanQuestion()) - @mock.patch('init.question.shell.check_output') - @mock.patch('init.question.shell.check') + @mock.patch('init.questions.question.shell.check_output') + @mock.patch('init.questions.question.shell.check') def test_auto_question(self, mock_check, mock_check_output): mock_check_output.return_value = '' @@ -93,8 +97,8 @@ class TestInput(unittest.TestCase): class's input handler. """ - @mock.patch('init.question.shell.check_output') - @mock.patch('init.question.shell.check') + @mock.patch('init.questions.question.shell.check_output') + @mock.patch('init.questions.question.shell.check') def test_boolean_question(self, mock_check, mock_check_output): mock_check_output.return_value = 'true' @@ -112,8 +116,8 @@ class TestInput(unittest.TestCase): q._input_func = lambda x: 'foo' q.ask() - @mock.patch('init.question.shell.check_output') - @mock.patch('init.question.shell.check') + @mock.patch('init.questions.question.shell.check_output') + @mock.patch('init.questions.question.shell.check') def test_string_question(self, mock_check, mock_check_output): mock_check_output.return_value = 'somedefault' diff --git a/tools/launch/launch/main.py b/tools/launch/launch/main.py index 3c875fd..93a3080 100644 --- a/tools/launch/launch/main.py +++ b/tools/launch/launch/main.py @@ -43,6 +43,12 @@ def parse_args(): help='Wait for server to become active before exiting') parser.add_argument('-r', '--retry', action='store_true', help='Retry failed launch attempts') + # TODO: add a passthrough for other openstack 'server create' + # args. Manually specifying them here is a bit silly. For now, we + # need to specify availability zone in some tests, so we add it + # here. + parser.add_argument('--availability-zone', + help='passthrough to avail zone') args = parser.parse_args() return args @@ -50,13 +56,18 @@ def parse_args(): def create_server(name, args): - ret = check_output('openstack', 'server', 'create', - '--flavor', args.flavor, - '--image', args.image, - '--nic', 'net-id={}'.format(args.net_id), - '--key-name', args.key, - name, '--format', 'json') - ret = json.loads(ret) + cmd = [ + 'openstack', 'server', 'create', + '--flavor', args.flavor, + '--image', args.image, + '--nic', 'net-id={}'.format(args.net_id), + '--key-name', args.key, + name, '--format', 'json' + ] + if args.availability_zone: + cmd += ['--availability-zone', args.availability_zone] + + ret = json.loads(check_output(*cmd)) return ret['id'] @@ -141,7 +152,7 @@ Server {} launched! (status is {}) Access it with `ssh -i \ $HOME/.ssh/id_microstack` @{}""".format(name, status, ip)) - gate = check_output('snapctl', 'get', 'questions.ext-gateway') + gate = check_output('snapctl', 'get', 'config.network.ext-gateway') print('You can also visit the OpenStack dashboard at http://{}'.format( gate)) diff --git a/tox.ini b/tox.ini index 90d6a9d..101a79a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = init_lint, init_unit, multipass +envlist = lint, unit, multipass skipsdist = True [testenv] @@ -24,7 +24,6 @@ commands = # Specify tests in sequence, as they can't run in parallel if not # using multipass. {toxinidir}/tests/test_basic.py - {toxinidir}/tests/test_control.py [testenv:multipass] # Default testing environment for a human operated machine. Builds the @@ -42,7 +41,6 @@ commands = {toxinidir}/tools/multipass_build.sh flake8 {toxinidir}/tests/ {toxinidir}/tests/test_basic.py - {toxinidir}/tests/test_control.py [testenv:basic] # Just run basic_test.sh, with multipass support. @@ -54,7 +52,30 @@ commands = {toxinidir}/tools/basic_setup.sh flake8 {toxinidir}/tests/ {toxinidir}/tests/test_basic.py - {toxinidir}/tests/test_control.py + +[testenv:cluster] +# Test out clustering! +# Requires multipass. +deps = -r{toxinidir}/test-requirements.txt +setenv = + MULTIPASS=true + +commands = + {toxinidir}/tools/basic_setup.sh + flake8 {toxinidir}/tests/ + {toxinidir}/tests/test_cluster.py + +[testenv:build] +# Just run basic_test.sh, with multipass support. +deps = -r{toxinidir}/test-requirements.txt +setenv = + MULTIPASS=true + +commands = + flake8 {toxinidir}/tests/ + flake8 {toxinidir}/tools/init/init + flake8 {toxinidir}/tools/cluster/cluster + {toxinidir}/tools/multipass_build.sh [testenv:lint] deps = -r{toxinidir}/test-requirements.txt @@ -62,13 +83,9 @@ commands = flake8 {toxinidir}/tests/ flake8 {toxinidir}/tools/init/init/ flake8 {toxinidir}/tools/launch/launch/ + flake8 {toxinidir}/tools/cluster/cluster/ -[testenv:init_lint] -deps = -r{toxinidir}/tools/init/test-requirements.txt - -r{toxinidir}/tools/init/requirements.txt -commands = flake8 {toxinidir}/tools/init/init/ - -[testenv:init_unit] +[testenv:unit] deps = -r{toxinidir}/tools/init/test-requirements.txt -r{toxinidir}/tools/init/requirements.txt commands =