diff --git a/.gitignore b/.gitignore index 963e589a..22606278 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ nosetests.xml .project .pydevproject +# PyCharm +.idea + # Complexity output/*.html output/*/index.html @@ -55,4 +58,4 @@ ChangeLog .*sw? # Files created by releasenotes build -releasenotes/build \ No newline at end of file +releasenotes/build diff --git a/octavia_tempest_plugin/config.py b/octavia_tempest_plugin/config.py new file mode 100644 index 00000000..36cb5ba8 --- /dev/null +++ b/octavia_tempest_plugin/config.py @@ -0,0 +1,86 @@ +# Copyright 2016 Rackspace Inc. +# Copyright 2017 Catalyst IT 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 oslo_config import cfg + + +service_option = cfg.BoolOpt( + 'loadbalancer', + default=True, + help="Whether or not loadbalancing service is expected to be available" +) + +octavia_group = cfg.OptGroup(name='loadbalancer', + title='Loadbalancing Service Options') + +OctaviaGroup = [ + cfg.StrOpt("region", + default="", + help="The region name to use. If empty, the value " + "of identity.region is used instead. If no such region " + "is found in the service catalog, the first found one is " + "used."), + cfg.StrOpt('catalog_type', + default='load-balancer', + help='Catalog type of the Octavia service.'), + cfg.StrOpt('endpoint_type', + default='publicURL', + choices=['public', 'admin', 'internal', + 'publicURL', 'adminURL', 'internalURL'], + help="The endpoint type to use for the Octavia service."), + cfg.IntOpt('build_interval', + default=5, + help='Time in seconds between build status checks for ' + 'non-loadbalancer resources to build'), + cfg.IntOpt('build_timeout', + default=30, + help='Timeout in seconds to wait for non-loadbalancer ' + 'resources to build'), + cfg.IntOpt('lb_build_interval', + default=10, + help='Time in seconds between build status checks for a ' + 'loadbalancer.'), + cfg.IntOpt('lb_build_timeout', + default=900, + help='Timeout in seconds to wait for a ' + 'loadbalancer to build.'), + cfg.BoolOpt('premade_server', + default=False, + help='Allows us to use an already provisioned server to test ' + 'loadbalancing.'), + cfg.StrOpt('premade_server_ip', + default=None, + help='IP of the premade server.'), + cfg.StrOpt('premade_server_subnet_id', + default=None, + help='Subnet ID of the premade server.'), + cfg.StrOpt('vip_network_id', + default=None, + help='Existing network ID to use for loadbalancer.'), + cfg.StrOpt('vip_subnet_id', + default=None, + help='Existing subnet ID to use for loadbalancer.'), + cfg.IntOpt('random_server_name_length', + default=0, + help='If non-zero, generate a random name of the length ' + 'provided for each server, in the format "m[A-Z0-9]*". '), + cfg.StrOpt('availability_zone', + default=None, + help='Availability zone to use for creating servers.'), + cfg.StrOpt('member_role', + default='load-balancer_member', + help="Role to add to users created for octavia tests."), +] diff --git a/octavia_tempest_plugin/contrib/__init__.py b/octavia_tempest_plugin/contrib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/octavia_tempest_plugin/contrib/httpd/README.md b/octavia_tempest_plugin/contrib/httpd/README.md new file mode 100644 index 00000000..a6274915 --- /dev/null +++ b/octavia_tempest_plugin/contrib/httpd/README.md @@ -0,0 +1,6 @@ +To build a statically linked binary for httpd on Ubuntu (can run anywhere): + +```sh +sudo apt-get install -y golang +go build -ldflags "-w -linkmode external -extldflags -static" -o httpd.bin httpd.go +``` diff --git a/octavia_tempest_plugin/contrib/httpd/__init__.py b/octavia_tempest_plugin/contrib/httpd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/octavia_tempest_plugin/contrib/httpd/httpd.bin b/octavia_tempest_plugin/contrib/httpd/httpd.bin new file mode 100755 index 00000000..fbae460d Binary files /dev/null and b/octavia_tempest_plugin/contrib/httpd/httpd.bin differ diff --git a/octavia_tempest_plugin/contrib/httpd/httpd.go b/octavia_tempest_plugin/contrib/httpd/httpd.go new file mode 100644 index 00000000..db47242e --- /dev/null +++ b/octavia_tempest_plugin/contrib/httpd/httpd.go @@ -0,0 +1,107 @@ +package main + +import ( + "flag" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +var sess_cookie http.Cookie +var resp string + +type ConnectionCount struct { + mu sync.Mutex + cur_conn int + max_conn int + total_conn int +} + +var scoreboard ConnectionCount + +func (cc *ConnectionCount) open() { + cc.mu.Lock() + defer cc.mu.Unlock() + + cc.cur_conn++ + cc.total_conn++ +} + +func (cc *ConnectionCount) close() { + cc.mu.Lock() + defer cc.mu.Unlock() + + if cc.cur_conn > cc.max_conn { + cc.max_conn = cc.cur_conn + } + cc.cur_conn-- +} + +func (cc *ConnectionCount) stats() (int, int) { + cc.mu.Lock() + defer cc.mu.Unlock() + + return cc.max_conn, cc.total_conn +} + +func (cc *ConnectionCount) reset() { + cc.mu.Lock() + defer cc.mu.Unlock() + + cc.max_conn = 0 + cc.total_conn = 0 +} + +func root_handler(w http.ResponseWriter, r *http.Request) { + scoreboard.open() + defer scoreboard.close() + + http.SetCookie(w, &sess_cookie) + io.WriteString(w, resp) +} + +func slow_handler(w http.ResponseWriter, r *http.Request) { + scoreboard.open() + defer scoreboard.close() + + delay, err := time.ParseDuration(r.URL.Query().Get("delay")) + if err != nil { + delay = 3 * time.Second + } + + time.Sleep(delay) + http.SetCookie(w, &sess_cookie) + io.WriteString(w, resp) +} + +func stats_handler(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &sess_cookie) + max_conn, total_conn := scoreboard.stats() + fmt.Fprintf(w, "max_conn=%d\ntotal_conn=%d\n", max_conn, total_conn) +} + +func reset_handler(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &sess_cookie) + scoreboard.reset() + fmt.Fprintf(w, "reset\n") +} + +func main() { + portPtr := flag.Int("port", 8080, "TCP port to listen on") + idPtr := flag.String("id", "1", "Server ID") + + flag.Parse() + + resp = fmt.Sprintf("%s", *idPtr) + sess_cookie.Name = "JESSIONID" + sess_cookie.Value = *idPtr + + http.HandleFunc("/", root_handler) + http.HandleFunc("/slow", slow_handler) + http.HandleFunc("/stats", stats_handler) + http.HandleFunc("/reset", reset_handler) + portStr := fmt.Sprintf(":%d", *portPtr) + http.ListenAndServe(portStr, nil) +} diff --git a/octavia_tempest_plugin/plugin.py b/octavia_tempest_plugin/plugin.py new file mode 100644 index 00000000..4d5f6a28 --- /dev/null +++ b/octavia_tempest_plugin/plugin.py @@ -0,0 +1,62 @@ +# Copyright 2017 GoDaddy +# Copyright 2017 Catalyst IT 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 os + +from tempest import config +from tempest.test_discover import plugins + +from octavia_tempest_plugin import config as project_config + + +class OctaviaTempestPlugin(plugins.TempestPlugin): + def load_tests(self): + base_path = os.path.split(os.path.dirname( + os.path.abspath(__file__)))[0] + test_dir = "octavia_tempest_plugin/tests" + full_test_dir = os.path.join(base_path, test_dir) + + return full_test_dir, base_path + + def register_opts(self, conf): + conf.register_opt( + project_config.service_option, group='service_available' + ) + conf.register_group(project_config.octavia_group) + conf.register_opts(project_config.OctaviaGroup, + group=project_config.octavia_group.name) + + def get_opt_lists(self): + return [ + ('service_available', [project_config.service_option]), + (project_config.octavia_group.name, project_config.OctaviaGroup) + ] + + def get_service_clients(self): + octavia_config = config.service_client_config( + project_config.octavia_group.name + ) + module_path = 'octavia_tempest_plugin.services.v2.loadbalancer_client' + + params = { + 'name': 'octavia_v2', + 'service_version': 'octavia.v2', + 'module_path': module_path, + 'client_names': ['LoadbalancerClient'], + } + params.update(octavia_config) + + return [params] diff --git a/octavia_tempest_plugin/services/__init__.py b/octavia_tempest_plugin/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/octavia_tempest_plugin/services/v2/__init__.py b/octavia_tempest_plugin/services/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/octavia_tempest_plugin/services/v2/base.py b/octavia_tempest_plugin/services/v2/base.py new file mode 100644 index 00000000..22ed5f2c --- /dev/null +++ b/octavia_tempest_plugin/services/v2/base.py @@ -0,0 +1,54 @@ +# Copyright 2017 Catalyst IT 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 json + +from oslo_serialization import jsonutils +from tempest.lib.common import rest_client + + +class LoadbalancerClientBase(rest_client.RestClient): + def get_list_objs(self, obj): + resp, body = self.get('/v2.0/lbaas/%s' % obj) + + return resp, jsonutils.loads(body) + + def delete_obj(self, obj, id, cascade=False): + url = '/v2.0/lbaas/{obj}/{id}'.format(obj=obj, id=id) + if cascade: + url += '?cascade=True' + return self.delete(url) + + def get_obj(self, obj, id): + resp, body = self.get('/v2.0/lbaas/{obj}/{id}'.format(obj=obj, id=id)) + + return resp, jsonutils.loads(body) + + def post_json(self, obj, req_body, extra_headers={}): + headers = {"Content-Type": "application/json"} + headers = dict(headers, **extra_headers) + url_path = '/v2.0/lbaas/%s' % obj + + resp, body = self.post(url_path, json.dumps(req_body), headers=headers) + + return resp, jsonutils.loads(body) + + def put_json(self, obj, id, req_body, extra_headers={}): + headers = {"Content-Type": "application/json"} + headers = dict(headers, **extra_headers) + url_path = '/v2.0/lbaas/%s/%s' % (obj, id) + + resp, body = self.put(url_path, json.dumps(req_body), headers=headers) + + return resp, jsonutils.loads(body) diff --git a/octavia_tempest_plugin/services/v2/loadbalancer_client.py b/octavia_tempest_plugin/services/v2/loadbalancer_client.py new file mode 100644 index 00000000..4af302a6 --- /dev/null +++ b/octavia_tempest_plugin/services/v2/loadbalancer_client.py @@ -0,0 +1,30 @@ +# Copyright 2017 GoDaddy +# Copyright 2017 Catalyst IT 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 tempest.lib import exceptions + +from octavia_tempest_plugin.services.v2 import base as client_base + + +class LoadbalancerClient(client_base.LoadbalancerClientBase): + """Tempest REST client for Octavia V2 API.""" + + def delete_resource(self, res, id, ignore_error=False, cascade=False): + try: + resp, _ = self.delete_obj(res, id, cascade=cascade) + return resp + except (exceptions.NotFound, exceptions.Conflict): + if ignore_error: + return None diff --git a/octavia_tempest_plugin/tests/server_util.py b/octavia_tempest_plugin/tests/server_util.py new file mode 100644 index 00000000..2500195c --- /dev/null +++ b/octavia_tempest_plugin/tests/server_util.py @@ -0,0 +1,322 @@ +# Copyright 2017 Catalyst IT 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 pkg_resources +import random +import shlex +import string +import subprocess +import tempfile +import time + +from oslo_log import log as logging +from oslo_utils import excutils +from tempest import config +from tempest.lib.common import fixed_network +from tempest.lib.common import rest_client +from tempest.lib.common.utils.linux import remote_client +from tempest.lib.common.utils import test_utils +from tempest.lib import exceptions + +CONF = config.CONF +LOG = logging.getLogger(__name__) + +SERVER_BINARY = pkg_resources.resource_filename( + 'octavia_tempest_plugin.contrib.httpd', 'httpd.bin') + + +class BuildErrorException(exceptions.TempestException): + message = "Server %(server_id)s failed to build and is in ERROR status" + + +def _get_task_state(body): + return body.get('OS-EXT-STS:task_state', None) + + +def wait_for_server_status(client, server_id, status, ready_wait=True, + extra_timeout=0, raise_on_error=True): + """Waits for a server to reach a given status.""" + + # NOTE(afazekas): UNKNOWN status possible on ERROR + # or in a very early stage. + body = client.show_server(server_id)['server'] + old_status = server_status = body['status'] + old_task_state = task_state = _get_task_state(body) + start_time = int(time.time()) + timeout = client.build_timeout + extra_timeout + while True: + # NOTE(afazekas): Now the BUILD status only reached + # between the UNKNOWN->ACTIVE transition. + # TODO(afazekas): enumerate and validate the stable status set + if status == 'BUILD' and server_status != 'UNKNOWN': + return + if server_status == status: + if ready_wait: + if status == 'BUILD': + return + # NOTE(afazekas): The instance is in "ready for action state" + # when no task in progress + if task_state is None: + # without state api extension 3 sec usually enough + time.sleep(CONF.compute.ready_wait) + return + else: + return + + time.sleep(client.build_interval) + body = client.show_server(server_id)['server'] + server_status = body['status'] + task_state = _get_task_state(body) + if (server_status != old_status) or (task_state != old_task_state): + LOG.info('State transition "%s" ==> "%s" after %d second wait', + '/'.join((old_status, str(old_task_state))), + '/'.join((server_status, str(task_state))), + time.time() - start_time) + if (server_status == 'ERROR') and raise_on_error: + if 'fault' in body: + raise BuildErrorException(body['fault'], + server_id=server_id) + else: + raise BuildErrorException(server_id=server_id) + + timed_out = int(time.time()) - start_time >= timeout + + if timed_out: + expected_task_state = 'None' if ready_wait else 'n/a' + message = ('Server %(server_id)s failed to reach %(status)s ' + 'status and task state "%(expected_task_state)s" ' + 'within the required time (%(timeout)s s).' % + {'server_id': server_id, + 'status': status, + 'expected_task_state': expected_task_state, + 'timeout': timeout}) + message += ' Current status: %s.' % server_status + message += ' Current task state: %s.' % task_state + caller = test_utils.find_test_caller() + if caller: + message = '(%s) %s' % (caller, message) + raise exceptions.TimeoutException(message) + old_status = server_status + old_task_state = task_state + + +def wait_for_server_termination(client, server_id, ignore_error=False): + """Waits for server to reach termination.""" + try: + body = client.show_server(server_id)['server'] + except exceptions.NotFound: + return + old_status = body['status'] + old_task_state = _get_task_state(body) + start_time = int(time.time()) + while True: + time.sleep(client.build_interval) + try: + body = client.show_server(server_id)['server'] + except exceptions.NotFound: + return + server_status = body['status'] + task_state = _get_task_state(body) + if (server_status != old_status) or (task_state != old_task_state): + LOG.info('State transition "%s" ==> "%s" after %d second wait', + '/'.join((old_status, str(old_task_state))), + '/'.join((server_status, str(task_state))), + time.time() - start_time) + if server_status == 'ERROR' and not ignore_error: + raise exceptions.DeleteErrorException(resource_id=server_id) + + if int(time.time()) - start_time >= client.build_timeout: + raise exceptions.TimeoutException + old_status = server_status + old_task_state = task_state + + +def create_server(clients, name=None, flavor=None, image_id=None, + validatable=False, validation_resources=None, + tenant_network=None, wait_until=None, availability_zone=None, + **kwargs): + """Common wrapper utility returning a test server. + + This method is a common wrapper returning a test server that can be + pingable or sshable. + + :param name: Name of the server to be provisioned. If not defined a random + string ending with '-instance' will be generated. + :param flavor: Flavor of the server to be provisioned. If not defined, + CONF.compute.flavor_ref will be used instead. + :param image_id: ID of the image to be used to provision the server. If not + defined, CONF.compute.image_ref will be used instead. + :param clients: Client manager which provides OpenStack Tempest clients. + :param validatable: Whether the server will be pingable or sshable. + :param validation_resources: Resources created for the connection to the + server. Include a keypair, a security group and an IP. + :param tenant_network: Tenant network to be used for creating a server. + :param wait_until: Server status to wait for the server to reach after + its creation. + :returns: a tuple + """ + if name is None: + r = random.SystemRandom() + name = "m{}".format("".join( + [r.choice(string.ascii_uppercase + string.digits) + for i in range( + CONF.loadbalancer.random_server_name_length - 1)] + )) + if flavor is None: + flavor = CONF.compute.flavor_ref + if image_id is None: + image_id = CONF.compute.image_ref + if availability_zone is None: + availability_zone = CONF.loadbalancer.availability_zone + + kwargs = fixed_network.set_networks_kwarg( + tenant_network, kwargs) or {} + + if availability_zone: + kwargs.update({'availability_zone': availability_zone}) + + if CONF.validation.run_validation and validatable: + LOG.debug("Provisioning test server with validation resources %s", + validation_resources) + if 'security_groups' in kwargs: + kwargs['security_groups'].append( + {'name': validation_resources['security_group']['name']}) + else: + try: + kwargs['security_groups'] = [ + {'name': validation_resources['security_group']['name']}] + except KeyError: + LOG.debug("No security group provided.") + + if 'key_name' not in kwargs: + try: + kwargs['key_name'] = validation_resources['keypair']['name'] + except KeyError: + LOG.debug("No key provided.") + + if CONF.validation.connect_method == 'floating': + if wait_until is None: + wait_until = 'ACTIVE' + + body = clients.servers_client.create_server(name=name, imageRef=image_id, + flavorRef=flavor, + **kwargs) + server = rest_client.ResponseBody(body.response, body['server']) + + def _setup_validation_fip(): + if CONF.service_available.neutron: + ifaces = clients.interfaces_client.list_interfaces(server['id']) + validation_port = None + for iface in ifaces['interfaceAttachments']: + if not tenant_network or (iface['net_id'] == + tenant_network['id']): + validation_port = iface['port_id'] + break + if not validation_port: + # NOTE(artom) This will get caught by the catch-all clause in + # the wait_until loop below + raise ValueError('Unable to setup floating IP for validation: ' + 'port not found on tenant network') + clients.floating_ips_client.update_floatingip( + validation_resources['floating_ip']['id'], + port_id=validation_port) + else: + fip_client = clients.compute_floating_ips_client + fip_client.associate_floating_ip_to_server( + floating_ip=validation_resources['floating_ip']['ip'], + server_id=server['id']) + + if wait_until: + try: + wait_for_server_status( + clients.servers_client, server['id'], wait_until) + + # Multiple validatable servers are not supported for now. Their + # creation will fail with the condition above (l.58). + if CONF.validation.run_validation and validatable: + if CONF.validation.connect_method == 'floating': + _setup_validation_fip() + + except Exception: + with excutils.save_and_reraise_exception(): + try: + clients.servers_client.delete_server(server['id']) + except Exception: + LOG.exception('Deleting server %s failed', server['id']) + try: + wait_for_server_termination(clients.servers_client, + server['id']) + except Exception: + LOG.exception('Server %s failed to delete in time', + server['id']) + + return server + + +def clear_server(servers_client, id): + try: + servers_client.delete_server(id) + except exceptions.NotFound: + pass + wait_for_server_termination(servers_client, id) + + +def _execute(cmd, cwd=None): + args = shlex.split(cmd) + subprocess_args = {'stdout': subprocess.PIPE, + 'stderr': subprocess.STDOUT, + 'cwd': cwd} + proc = subprocess.Popen(args, **subprocess_args) + stdout, stderr = proc.communicate() + if proc.returncode != 0: + LOG.error('Command %s returned with exit status %s, output %s, ' + 'error %s', cmd, proc.returncode, stdout, stderr) + raise exceptions.CommandFailed(proc.returncode, cmd, stdout, stderr) + return stdout + + +def copy_file(floating_ip, private_key, local_file, remote_file): + """Copy web server script to instance.""" + with tempfile.NamedTemporaryFile() as key: + key.write(private_key.encode('utf-8')) + key.flush() + dest = ( + "%s@%s:%s" % + (CONF.validation.image_ssh_user, floating_ip, remote_file) + ) + cmd = ("scp -v -o UserKnownHostsFile=/dev/null " + "-o StrictHostKeyChecking=no " + "-i %(key_file)s %(file)s %(dest)s" % {'key_file': key.name, + 'file': local_file, + 'dest': dest}) + return _execute(cmd) + + +def run_webserver(connect_ip, private_key): + httpd = "/dev/shm/httpd.bin" + + linux_client = remote_client.RemoteClient( + connect_ip, + CONF.validation.image_ssh_user, + pkey=private_key, + ) + linux_client.validate_authentication() + + # TODO(kong): We may figure out an elegant way to copy file to instance + # in future. + LOG.debug("Copying the webserver binary to the server.") + copy_file(connect_ip, private_key, SERVER_BINARY, httpd) + + LOG.debug("Starting services on the server.") + linux_client.exec_command('sudo screen -d -m %s -port 80 -id 1' % httpd) + linux_client.exec_command('sudo screen -d -m %s -port 81 -id 2' % httpd) diff --git a/octavia_tempest_plugin/tests/test_octavia_tempest_plugin.py b/octavia_tempest_plugin/tests/test_octavia_tempest_plugin.py deleted file mode 100644 index 7805347c..00000000 --- a/octavia_tempest_plugin/tests/test_octavia_tempest_plugin.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- - -# 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. - -""" -test_octavia_tempest_plugin ----------------------------------- - -Tests for `octavia_tempest_plugin` module. -""" - -from octavia_tempest_plugin.tests import base - - -class TestOctavia_tempest_plugin(base.TestCase): - - def test_something(self): - pass diff --git a/octavia_tempest_plugin/tests/v2/__init__.py b/octavia_tempest_plugin/tests/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/octavia_tempest_plugin/tests/v2/api/test_loadbalancer.py b/octavia_tempest_plugin/tests/v2/api/test_loadbalancer.py new file mode 100644 index 00000000..4325321e --- /dev/null +++ b/octavia_tempest_plugin/tests/v2/api/test_loadbalancer.py @@ -0,0 +1,61 @@ +# Copyright 2017 GoDaddy +# Copyright 2017 Catalyst IT 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 tempest.lib.common.utils import data_utils +from tempest.lib import decorators + +from octavia_tempest_plugin.tests.v2 import base + + +class LoadbalancerTest(base.BaseLoadbalancerTest): + name_prefix = 'Tempest-LoadbalancerTest' + + @decorators.idempotent_id('94c66b04-1ab3-4375-a921-89e48d833c1d') + @decorators.attr(type='slow') + def test_crud_loadbalancer(self): + # Create loadbalancer + params = {} + if self.vip_network_id: + params['vip_network_id'] = self.vip_network_id + if self.vip_subnet_id: + params['vip_subnet_id'] = self.vip_subnet_id + lb_id = self.create_loadbalancer(**params)['id'] + + # Get loadbalancers + resp, body = self.lb_client.get_list_objs('loadbalancers') + self.assertEqual(200, resp.status) + self.assertIn( + lb_id, + [item['id'] for item in body['loadbalancers']] + ) + + # Update loadbalancer + new_name = data_utils.rand_name('lb', prefix=self.name_prefix) + self.update_loadbalancer(lb_id, name=new_name) + + # Get loadbalancer + resp, body = self.lb_client.get_obj('loadbalancers', lb_id) + self.assertEqual(200, resp.status) + self.assertEqual(new_name, body['loadbalancer']['name']) + + # Delete loadbalancer + self.delete_loadbalancer(lb_id) + + # Get loadbalancers + resp, body = self.lb_client.get_list_objs('loadbalancers') + self.assertEqual(200, resp.status) + self.assertNotIn( + lb_id, + [item['id'] for item in body['loadbalancers']] + ) diff --git a/octavia_tempest_plugin/tests/v2/base.py b/octavia_tempest_plugin/tests/v2/base.py new file mode 100644 index 00000000..7fec3244 --- /dev/null +++ b/octavia_tempest_plugin/tests/v2/base.py @@ -0,0 +1,524 @@ +# Copyright 2017 GoDaddy +# Copyright 2017 Catalyst IT 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 time + +from oslo_log import log as logging +import requests +from tempest import config +from tempest.lib.common.utils import data_utils +from tempest.lib.common.utils import test_utils +from tempest.lib import exceptions as lib_exc +from tempest import test +import tenacity + +from octavia_tempest_plugin.tests import server_util + +CONF = config.CONF +LOG = logging.getLogger(__name__) + + +class BaseLoadbalancerTest(test.BaseTestCase): + credentials = (['lbmember', CONF.loadbalancer.member_role], 'admin') + name_prefix = 'Tempest-BaseLoadbalancerTest' + vip_network_id = None + vip_subnet_id = None + vip_address = None + member_subnet_id = None + member_network_id = None + vm_ip = None + + @classmethod + def skip_checks(cls): + super(BaseLoadbalancerTest, cls).skip_checks() + + if not CONF.service_available.loadbalancer: + raise cls.skipException("Loadbalancing service is not available.") + + service_list = { + 'loadbalancing': CONF.service_available.loadbalancer, + 'compute': CONF.service_available.nova, + 'image': CONF.service_available.glance, + 'neutron': CONF.service_available.neutron + } + for srv, available in service_list.items(): + if not available: + raise cls.skipException("Service %s is not available." % srv) + + @classmethod + def setup_clients(cls): + super(BaseLoadbalancerTest, cls).setup_clients() + + cls.lb_client = cls.os_roles_lbmember.octavia_v2.LoadbalancerClient() + cls.servers_client = cls.os_roles_lbmember.servers_client + cls.networks_client = cls.os_roles_lbmember.networks_client + cls.subnets_client = cls.os_roles_lbmember.subnets_client + cls.interfaces_client = cls.os_roles_lbmember.interfaces_client + cls.sg_rule_client = cls.os_roles_lbmember.security_group_rules_client + cls.floatingip_client = cls.os_roles_lbmember.floating_ips_client + cls.floatingip_adm_client = cls.os_admin.floating_ips_client + cls.routers_adm_client = cls.os_admin.routers_client + + if CONF.identity.auth_version == 'v3': + project_id = cls.os_roles_lbmember.auth_provider.auth_data[1][ + 'project']['id'] + else: + project_id = cls.os_roles_lbmember.auth_provider.auth_data[ + 1]['token']['tenant']['id'] + + cls.tenant_id = project_id + cls.user_id = cls.os_roles_lbmember.auth_provider.auth_data[1][ + 'user']['id'] + + @classmethod + def resource_setup(cls): + """Creates network resources.""" + super(BaseLoadbalancerTest, cls).resource_setup() + if not CONF.loadbalancer.vip_network_id: + network_name = data_utils.rand_name( + 'network', + prefix=cls.name_prefix + ) + body = cls.networks_client.create_network(name=network_name) + cls.vip_network_id = body['network']['id'] + cls.addClassResourceCleanup( + test_utils.call_and_ignore_notfound_exc, + cls.networks_client.delete_network, + cls.vip_network_id + ) + + subnet_name = data_utils.rand_name( + 'subnet', + prefix=cls.name_prefix + ) + body = cls.subnets_client.create_subnet( + name=subnet_name, + network_id=cls.vip_network_id, + cidr='10.100.1.0/24', + ip_version=4, + gateway_ip='10.100.1.1', + ) + cls.vip_subnet_id = body['subnet']['id'] + cls.addClassResourceCleanup( + test_utils.call_and_ignore_notfound_exc, + cls.subnets_client.delete_subnet, + cls.vip_subnet_id + ) + cls.member_network_id = cls.vip_network_id + cls.member_subnet_id = cls.vip_subnet_id + + if CONF.validation.connect_method == 'floating': + router_name = data_utils.rand_name( + 'router', + prefix=cls.name_prefix + ) + kwargs = { + 'name': router_name, + 'tenant_id': cls.tenant_id + } + if CONF.network.public_network_id: + kwargs['external_gateway_info'] = dict( + network_id=CONF.network.public_network_id + ) + body = cls.routers_adm_client.create_router(**kwargs) + cls.router_id = body['router']['id'] + cls.addClassResourceCleanup( + test_utils.call_and_ignore_notfound_exc, + cls.routers_adm_client.delete_router, + cls.router_id, + ) + + cls.routers_adm_client.add_router_interface( + cls.router_id, subnet_id=cls.member_subnet_id + ) + cls.addClassResourceCleanup( + test_utils.call_and_ignore_notfound_exc, + cls.routers_adm_client.remove_router_interface, + cls.router_id, + subnet_id=cls.member_subnet_id + ) + else: + cls.vip_network_id = CONF.loadbalancer.vip_network_id + cls.vip_subnet_id = CONF.loadbalancer.vip_subnet_id + cls.member_subnet_id = CONF.loadbalancer.premade_server_subnet_id + + @tenacity.retry( + wait=tenacity.wait_fixed(CONF.loadbalancer.lb_build_interval), + stop=tenacity.stop_after_delay(CONF.loadbalancer.lb_build_timeout), + retry=tenacity.retry_if_exception_type(AssertionError) + ) + def await_loadbalancer_active(self, id, name=None): + resp, body = self.lb_client.get_obj('loadbalancers', id) + self.assertEqual(200, resp.status) + + lb = body['loadbalancer'] + + if lb['provisioning_status'] == 'ERROR': + raise Exception('Failed to wait for loadbalancer to be active, ' + 'actual provisioning_status: ERROR') + + self.assertEqual('ACTIVE', lb['provisioning_status']) + + if name: + self.assertEqual(name, lb['name']) + + @tenacity.retry( + wait=tenacity.wait_fixed(CONF.loadbalancer.lb_build_interval), + stop=tenacity.stop_after_delay(CONF.loadbalancer.lb_build_timeout), + retry=tenacity.retry_if_exception_type(AssertionError) + ) + def await_loadbalancer_deleted(self, id): + resp, body = self.lb_client.get_obj('loadbalancers', id) + self.assertEqual(200, resp.status) + + lb = body['loadbalancer'] + self.assertEqual('DELETED', lb['provisioning_status']) + + @tenacity.retry( + wait=tenacity.wait_fixed(CONF.loadbalancer.lb_build_interval), + stop=tenacity.stop_after_delay(CONF.loadbalancer.lb_build_timeout), + retry=tenacity.retry_if_exception_type(AssertionError) + ) + def await_listener_active(self, id, name=None): + resp, body = self.lb_client.get_obj('listeners', id) + self.assertEqual(200, resp.status) + + listener = body['listener'] + + if listener['provisioning_status'] == 'ERROR': + raise Exception('Failed to wait for listener to be active, actual ' + 'provisioning_status: ERROR') + + self.assertEqual('ACTIVE', listener['provisioning_status']) + self.assertEqual('ONLINE', listener['operating_status']) + + if name: + self.assertEqual(name, listener['name']) + + def create_loadbalancer(self, **kwargs): + name = data_utils.rand_name('lb', prefix=self.name_prefix) + payload = {'loadbalancer': {'name': name}} + payload['loadbalancer'].update(kwargs) + + resp, body = self.lb_client.post_json('loadbalancers', payload) + self.assertEqual(201, resp.status) + + lb = body['loadbalancer'] + lb_id = lb['id'] + + self.addCleanup(self.delete_loadbalancer, lb_id, ignore_error=True) + LOG.info('Waiting for loadbalancer %s to be active', lb_id) + self.await_loadbalancer_active( + lb_id, + name=payload['loadbalancer']['name'] + ) + + self.lb_id = lb['id'] + self.vip_port = lb['vip_port_id'] + if CONF.validation.connect_method == 'floating': + self.vip_address = self._associate_floatingip() + else: + self.vip_address = lb['vip_address'] + + return lb + + def update_loadbalancer(self, lb_id, **kwargs): + new_name = data_utils.rand_name('lb', prefix=self.name_prefix) + payload = {'loadbalancer': {'name': new_name}} + payload['loadbalancer'].update(kwargs) + + resp, _ = self.lb_client.put_json('loadbalancers', lb_id, payload) + self.assertEqual(200, resp.status) + + # Wait for loadbalancer to be active + LOG.info( + 'Waiting for loadbalancer %s to be active after update', lb_id + ) + self.await_loadbalancer_active(lb_id) + + def delete_loadbalancer(self, id, ignore_error=False): + """Delete loadbalancer and wait for it to be deleted. + + Only if loadbalancer is deleted completely can other network resources + be deleted. + """ + resp = self.lb_client.delete_resource('loadbalancers', id, + ignore_error=ignore_error, + cascade=True) + if resp: + self.assertEqual(204, resp.status) + + LOG.info('Waiting for loadbalancer %s to be deleted', id) + self.await_loadbalancer_deleted(id) + + def create_listener(self, lb_id, **kwargs): + name = data_utils.rand_name('listener', prefix=self.name_prefix) + payload = { + 'listener': { + 'protocol': 'HTTP', + 'protocol_port': '80', + 'loadbalancer_id': lb_id, + 'name': name + } + } + payload['listener'].update(kwargs) + + resp, body = self.lb_client.post_json('listeners', payload) + self.assertEqual(201, resp.status) + + listener_id = body['listener']['id'] + + LOG.info( + 'Waiting for loadbalancer %s to be active after listener %s ' + 'creation', lb_id, listener_id + ) + self.addCleanup(self.delete_listener, listener_id, lb_id, + ignore_error=True) + self.await_loadbalancer_active(lb_id) + + return body['listener'] + + def update_listener(self, listener_id, lb_id, **kwargs): + new_name = data_utils.rand_name('listener', prefix=self.name_prefix) + payload = {'listener': {'name': new_name}} + payload['listener'].update(kwargs) + + resp, _ = self.lb_client.put_json('listeners', listener_id, payload) + self.assertEqual(200, resp.status) + + # Wait for loadbalancer to be active + LOG.info( + 'Waiting for loadbalancer %s to be active after listener %s ' + 'update', lb_id, listener_id + ) + self.await_loadbalancer_active(lb_id) + + def delete_listener(self, id, lb_id, ignore_error=False): + resp = self.lb_client.delete_resource('listeners', id, + ignore_error=ignore_error) + if resp: + self.assertEqual(204, resp.status) + + LOG.info( + 'Waiting for loadbalancer %s to be active after deleting ' + 'listener %s', lb_id, id + ) + self.await_loadbalancer_active(lb_id) + + def create_pool(self, lb_id, **kwargs): + name = data_utils.rand_name('pool', prefix=self.name_prefix) + payload = { + 'pool': { + 'name': name, + 'loadbalancer_id': lb_id, + 'lb_algorithm': 'ROUND_ROBIN', + 'protocol': 'HTTP' + } + } + payload['pool'].update(kwargs) + + resp, body = self.lb_client.post_json('pools', payload) + self.assertEqual(201, resp.status) + + pool_id = body['pool']['id'] + + LOG.info( + 'Waiting for loadbalancer %s to be active after pool %s creation', + lb_id, pool_id + ) + self.addCleanup(self.delete_pool, pool_id, lb_id, ignore_error=True) + self.await_loadbalancer_active(lb_id) + + return body['pool'] + + def update_pool(self, pool_id, lb_id, **kwargs): + new_name = data_utils.rand_name('pool', prefix=self.name_prefix) + payload = {'pool': {'name': new_name}} + payload['pool'].update(kwargs) + + resp, _ = self.lb_client.put_json('pools', pool_id, payload) + self.assertEqual(200, resp.status) + + # Wait for loadbalancer to be active + LOG.info( + 'Waiting for loadbalancer %s to be active after pool %s update', + lb_id, pool_id + ) + self.await_loadbalancer_active(lb_id) + + def delete_pool(self, id, lb_id, ignore_error=False): + resp = self.lb_client.delete_resource('pools', id, + ignore_error=ignore_error) + if resp: + self.assertEqual(204, resp.status) + + LOG.info( + 'Waiting for loadbalancer %s to be active after deleting ' + 'pool %s', lb_id, id + ) + self.await_loadbalancer_active(lb_id) + + def create_member(self, pool_id, lb_id, **kwargs): + name = data_utils.rand_name('member', prefix=self.name_prefix) + payload = {'member': {'name': name}} + payload['member'].update(kwargs) + + resp, body = self.lb_client.post_json( + 'pools/%s/members' % pool_id, payload + ) + self.assertEqual(201, resp.status) + + member_id = body['member']['id'] + + LOG.info( + 'Waiting for loadbalancer %s to be active after adding ' + 'member %s', lb_id, member_id + ) + self.addCleanup(self.delete_member, member_id, pool_id, + lb_id, ignore_error=True) + self.await_loadbalancer_active(lb_id) + + return body['member'] + + def delete_member(self, id, pool_id, lb_id, ignore_error=False): + resp = self.lb_client.delete_resource( + 'pools/%s/members' % pool_id, + id, + ignore_error=ignore_error + ) + if resp: + self.assertEqual(204, resp.status) + + LOG.info( + 'Waiting for loadbalancer %s to be active after deleting ' + 'member %s', lb_id, id + ) + self.await_loadbalancer_active(lb_id) + + def _wait_for_lb_functional(self, vip_address): + session = requests.Session() + start = time.time() + + while time.time() - start < CONF.loadbalancer.lb_build_timeout: + try: + session.get("http://{0}".format(vip_address), timeout=2) + time.sleep(1) + return + except Exception: + LOG.warning('Server is not passing initial traffic. Waiting.') + time.sleep(1) + LOG.error('Server did not begin passing traffic within the timeout ' + 'period. Failing test.') + raise lib_exc.ServerFault() + + def check_members_balanced(self): + session = requests.Session() + response_counts = {} + + self._wait_for_lb_functional(self.vip_address) + + # Send a number requests to lb vip + for i in range(20): + try: + r = session.get('http://{0}'.format(self.vip_address), + timeout=2) + LOG.debug('Loadbalancer response: %s', r.content) + + if r.content in response_counts: + response_counts[r.content] += 1 + else: + response_counts[r.content] = 1 + + except Exception: + LOG.exception('Failed to send request to loadbalancer vip') + raise lib_exc.BadRequest(message='Failed to connect to lb') + + # Ensure the correct number of members + self.assertEqual(2, len(response_counts)) + + # Ensure both members got the same number of responses + self.assertEqual(1, len(set(response_counts.values()))) + + def _delete_floatingip(self, floating_ip): + self.floatingip_adm_client.update_floatingip( + floating_ip, + port_id=None + ) + test_utils.call_and_ignore_notfound_exc( + self.floatingip_adm_client.delete_floatingip, floating_ip + ) + + def _associate_floatingip(self): + # Associate floatingip with loadbalancer vip + floatingip = self.floatingip_adm_client.create_floatingip( + floating_network_id=CONF.network.public_network_id + )['floatingip'] + floatip_vip = floatingip['floating_ip_address'] + self.addCleanup(self._delete_floatingip, floatingip['id']) + + LOG.debug('Floating ip %s created.', floatip_vip) + + self.floatingip_adm_client.update_floatingip( + floatingip['id'], + port_id=self.vip_port + ) + + LOG.debug('Floating ip %s associated with vip.', floatip_vip) + return floatip_vip + + def create_backend(self): + if CONF.loadbalancer.premade_server_ip: + self.vm_ip = CONF.loadbalancer.premade_server_ip + return + + vr_resources = self.vr.resources + vm = server_util.create_server( + self.os_roles_lbmember, + validatable=True, + validation_resources=vr_resources, + wait_until='ACTIVE', + tenant_network=({'id': self.member_network_id} + if self.member_network_id else None), + ) + self.addCleanup( + server_util.clear_server, + self.os_roles_lbmember.servers_client, + vm['id'] + ) + + # Get vm private ip address. + ifaces = self.interfaces_client.list_interfaces(vm['id']) + for iface in ifaces['interfaceAttachments']: + if not self.member_network_id or (iface['net_id'] == + self.vip_network_id): + for ip_info in iface['fixed_ips']: + if not self.vip_subnet_id or (ip_info['subnet_id'] == + self.vip_subnet_id): + self.vm_ip = ip_info['ip_address'] + break + if self.vm_ip: + break + + self.assertIsNotNone(self.vm_ip) + + if CONF.validation.connect_method == 'floating': + connect_ip = vr_resources['floating_ip']['floating_ip_address'] + else: + connect_ip = self.vm_ip + + server_util.run_webserver( + connect_ip, + vr_resources['keypair']['private_key'] + ) + LOG.debug('Web servers are running inside %s', vm['id']) diff --git a/octavia_tempest_plugin/tests/v2/scenario/__init__.py b/octavia_tempest_plugin/tests/v2/scenario/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/octavia_tempest_plugin/tests/v2/scenario/test_basic_ops.py b/octavia_tempest_plugin/tests/v2/scenario/test_basic_ops.py new file mode 100644 index 00000000..25d16ff1 --- /dev/null +++ b/octavia_tempest_plugin/tests/v2/scenario/test_basic_ops.py @@ -0,0 +1,92 @@ +# Copyright 2017 Catalyst IT 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 oslo_log import log as logging + +from tempest import config +from tempest.lib.common import validation_resources as vr +from tempest.lib import decorators + +from octavia_tempest_plugin.tests.v2 import base + +LOG = logging.getLogger(__name__) +CONF = config.CONF + + +class BasicOpsTest(base.BaseLoadbalancerTest): + name_prefix = 'Tempest-BasicOpsTest' + + def setUp(self): + super(BasicOpsTest, self).setUp() + + # Setup network resources for instance + resources = dict( + keypair=True, + security_group=True, + security_group_rules=True, + floating_ip=CONF.validation.connect_method == 'floating' + ) + self.vr = self.useFixture( + vr.ValidationResourcesFixture( + self.os_roles_lbmember, + use_neutron=True, + floating_network_id=CONF.network.public_network_id, + **resources + ) + ) + + # Add security group rule to allow http request + self.sg_rule_client.create_security_group_rule( + security_group_id=self.vr.resources['security_group']['id'], + protocol='tcp', + ethertype='IPv4', + port_range_min=80, + port_range_max=81, + direction='ingress' + ) + + self.create_backend() + + @decorators.idempotent_id('250ebc41-645e-43fb-a79a-e3035f338e2a') + @decorators.attr(type='slow') + def test_basic_ops(self): + # Create loadbalancer + params = {} + if self.vip_network_id: + params['vip_network_id'] = self.vip_network_id + if self.vip_subnet_id: + params['vip_subnet_id'] = self.vip_subnet_id + + self.create_loadbalancer(**params) + + # Create pool + pool = self.create_pool(self.lb_id) + self.pool_id = pool['id'] + + # Create listener + params = {'default_pool_id': self.pool_id} + listener = self.create_listener(self.lb_id, **params) + self.listener_id = listener['id'] + + # Add members to the pool + for port in [80, 81]: + params = { + 'address': self.vm_ip, + 'protocol_port': port, + } + if self.member_subnet_id: + params['subnet_id'] = self.member_subnet_id + + self.create_member(self.pool_id, self.lb_id, **params) + + self.check_members_balanced() diff --git a/requirements.txt b/requirements.txt index 6de9f4e8..70c84feb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,7 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. -pbr!=2.1.0,>=2.0.0 # Apache-2.0 +pbr>=2.0.0,!=2.1.0 # Apache-2.0 +oslotest>=1.10.0 # Apache-2.0 +tempest>=17.1.0 # Apache-2.0 +tenacity>=3.2.1 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 993c4f7f..b4526143 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,6 +18,10 @@ classifier = Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 +[global] +setup-hooks = + pbr.hooks.setup_hook + [files] packages = octavia_tempest_plugin @@ -48,3 +52,7 @@ output_file = octavia_tempest_plugin/locale/octavia_tempest_plugin.pot all_files = 1 build-dir = releasenotes/build source-dir = releasenotes/source + +[entry_points] +tempest.test_plugins = + octavia-tempest-plugin = octavia_tempest_plugin.plugin:OctaviaTempestPlugin