Create scenario tests for loadbalancers

This patch implements the tempest plugin for basic load balancer
operations in Octavia. It contains tests for loadbalancer API and
a minimal operation test for loadbalancing functionality.

Steps for testing in devstack environment:

- Clone octavia-tempest-plugin repo, check out this patch, install
  octavia-tempest-plugin project.
- Create a tempest work directory by running 'tempest init <workdir>'.
  In the etc/tempest.conf, add 'loadbalancer = true' in
  'service_available' section.
- Set a big value to 'OS_TEST_TIMEOUT' in .testr.conf
- Add or modify other related config options (image, network, flavor,
  validation, etc).
- Run 'tempest run --regex ^octavia_tempest_plugin'

Co-Authored-By: Lingxian Kong <anlin.kong@gmail.com>
Co-Authored-By: Adam Harwell <flux.adam@gmail.com>
Change-Id: Ibc2904f431b15dfca2ff8e38e0d4d06c1430abea
diff --git a/octavia_tempest_plugin/config.py b/octavia_tempest_plugin/config.py
new file mode 100644
index 0000000..36cb5ba
--- /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 0000000..e69de29
--- /dev/null
+++ b/octavia_tempest_plugin/contrib/__init__.py
diff --git a/octavia_tempest_plugin/contrib/httpd/README.md b/octavia_tempest_plugin/contrib/httpd/README.md
new file mode 100644
index 0000000..a627491
--- /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 0000000..e69de29
--- /dev/null
+++ b/octavia_tempest_plugin/contrib/httpd/__init__.py
diff --git a/octavia_tempest_plugin/contrib/httpd/httpd.bin b/octavia_tempest_plugin/contrib/httpd/httpd.bin
new file mode 100755
index 0000000..fbae460
--- /dev/null
+++ b/octavia_tempest_plugin/contrib/httpd/httpd.bin
Binary files differ
diff --git a/octavia_tempest_plugin/contrib/httpd/httpd.go b/octavia_tempest_plugin/contrib/httpd/httpd.go
new file mode 100644
index 0000000..db47242
--- /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 0000000..4d5f6a2
--- /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 0000000..e69de29
--- /dev/null
+++ b/octavia_tempest_plugin/services/__init__.py
diff --git a/octavia_tempest_plugin/services/v2/__init__.py b/octavia_tempest_plugin/services/v2/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/octavia_tempest_plugin/services/v2/__init__.py
diff --git a/octavia_tempest_plugin/services/v2/base.py b/octavia_tempest_plugin/services/v2/base.py
new file mode 100644
index 0000000..22ed5f2
--- /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 0000000..4af302a
--- /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 0000000..2500195
--- /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 7805347..0000000
--- 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 0000000..e69de29
--- /dev/null
+++ b/octavia_tempest_plugin/tests/v2/__init__.py
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 0000000..4325321
--- /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 0000000..7fec324
--- /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 0000000..e69de29
--- /dev/null
+++ b/octavia_tempest_plugin/tests/v2/scenario/__init__.py
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 0000000..25d16ff
--- /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()