diff --git a/.gitignore b/.gitignore index 2f33e78..abe7d45 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ nosetests.xml coverage.xml *.cover .hypothesis/ +.stestr/ # Translations *.mo diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..e6d188a --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./tools/init/tests/ +top_dir=./tools/init/ diff --git a/.zuul.yaml b/.zuul.yaml index 9c27995..4d8687e 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -3,6 +3,8 @@ parent: openstack-tox-snap-with-sudo timeout: 7200 nodeset: ubuntu-bionic + vars: + tox_envlist: init_lint init_unit snap - project: check: diff --git a/snap-overlay/bin/init.sh b/snap-overlay/bin/init.sh deleted file mode 100755 index e60398c..0000000 --- a/snap-overlay/bin/init.sh +++ /dev/null @@ -1,317 +0,0 @@ -#!/bin/bash -set -e - -echo "Initializing Microstack." - -############################################################################## -# -# Config -# -# Setup env and templates. -# -############################################################################## -echo "Loading config and writing out templates ..." - -ospassword=$(snapctl get ospassword) -extgateway=$(snapctl get extgateway) -extcidr=$(snapctl get extcidr) -dns=$(snapctl get dns) - -# Check Config -if [ -z "$ospassword" -o -z "$extgateway" -o -z "$dns" -o -z "$extcidr"]; then - echo "Missing required config value." - exit 1 -fi - -# Write out templates and read off of our microstack.rc template -# TODO: any password change hooks would go here, updating the password -# in the db before writing it to the templates and restarting -# services. -snap-openstack setup # Write out templates - -# Load openstack .rc into this script's environment. Outside of the -# snap shell, this is handled by a wrapper. -source $SNAP_COMMON/etc/microstack.rc - - -############################################################################## -# -# System Optimization -# -# Perform some tasks that change the host system in ways to better -# support microstack. -# -############################################################################## - - -# Open up networking so that instances can route to the Internet (see -# bin/setup-br-ex for more networking setup, executed on microstack -# services start.) -echo "Setting up ipv4 forwarding." -sudo sysctl net.ipv4.ip_forward=1 - -# TODO: add vm swappiness and increased file handle limits here. -# TODO: make vm swappiness and file handle changes optional. - - -############################################################################## -# -# RabbitMQ Setup -# -# Configure database and wait for services to start. -# -############################################################################## -echo "Configuring RabbitMQ" - -echo "Waiting for rabbitmq to start" -while ! nc -z $extgateway 5672; do sleep 0.1; done; -while :; -do - grep "Starting broker..." ${SNAP_COMMON}/log/rabbitmq/startup_log && \ - grep "completed" ${SNAP_COMMON}/log/rabbitmq/startup_log && \ - break - sleep 1; -done -echo "Rabbitmq started." - -# Config! -HOME=$SNAP_COMMON/lib/rabbitmq rabbitmqctl add_user openstack rabbitmq || : -HOME=$SNAP_COMMON/lib/rabbitmq rabbitmqctl set_permissions openstack ".*" ".*" ".*" - - -############################################################################## -# -# Database setup -# -# Create databases and initialize keystone. -# -############################################################################## - -# Wait for MySQL to startup -echo "Waiting for MySQL server to start ..." -while ! nc -z $extgateway 3306; do sleep 0.1; done; -while :; -do - grep "mysqld: ready for connections." \ - ${SNAP_COMMON}/log/mysql/error.log && break; - sleep 1; -done -echo "Mysql server started." - -for db in neutron nova nova_api nova_cell0 cinder glance keystone; do - echo "CREATE DATABASE IF NOT EXISTS ${db}; GRANT ALL PRIVILEGES ON ${db}.* TO '${db}'@'$extgateway' IDENTIFIED BY '${db}';" \ - | mysql-start-client -u root -done - -# Configure Keystone Fernet Keys -echo "Configuring Keystone..." -snap-openstack launch keystone-manage fernet_setup \ - --keystone-user root \ - --keystone-group root -snap-openstack launch keystone-manage db_sync - -systemctl restart snap.microstack.keystone-* - -openstack user show admin || { - snap-openstack launch keystone-manage bootstrap \ - --bootstrap-password $ospassword \ - --bootstrap-admin-url http://$extgateway:5000/v3/ \ - --bootstrap-internal-url http://$extgateway:5000/v3/ \ - --bootstrap-public-url http://$extgateway:5000/v3/ \ - --bootstrap-region-id microstack -} - -openstack project show service || { - openstack project create --domain default --description "Service Project" service -} -echo "Keystone configured." - -############################################################################## -# -# Nova Setup -# -# Configure database and wait for services to start. -# -############################################################################## -echo "Configuring Nova..." - -openstack user show nova || { - openstack user create --domain default --password nova nova - openstack role add --project service --user nova admin -} - -openstack user show placement || { - openstack user create --domain default --password placement placement - openstack role add --project service --user placement admin -} - -openstack service show compute || { - openstack service create --name nova \ - --description "OpenStack Compute" compute - - for endpoint in public internal admin; do - openstack endpoint create --region microstack \ - compute $endpoint http://$extgateway:8774/v2.1 || : - done -} - -openstack service show placement || { - openstack service create --name placement \ - --description "Placement API" placement - - for endpoint in public internal admin; do - openstack endpoint create --region microstack \ - placement $endpoint http://$extgateway:8778 || : - done -} - -# Grant nova user access to cell0 -echo "GRANT ALL PRIVILEGES ON nova_cell0.* TO 'nova'@'$extgateway' IDENTIFIED BY 'nova';" \ - | mysql-start-client -u root - -snap-openstack launch nova-manage api_db sync -snap-openstack launch nova-manage cell_v2 list_cells | grep cell0 || { - snap-openstack launch nova-manage cell_v2 map_cell0 -} -snap-openstack launch nova-manage cell_v2 list_cells | grep cell1 || { - snap-openstack launch nova-manage cell_v2 create_cell --name=cell1 --verbose -} -snap-openstack launch nova-manage db sync - -systemctl restart snap.microstack.nova-* - -while ! nc -z $extgateway 8774; do sleep 0.1; done; - -sleep 5 - -openstack flavor show m1.tiny || { - openstack flavor create --id 1 --ram 512 --disk 1 --vcpus 1 m1.tiny -} -openstack flavor show m1.small || { - openstack flavor create --id 2 --ram 2048 --disk 20 --vcpus 1 m1.small -} -openstack flavor show m1.medium || { - openstack flavor create --id 3 --ram 4096 --disk 20 --vcpus 2 m1.medium -} -openstack flavor show m1.large || { - openstack flavor create --id 4 --ram 8192 --disk 20 --vcpus 4 m1.large -} -openstack flavor show m1.xlarge || { - openstack flavor create --id 5 --ram 16384 --disk 20 --vcpus 8 m1.xlarge -} - -############################################################################## -# -# Neutron Setup -# -# Configure database and wait for services to start. -# -############################################################################## -echo "Configuring Neutron" - -openstack user show neutron || { - openstack user create --domain default --password neutron neutron - openstack role add --project service --user neutron admin -} - -openstack service show network || { - openstack service create --name neutron \ - --description "OpenStack Network" network - - for endpoint in public internal admin; do - openstack endpoint create --region microstack \ - network $endpoint http://$extgateway:9696 || : - done -} - -snap-openstack launch neutron-db-manage upgrade head - -systemctl restart snap.microstack.neutron-* - -while ! nc -z $extgateway 9696; do sleep 0.1; done; - -sleep 5 - -openstack network show test || { - openstack network create test -} - -openstack subnet show test-subnet || { - openstack subnet create --network test --subnet-range 192.168.222.0/24 test-subnet -} - -openstack network show external || { - openstack network create --external \ - --provider-physical-network=physnet1 \ - --provider-network-type=flat external -} - -openstack subnet show external-subnet || { - openstack subnet create --network external --subnet-range 10.20.20.0/24 \ - --no-dhcp external-subnet -} - -openstack router show test-router || { - openstack router create test-router - openstack router add subnet test-router test-subnet - openstack router set --external-gateway external test-router -} - -############################################################################## -# -# Glance Setup -# -# Configure database and wait for services to start. -# -############################################################################## -echo "Configuring Glance" - -openstack user show glance || { - openstack user create --domain default --password glance glance - openstack role add --project service --user glance admin -} - -openstack service show image || { - openstack service create --name glance --description "OpenStack Image" image - for endpoint in internal admin public; do - openstack endpoint create --region microstack \ - image $endpoint http://$extgateway:9292 || : - done -} - -snap-openstack launch glance-manage db_sync - -systemctl restart snap.microstack.glance* - -while ! nc -z $extgateway 9292; do sleep 0.1; done; - -sleep 5 - -# Setup the cirros image, which is used by the launch app -echo "Grabbing cirros image." -openstack image show cirros || { - [ -f $SNAP_COMMON/images/cirros-0.4.0-x86_64-disk.img ] || { - mkdir -p $SNAP_COMMON/images - wget \ - http://download.cirros-cloud.net/0.4.0/cirros-0.4.0-x86_64-disk.img \ - -O ${SNAP_COMMON}/images/cirros-0.4.0-x86_64-disk.img - } - openstack image create \ - --file ${SNAP_COMMON}/images/cirros-0.4.0-x86_64-disk.img \ - --public --container-format=bare --disk-format=qcow2 cirros -} - -############################################################################## -# -# Post-setup tasks. -# -# Clean up hanging threads and wait for services to restart. -# -############################################################################## - -# Restart libvirt and virtlogd to get logging -# TODO: figure out why this doesn't Just Work initially -systemctl restart snap.microstack.*virt* - -echo "Complete. Marking microstack as initialized!" -snapctl set initialized=true diff --git a/snap-overlay/bin/setup-br-ex b/snap-overlay/bin/setup-br-ex index da080c5..b0a1206 100755 --- a/snap-overlay/bin/setup-br-ex +++ b/snap-overlay/bin/setup-br-ex @@ -18,6 +18,6 @@ ovs-vsctl --retry --may-exist add-br br-ex ip address add $extcidr dev br-ex || : ip link set br-ex up || : -sudo iptables -t nat -A POSTROUTING -s $extcidr ! -d $extcidr -j MASQUERADE +sudo iptables -w -t nat -A POSTROUTING -s $extcidr ! -d $extcidr -j MASQUERADE exit 0 diff --git a/snapcraft.yaml b/snapcraft.yaml index a118884..5084a15 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -22,7 +22,7 @@ apps: # OpenStack Service Configuration init: - command: init.sh + command: microstack_init # plugs: # - network @@ -792,3 +792,12 @@ parts: overlay: plugin: dump source: snap-overlay + + # Optionally interactive init script + init: + plugin: python + python-version: python3 + python-packages: + - pymysql + - wget + source: tools/init diff --git a/tests/basic-test.sh b/tests/basic-test.sh index 6d69c4a..731d8d4 100755 --- a/tests/basic-test.sh +++ b/tests/basic-test.sh @@ -79,7 +79,7 @@ if [ "${UPGRADE_FROM}" != "none" ]; then $PREFIX sudo snap install --classic --${UPGRADE_FROM} microstack fi -# Install the snap under test +# Install the snap under test -- try again if the machine is not yet ready. $PREFIX sudo snap install --classic --dangerous microstack*.snap $PREFIX sudo /snap/bin/microstack.init diff --git a/tools/init/init/__init__.py b/tools/init/init/__init__.py new file mode 100644 index 0000000..5705e5d --- /dev/null +++ b/tools/init/init/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2019 Canonical Ltd +# +# 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. diff --git a/tools/init/init/config.py b/tools/init/init/config.py new file mode 100644 index 0000000..f1e4e22 --- /dev/null +++ b/tools/init/init/config.py @@ -0,0 +1,52 @@ +"""config.py + +Keep track of shared config, logging, etc. here. + +---------------------------------------------------------------------- + +Copyright 2019 Canonical Ltd + +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 logging +import os +import sys + + +class Env(): + """Singleton that tracks environment variables. + + Contains the env variables of the shell that called us. We also + add the snapctl config values to it in the Setup Question. + + """ + _global_config = {} + _global_config.update(**os.environ) + + def __init__(self): + self.__dict__ = self._global_config + + def get_env(self): + """Get a mapping friendly dict.""" + return self.__dict__ + + +logging.basicConfig( + # filename='{SNAP_COMMON}/log/microstack_init.log'.format(**Env), + stream=sys.stdout, + level=logging.DEBUG +) + + +log = logging # noqa diff --git a/tools/init/init/main.py b/tools/init/init/main.py new file mode 100644 index 0000000..45e2dd2 --- /dev/null +++ b/tools/init/init/main.py @@ -0,0 +1,62 @@ +"""Microstack Init + +Initialize the databases and configuration files of a microstack +install. + +We structure our init in the form of 'Question' classes, each of which +has an 'ask' routine, run in the order laid out in the +question_classes in the main function in this file. + +.ask will either ask the user a question, and run the appropriate +routine in the Question class, or simply automatically run a routine +without input from the user (in the case of 'required' questions). + +---------------------------------------------------------------------- + +Copyright 2019 Canonical Ltd + +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 + +from init.config import log + +from init import questions + + +def main() -> None: + question_list = [ + questions.Setup(), + questions.IpForwarding(), + # The following are not yet implemented: + # questions.VmSwappiness(), + # questions.FileHandleLimits(), + questions.RabbitMQ(), + questions.DatabaseSetup(), + questions.NovaSetup(), + questions.NeutronSetup(), + questions.GlanceSetup(), + questions.PostSetup(), + ] + + for question in question_list: + try: + question.ask() + except questions.ConfigError as e: + log.critical(e) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/tools/init/init/question.py b/tools/init/init/question.py new file mode 100644 index 0000000..491dedb --- /dev/null +++ b/tools/init/init/question.py @@ -0,0 +1,129 @@ +"""question.py + +Contains our Question class, which knows how to ask a question, then +run abitrary code. + +---------------------------------------------------------------------- + +Copyright 2019 Canonical Ltd + +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 typing import Tuple + + +class InvalidQuestion(Exception): + """Exception to raies in the case where a Question subclass has not + been properly implemented. + + """ + + +class InvalidAnswer(Exception): + """Exception to raise in the case where the user has specified an + invalid answer. + + """ + + +class AnswerNotImplemented(Exception): + """Exception to raise in the case where a 'yes' or 'no' routine has + not been overriden in the subclass, as required. + + """ + + +class Question(): + """ + Ask the user a question, and then run code as appropriate. + + Contains a support for always defaulting to yes. + + TODO: Add support for finding answers in a config.yaml. + + """ + _valid_types = [ + 'binary', # Yes or No, and variants thereof + 'string', # Accept (and sanitize) any string + 'auto' # Don't actually ask a question -- just execute self.yes(True) + ] + + _question = '(required)' + _type = 'auto' # can be binary, string or auto + _invalid_prompt = 'Please answer Yes or No.' + _retries = 3 + _input_func = input + + def __init__(self): + + if self._type not in ['binary', 'string', 'auto']: + raise InvalidQuestion( + 'Invalid type {} specified'.format(self._type)) + + def _validate(self, answer: bytes) -> Tuple[str, bool]: + """Validate an answer. + + :param anwser: raw input from the user. + + Returns the answer, and whether or not the answer was valid. + + """ + if self._type == 'auto': + return True, True + + answer = answer.decode('utf8') + + if self._type == 'string': + # TODO Santize this! + return answer, True + + # self._type is binary + if answer.lower() in ['y', 'yes']: + return True, True + + if answer.lower() in ['n', 'no']: + return False, True + + return answer, False + + def yes(self, answer: str) -> None: + """Routine to run if the user answers 'yes' or with a value.""" + raise AnswerNotImplemented('You must override this method.') + + def no(self, answer: str) -> None: + """Routine to run if the user answers 'no'""" + raise AnswerNotImplemented('You must override this method.') + + def ask(self) -> None: + """ + Ask the user a question. + + Run self.yes or self.no as appropriate. Raise an error if the + user cannot specify a valid answer after self._retries tries. + + """ + prompt = self._question + + for i in range(0, self._retries): + awr, valid = self._validate( + self._type == 'auto' or self._input_func(prompt)) + if valid: + if awr: + return self.yes(awr) + return self.no(awr) + prompt = '{} is not valid. {}'.format(awr, self._invalid_prompt) + + raise InvalidAnswer('Too many invalid answers.') diff --git a/tools/init/init/questions.py b/tools/init/init/questions.py new file mode 100644 index 0000000..ec2bf70 --- /dev/null +++ b/tools/init/init/questions.py @@ -0,0 +1,410 @@ +"""questions.py + +All of our subclasses of Question live here. + +We might break this file up into multiple pieces at some point, but +for now, we're keeping things simple (if a big lengthy) + +---------------------------------------------------------------------- + +Copyright 2019 Canonical Ltd + +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 time import sleep +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 + + +_env = Env().get_env() + + +class ConfigError(Exception): + """Suitable error to raise in case there is an issue with the snapctl + config or environment vars. + + """ + + +class Setup(Question): + """Prepare our environment. + + Check to make sure that everything is in place, and populate our + config object and os.environ with the correct values. + + """ + def yes(self, answer: str) -> None: + """Since this is an auto question, we always execute yes.""" + log.info('Loading config and writing templates ...') + + log.info('Validating config ...') + for key in ['ospassword', 'extgateway', 'extcidr', 'dns']: + val = check_output('snapctl', 'get', key) + if not val: + raise ConfigError( + 'Expected config value {} is not set.'.format(key)) + _env[key] = val + + log.info('Writing out templates ...') + check('snap-openstack', 'setup') + + # Parse microstack.rc, and load into _env + # TODO: write something more robust (this breaks on comments + # at end of line.) + mstackrc = '{SNAP_COMMON}/etc/microstack.rc'.format(**_env) + with open(mstackrc, 'r') as rc_file: + for line in rc_file.readlines(): + if not line.startswith('export'): + continue + key, val = line[7:].split('=') + _env[key.strip()] = val.strip() + + +class IpForwarding(Question): + """Possibly setup IP forwarding.""" + + _type = 'auto' # Auto for now, to maintain old behavior. + _question = 'Do you wish to setup ip forwarding? (recommended)' + + def yes(self, answer: str) -> None: + """Use sysctl to setup ip forwarding.""" + log.info('Setting up ipv4 forwarding...') + + check('sysctl', 'net.ipv4.ip_forward=1') + + +class VmSwappiness(Question): + + _type = 'binary' + _question = 'Do you wish to set vm swappiness to 1? (recommended)' + + def yes(self, answer: str) -> None: + # TODO + pass + + +class FileHandleLimits(Question): + + _type = 'binary' + _question = 'Do you wish to increase file handle limits? (recommended)' + + def yes(self, answer: str) -> None: + # TODO + pass + + +class RabbitMQ(Question): + """Wait for Rabbit to start, then setup permissions.""" + + def _wait(self) -> None: + nc_wait(_env['extgateway'], '5672') + log_file = '{SNAP_COMMON}/log/rabbitmq/startup_log'.format(**_env) + log_wait(log_file, 'completed') + + def _configure(self) -> None: + """Configure RabbitMQ + + (actions may have already been run, in which case we fail silently). + """ + # Add Erlang HOME to env. + env = dict(**_env) + env['HOME'] = '{SNAP_COMMON}/lib/rabbitmq'.format(**_env) + # Configure RabbitMQ + call('rabbitmqctl', 'add_user', 'openstack', 'rabbitmq', env=env) + shell('rabbitmqctl set_permissions openstack ".*" ".*" ".*"', env=env) + + def yes(self, answer: str) -> None: + log.info('Waiting for RabbitMQ to start ...') + self._wait() + log.info('RabbitMQ started!') + log.info('Configuring RabbitMQ ...') + self._configure() + log.info('RabbitMQ Configured!') + + +class DatabaseSetup(Question): + """Setup keystone permissions, then setup all databases.""" + + def _wait(self) -> None: + nc_wait(_env['extgateway'], '3306') + log_wait('{SNAP_COMMON}/log/mysql/error.log'.format(**_env), + 'mysqld: ready for connections.') + + def _create_dbs(self) -> None: + 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} \ + IDENTIFIED BY '{db}';".format(db=db, **_env)) + + def _bootstrap(self) -> None: + + if call('openstack', 'user', 'show', 'admin'): + return + + bootstrap_url = 'http://{extgateway}:5000/v3/'.format(**_env) + + check('snap-openstack', 'launch', 'keystone-manage', 'bootstrap', + '--bootstrap-password', _env['ospassword'], + '--bootstrap-admin-url', bootstrap_url, + '--bootstrap-internal-url', bootstrap_url, + '--bootstrap-public-url', bootstrap_url) + + def yes(self, answer: str) -> None: + """Setup Databases. + + Create all the MySQL databases we require, then setup the + fernet keys and create the service project. + + """ + log.info('Waiting for MySQL server to start ...') + self._wait() + log.info('Mysql server started! Creating databases ...') + self._create_dbs() + + log.info('Configuring Keystone Fernet Keys ...') + check('snap-openstack', 'launch', 'keystone-manage', + 'fernet_setup', '--keystone-user', 'root', + '--keystone-group', 'root') + check('snap-openstack', 'launch', 'keystone-manage', 'db_sync') + + restart('keystone-*') + + log.info('Bootstrapping Keystone ...') + self._bootstrap() + + log.info('Creating service project ...') + if not call('openstack', 'project', 'show', 'service'): + check('openstack', 'project', 'create', '--domain', + 'default', '--description', 'Service Project', + 'service') + + log.info('Keystone configured!') + + +class NovaSetup(Question): + """Create all relevant nova users and services.""" + + def _flavors(self) -> None: + """Create default flavors.""" + + if not call('openstack', 'flavor', 'show', 'm1.tiny'): + check('openstack', 'flavor', 'create', '--id', '1', + '--ram', '512', '--disk', '1', '--vcpus', '1', 'm1.tiny') + if not call('openstack', 'flavor', 'show', 'm1.small'): + check('openstack', 'flavor', 'create', '--id', '2', + '--ram', '2048', '--disk', '20', '--vcpus', '1', 'm1.small') + if not call('openstack', 'flavor', 'show', 'm1.medium'): + check('openstack', 'flavor', 'create', '--id', '3', + '--ram', '4096', '--disk', '20', '--vcpus', '2', 'm1.medium') + if not call('openstack', 'flavor', 'show', 'm1.large'): + check('openstack', 'flavor', 'create', '--id', '4', + '--ram', '8192', '--disk', '20', '--vcpus', '4', 'm1.large') + if not call('openstack', 'flavor', 'show', 'm1.xlarge'): + check('openstack', 'flavor', 'create', '--id', '5', + '--ram', '16384', '--disk', '20', '--vcpus', '8', + 'm1.xlarge') + + def yes(self, answer: str) -> None: + log.info('Configuring nova ...') + + if not call('openstack', 'user', 'show', 'nova'): + check('openstack', 'user', 'create', '--domain', + 'default', '--password', 'nova', 'nova') + check('openstack', 'role', 'add', '--project', + 'service', '--user', 'nova', 'admin') + + if not call('openstack', 'user', 'show', 'placement'): + check('openstack', 'user', 'create', '--domain', 'default', + '--password', 'placement', 'placement') + 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"', + 'placement') + + 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)) + + check('snap-openstack', 'launch', 'nova-manage', 'api_db', 'sync') + + if 'cell0' not in check_output('snap-openstack', 'launch', + 'nova-manage', 'cell_v2', + 'list_cells'): + check('snap-openstack', 'launch', 'nova-manage', + 'cell_v2', 'map_cell0') + + if 'cell1' not in check_output('snap-openstack', 'launch', + 'nova-manage', 'cell_v2', 'list_cells'): + + check('snap-openstack', 'launch', 'nova-manage', 'cell_v2', + 'create_cell', '--name=cell1', '--verbose') + + check('snap-openstack', 'launch', 'nova-manage', 'db', 'sync') + + restart('nova-*') + + nc_wait(_env['extgateway'], '8774') + + sleep(5) # TODO: log_wait + + log.info('Creating default flavors...') + self._flavors() + + +class NeutronSetup(Question): + """Create all relevant neutron services and users.""" + + def yes(self, answer: str) -> None: + log.info('Configuring Neutron') + + if not call('openstack', 'user', 'show', 'neutron'): + check('openstack', 'user', 'create', '--domain', 'default', + '--password', 'neutron', 'neutron') + check('openstack', 'role', 'add', '--project', 'service', + '--user', 'neutron', 'admin') + + if not call('openstack', 'service', 'show', 'network'): + check('openstack', 'service', 'create', '--name', 'neutron', + '--description', '"OpenStack Network"', 'network') + for endpoint in ['public', 'internal', 'admin']: + call('openstack', 'endpoint', 'create', '--region', + 'microstack', 'network', endpoint, + 'http://{extgateway}:9696'.format(**_env)) + + check('snap-openstack', 'launch', 'neutron-db-manage', 'upgrade', + 'head') + + restart('neutron-*') + + nc_wait(_env['extgateway'], '9696') + + sleep(5) # TODO: log_wait + + if not call('openstack', 'network', 'show', 'test'): + check('openstack', 'network', 'create', 'test') + + if not call('openstack', 'subnet', 'show', 'test-subnet'): + check('openstack', 'subnet', 'create', '--network', 'test', + '--subnet-range', '192.168.222.0/24', 'test-subnet') + + if not call('openstack', 'network', 'show', 'external'): + check('openstack', 'network', 'create', '--external', + '--provider-physical-network=physnet1', + '--provider-network-type=flat', 'external') + if not call('openstack', 'subnet', 'show', 'external-subnet'): + check('openstack', 'subnet', 'create', '--network', 'external', + '--subnet-range', _env['extcidr'], '--no-dhcp', + 'external-subnet') + + if not call('openstack', 'router', 'show', 'test-router'): + check('openstack', 'router', 'create', 'test-router') + check('openstack', 'router', 'add', 'subnet', 'test-router', + 'test-subnet') + check('openstack', 'router', 'set', '--external-gateway', + 'external', 'test-router') + + +class GlanceSetup(Question): + """Setup glance, and download an initial Cirros image.""" + + def _fetch_cirros(self) -> None: + + if call('openstack', 'image', 'show', 'cirros'): + return + + 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)) + download( + 'http://download.cirros-cloud.net/{VER}/{IMG}'.format(**env), + '{SNAP_COMMON}/images/{IMG}'.format(**env)) + + check('openstack', 'image', 'create', '--file', + '{SNAP_COMMON}/images/{IMG}'.format(**env), + '--public', '--container-format=bare', + '--disk-format=qcow2', 'cirros') + + def yes(self, answer: str) -> None: + + log.info('Configuring Glance ...') + + if not call('openstack', 'user', 'show', 'glance'): + check('openstack', 'user', 'create', '--domain', 'default', + '--password', 'glance', 'glance') + check('openstack', 'role', 'add', '--project', 'service', + '--user', 'glance', 'admin') + + if not call('openstack', 'service', 'show', 'image'): + check('openstack', 'service', 'create', '--name', 'glance', + '--description', '"OpenStack Image"', 'image') + for endpoint in ['internal', 'admin', 'public']: + check('openstack', 'endpoint', 'create', '--region', + 'microstack', 'image', endpoint, + 'http://{extgateway}:9292'.format(**_env)) + + check('snap-openstack', 'launch', 'glance-manage', 'db_sync') + + restart('glance*') + + nc_wait(_env['extgateway'], '9292') + + sleep(5) # TODO: log_wait + + self._fetch_cirros() + + +class PostSetup(Question): + """Sneak in any additional cleanup, then set the initialized state.""" + + def yes(self, answer: str) -> None: + + log.info('restarting libvirt and virtlogd ...') + # This fixes an issue w/ logging not getting set. + # TODO: fix issue. + restart('*virt*') + + check('snapctl', 'set', 'initialized=true') + log.info('Complete. Marked microstack as initialized!') diff --git a/tools/init/init/shell.py b/tools/init/init/shell.py new file mode 100644 index 0000000..cc362dc --- /dev/null +++ b/tools/init/init/shell.py @@ -0,0 +1,136 @@ +"""shell.py + +Contains wrappers around subprocess and pymysql commands, specific to +our needs in the init script. + +# TODO capture stdout (and output to log.DEBUG) + +---------------------------------------------------------------------- + +Copyright 2019 Canonical Ltd + +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 subprocess +from time import sleep +from typing import Dict, List + +import pymysql +import wget + +from init.config import Env + + +_env = Env().get_env() + + +def check(*args: List[str], env: Dict = _env) -> int: + """Execute a shell command, raising an error on failed excution. + + :param args: strings to be composed into the bash call. + :param env: defaults to our Env singleton; can be overriden. + + """ + return subprocess.check_call(args, env=env) + + +def check_output(*args: List[str], env: Dict = _env) -> str: + """Execute a shell command, returning the output of the command. + + :param args: strings to be composed into the bash call. + :param env: defaults to our Env singleton; can be overriden. + + Include our env; pass in any extra keyword args. + """ + return subprocess.check_output(args, env=env, + universal_newlines=True).strip() + + +def call(*args: List[str], env: Dict = _env) -> bool: + """Execute a shell command. + + Return True if the call executed successfully (returned 0), or + False if it returned with an error code (return > 0) + + :param args: strings to be composed into the bash call. + :param env: defaults to our Env singleton; can be overriden. + """ + return not subprocess.call(args, env=env) + + +def shell(cmd: str, env: Dict = _env) -> int: + """Execute a command, using the actual bourne again shell. + + Use this in cases where it is difficult to compose a comma + separate list that will get parsed into a succesful bash + command. (E.g., your bash command contains an argument like ".*" + ".*" ".*") + + :param cmd: the command to run. + :param env: defaults to our Env singleton; can be overriden. + + """ + return subprocess.check_call(cmd, shell=True, env=env, + executable='/snap/core18/current/bin/bash') + + +def sql(cmd: str) -> 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 + in our init script. + + :param cmd: sql to execute. + + """ + mysql_conf = '${SNAP_USER_COMMON}/etc/mysql/my.cnf'.format(**_env) + connection = pymysql.connect(host='localhost', user='root', + read_default_file=mysql_conf) + + with connection.cursor() as cursor: + cursor.execute(cmd) + + +def nc_wait(addr: str, port: str) -> None: + """Wait for a service to be answering on a port.""" + print('Waiting for {}:{}'.format(addr, port)) + while not call('nc', '-z', addr, port): + sleep(1) + + +def log_wait(log: str, message: str) -> None: + """Wait until a message appears in a log.""" + while True: + with open(log, 'r') as log_file: + for line in log_file.readlines(): + if message in line: + return + sleep(1) + + +def restart(service: str) -> None: + """Restart a microstack service. + + :param service: the service(s) to be restarted. Can contain wild cards. + e.g. *rabbit* + + """ + check('systemctl', 'restart', 'snap.microstack.{}'.format(service)) + + +def download(url: str, output: str) -> None: + """Download a file to a path""" + wget.download(url, output) diff --git a/tools/init/requirements.txt b/tools/init/requirements.txt new file mode 100644 index 0000000..26faa35 --- /dev/null +++ b/tools/init/requirements.txt @@ -0,0 +1,2 @@ +pymysql +wget diff --git a/tools/init/setup.py b/tools/init/setup.py new file mode 100644 index 0000000..f72aec9 --- /dev/null +++ b/tools/init/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup, find_packages + +setup( + name="microstack_init", + description="Optionally interactive init script for Microstack.", + packages=find_packages(), + version="0.0.1", + entry_points={ + 'console_scripts': [ + 'microstack_init = init.main:main', + ], + }, +) diff --git a/tools/init/test-requirements.txt b/tools/init/test-requirements.txt new file mode 100644 index 0000000..d62cda7 --- /dev/null +++ b/tools/init/test-requirements.txt @@ -0,0 +1,4 @@ +pylint +pep8 +stestr +flake8 diff --git a/tools/init/tests/__init__.py b/tools/init/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/init/tests/test_question.py b/tools/init/tests/test_question.py new file mode 100644 index 0000000..4a5db6f --- /dev/null +++ b/tools/init/tests/test_question.py @@ -0,0 +1,125 @@ +import sys +import os +import unittest + +# 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) + + +############################################################################## +# +# Test Fixtures +# +############################################################################## + + +class InvalidTypeQuestion(Question): + _type = 'foo' + + +class IncompleteQuestion(Question): + _type = 'auto' + + +class GoodAutoQuestion(Question): + _type = 'auto' + + def yes(self, answer): + return 'I am a good question!' + + +class GoodBinaryQuestion(Question): + _type = 'binary' + + def yes(self, answer): + return True + + def no(self, answer): + return False + + +class GoodStringQuestion(Question): + """Pass a string through to the output of Question.ask. + + # TODO right now, we have separate handlers for Truthy and Falsey + answers, and this test class basically makes them do the same + thing. Is this a good pattern? + + """ + _type = 'string' + + def yes(self, answer): + return answer + + def no(self, answer): + return answer + + +############################################################################## +# +# Tests Proper +# +############################################################################## + + +class TestQuestionClass(unittest.TestCase): + """ + Test basic features of the Question class. + + """ + def test_invalid_type(self): + + with self.assertRaises(InvalidQuestion): + InvalidTypeQuestion().ask() + + def test_valid_type(self): + + self.assertTrue(GoodBinaryQuestion()) + + def test_not_implemented(self): + + with self.assertRaises(AnswerNotImplemented): + IncompleteQuestion().ask() + + def test_auto_question(self): + + self.assertEqual(GoodAutoQuestion().ask(), 'I am a good question!') + + +class TestInput(unittest.TestCase): + """ + Test input handling. + + Takes advantage of the fact that we can override the Question + class's input handler. + + """ + def test_binary_question(self): + + q = GoodBinaryQuestion() + + for answer in ['yes', 'Yes', 'y']: + q._input_func = lambda x: answer.encode('utf8') + self.assertTrue(q.ask()) + + for answer in ['No', 'n', 'no']: + q._input_func = lambda x: answer.encode('utf8') + self.assertFalse(q.ask()) + + with self.assertRaises(InvalidAnswer): + q._input_func = lambda x: 'foo'.encode('utf8') + q.ask() + + def test_string_question(self): + q = GoodStringQuestion() + + for answer in ['foo', 'bar', 'baz', '', 'yadayadayada']: + q._input_func = lambda x: answer.encode('utf8') + self.assertEqual(answer, q.ask()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tox.ini b/tox.ini index 1597a4a..1502147 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = multipass +envlist = init_lint, init_unit, multipass skipsdist = True [testenv] @@ -36,3 +36,16 @@ commands = # Just run basic_test.sh, with multipass support. commands = {toxinidir}/tests/basic-test.sh -m + + +[testenv:init_lint] +basepython=python3 +deps = -r{toxinidir}/tools/init/test-requirements.txt + -r{toxinidir}/tools/init/requirements.txt +commands = flake8 {toxinidir}/tools/init/init/ + +[testenv:init_unit] +basepython=python3 +deps = -r{toxinidir}/tools/init/test-requirements.txt + -r{toxinidir}/tools/init/requirements.txt +commands = stestr run {posargs}