mirror of https://github.com/apache/cloudstack.git
448 lines
17 KiB
Markdown
448 lines
17 KiB
Markdown
# Marvin Refactor
|
|
The Marvin test framework will undergo some key improvements as part of this
|
|
refactor:
|
|
|
|
1. All CloudStack resources modelled as entities which are more object-oriented
|
|
2. Data modelled as factories that form basic building blocks
|
|
3. DSL support for assertions
|
|
|
|
## Introduction
|
|
Marvin which has been used thus far for testing has undergone several
|
|
significant changes in this refactor. Many of these changes were driven by the
|
|
need for succinctly describing a test scenario in a few lines of code. This
|
|
document describes the changes and the reasons behind this refactor. While this
|
|
makes the framework simple to use the internals of marvin have become a bit
|
|
complex. For this reason we will cover some of the internal workings as part of
|
|
this document.
|
|
|
|
## Rationale
|
|
Two main rationale were responsible for this refactor
|
|
|
|
1. Brittle nature of the integration library
|
|
2. Separating data from the test
|
|
|
|
### Integration library
|
|
Typically to write a test case previously the test case author was expected to
|
|
know (in advance) all the APIs he was going to call to complete his scenario.
|
|
With the growing list of APIs, their parameters and optional arguments it
|
|
becomes tedious often to compose a single API call. To overcome this the
|
|
integration libraries were written. These libraries (`integration.lib.base,
|
|
integration.lib.common` etc) present a list of resources or entities - eg:
|
|
VirtualMachine, VPC, VLAN to the library user. Each entity can perform a set of
|
|
operations that in turn transform into an API call.
|
|
|
|
```python
|
|
class VirtualMachine(object):
|
|
def deploy(self, apiclient, service, template, zone):
|
|
cmd = deployVirtualMachine.deployVirtualMachineCmd()
|
|
cmd.serviceofferingid = service
|
|
cmd.templateid = template
|
|
...
|
|
...
|
|
def list(self,apiclient)
|
|
cmd = listVirtualMachines.listVirtualMachinesCmd()
|
|
return apiclient.listVirtualMachines(cmd)
|
|
```
|
|
This makes the library usage more object-oriented. So in the testcase the
|
|
author only has to make a call to the VirtualMachine class when
|
|
creating/destroying/starting/stopping virtualmachine instances.
|
|
|
|
The disadvantage of this approach is that the integration library is
|
|
hand-written and brittle. When changes are made several tests are affected in
|
|
the process. There are also inconsistencies caused by mixing the data required
|
|
for the API call with the arguments of the operation being performed. eg:
|
|
|
|
```python
|
|
class VirtualMachine(object):
|
|
....
|
|
@classmethod
|
|
def create(cls, apiclient, services, templateid=None, accountid=None,
|
|
domainid=None, zoneid=None, networkids=None, serviceofferingid=None,
|
|
securitygroupids=None, projectid=None, startvm=None,
|
|
diskofferingid=None, affinitygroupnames=None, group=None,
|
|
hostid=None, keypair=None, mode='basic', method='GET'):
|
|
....
|
|
....
|
|
````
|
|
In this call, every argument is optionally lookedup in the services dictionary
|
|
or as part of the argument thereby complicating the body of the create(..)
|
|
call. Also the naming and the size of the API call is daunting for anyone using
|
|
the library.
|
|
|
|
### Data vs Test
|
|
Another major disadvantage of the previous approach was data required for the
|
|
test was mixed with the test itself. This made it difficult to generate new
|
|
data from existing data objects. Data being highly coupled with the test
|
|
reduces readability.
|
|
|
|
Additionaly due to the strict structure of this data it would impose itself
|
|
onto the implementation of a resource's methods in the integration library.
|
|
|
|
However all of the data is reusable by other tests if presented as factories.
|
|
The refactor will address this using factories that act as building blocks for
|
|
creating reusable data. The document also describes how these blocks are extended.
|
|
|
|
## CloudStack API Generation
|
|
The process of API module generation remains the same as before. CloudStack
|
|
expresses its API in XML and JSON via the ApiDiscovery plugin. For instance the
|
|
createFirewallRule API looks as follows (some fields removed for brevity)
|
|
|
|
```json
|
|
"api": [
|
|
{
|
|
"name": "createFirewallRule",
|
|
"description": "Creates a firewall rule for a given ip address",
|
|
"isasync": true,
|
|
"params": [
|
|
{
|
|
"name": "cidrlist",
|
|
"description": "the cidr list to forward traffic from",
|
|
"type": "list",
|
|
"length": 255,
|
|
"required": false
|
|
},
|
|
{
|
|
"name": "icmpcode",
|
|
},
|
|
{
|
|
"name": "icmptype",
|
|
},
|
|
{
|
|
"name": "type",
|
|
},
|
|
],
|
|
"response": [
|
|
{
|
|
"name": "state",
|
|
"description": "the state of the rule",
|
|
"type": "string"
|
|
},
|
|
{
|
|
"name": "endport",
|
|
},
|
|
{
|
|
"name": "protocol",
|
|
},
|
|
],
|
|
"entity": "Firewall"
|
|
}
|
|
]
|
|
```
|
|
|
|
This JSON/XML can be used to create a binding in your favorite language and for
|
|
Marvin's purpose this will be python. An API module named
|
|
createFirewallRule.py with two classes (request and response) -
|
|
createFirewallRuleCmd and createFirewallRuleResponse represents the creation of
|
|
firewall rules.
|
|
|
|
### Changes to API Discovery
|
|
Generated API modules now include the `entity` attribute from the listApi
|
|
response. The API discovery plugin has been enhanced to include the type of
|
|
entity that an API is acting upon. For instance when doing createFirewallRule
|
|
the entity that the user is dealing with is the `Firewall`. We do not
|
|
intuitively guess what entity an API acts upon but depend on the CloudStack
|
|
endpoint to tell us this information. Mostly because we cannot always predict
|
|
the entity an API acts upon using the name of the API
|
|
|
|
eg: dedicatePublicIpRange
|
|
|
|
```json
|
|
listapisresponse: {
|
|
count: 1,
|
|
api: [
|
|
{
|
|
name: "dedicatePublicIpRange",
|
|
description: "Dedicates a Public IP range to an account",
|
|
isasync: false,
|
|
related: "listVlanIpRanges",
|
|
params: [],
|
|
response: [],
|
|
entity: "VlanIpRange"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
This transforms into the following Marvin entity class through auto-generation:
|
|
|
|
```python
|
|
class VlanIpRange(CloudStackEntity):
|
|
|
|
def dedicate(self, apiclient, account, domainid, **kwargs):
|
|
cmd = dedicatePublicIpRange.dedicatePublicIpRangeCmd()
|
|
cmd.id = self.id
|
|
cmd.account = account
|
|
cmd.domainid = domainid
|
|
[setattr(cmd, key, value) for key,value in kwargs.iteritems()]
|
|
publiciprange = apiclient.dedicatePublicIpRange(cmd)
|
|
return publiciprange if publiciprange else None
|
|
|
|
```
|
|
|
|
> kwargs represents all the optional arguments for dedicatePublicIpRange
|
|
|
|
The use of the entity in generating a higher level model for the CloudStack API
|
|
is described in the next section.
|
|
|
|
## Entity and Factory Generation
|
|
Marvin now includes a new module named `generate` that contains all the code
|
|
generators.
|
|
|
|
1. `xmltoapi.py` - this module is responsible for converting the JSON/XML
|
|
response to a python binding. Previously this was the `codegenerator.py`
|
|
2. `apitoentity.py` - this module is responsible for grouping actions on a
|
|
given entity into a single module and define all its actions as methods on the
|
|
entity object.
|
|
3. `entity.py` - is the base entity creator that transforms an API into a
|
|
cloudstackEntity
|
|
4. `factory.py` - is the base factory creator that transforms an API into a
|
|
factory
|
|
|
|
For eg: in the method createFirewallRule the `entity` is the Firewall and the
|
|
`action` being performed on the entity is `create`
|
|
|
|
So our entity becomes
|
|
|
|
```python
|
|
class Firewall:
|
|
def create(...):
|
|
createFirewallRule()
|
|
```
|
|
|
|
Almost all APIs are transformed naturally into this model but there are a few
|
|
exceptions. These exceptions are dealt with by the `linguist.py` module in
|
|
which APIs that don't split this way are broken down using special
|
|
transformers.
|
|
|
|
### Required and Optional Arguments
|
|
All required arguments to an API will be available in the API operation
|
|
|
|
```python
|
|
Entity.verb(reqd1=None, reqd2=None, ..., **kwargs)
|
|
```
|
|
|
|
Here the `Entity` (eg:Firewall) can perform an operation `verb()` (eg:create)
|
|
using the arguments `[reqd1, reqd2]`. The optional arguments (if any) will be
|
|
passed as key, value pairs to the keyword args `**kwargs`.
|
|
|
|
All entity classes are autogenerated and placed in the `marvin.entity` module.
|
|
You may want to look at some sample entities like virtualmachine.py or
|
|
network.py. To anyone who has used the previous version of marvin, these will
|
|
look familiar. If you are looking at them for the first time, it will be
|
|
obvious to you that each entity is a simple class defined with CRUD operations
|
|
that map to the cloudStack API.
|
|
|
|
1. **Creators**
|
|
A creator of an entity is the API operation that brings the entity into
|
|
existence on the cloud. For instance a firewall rule is created using the
|
|
createFirewallRule API. Or a virtualmachine comes into existence with the
|
|
deployVirtualMachine command. These are our creators for entities firewall and
|
|
virtualmachines respectively. Every entity class's `__init__` method is
|
|
basically a call to its creator
|
|
|
|
2. **Enumerators**
|
|
Often it is not necessary to bring an entity into existence since it is already
|
|
present on the cloud infrastructure. We simply list* these entities and should
|
|
still be able to treat them and use them like entities created using their
|
|
corresponding creator methods. The list* APIs become our enumerators for each
|
|
entity.
|
|
|
|
## Factories
|
|
Factories in cloudstack are implemented using the
|
|
[factory_boy](http://factoryboy.readthedocs.org/en/latest/) framework. The
|
|
factory_boy framework helps cloudstack define complex relationships in its
|
|
model. For eg. In order to create a virtualmachine typically one needs a
|
|
service offering, a template and a zone present to be able to launch the VM.
|
|
Factory boy enables traversing these object relationships effectively
|
|
(top-down or bottom-up) to create those objects.
|
|
|
|
Every entity in the new framework is created using its corresponding factory
|
|
`EntityFactory`. Factories can be thought of as objects that carry necessary
|
|
and sufficient data to satisfy the API call that brings the entity into
|
|
existence. For example in order to create an account the `AccountFactory` will
|
|
carry the `firstname, lastname, email, username` of the Account since these
|
|
are the required arguments to the `createAccount` API.
|
|
|
|
So the account factory looks as follows:
|
|
|
|
```python
|
|
import factory
|
|
|
|
class AccountFactory(factory):
|
|
|
|
FACTORY_FOR = Account
|
|
|
|
accounttype = None
|
|
firstname = None
|
|
lastname = None
|
|
email = None
|
|
username = None
|
|
password = None
|
|
```
|
|
|
|
Here the `AccountFactory` is a bare representation with all None fields. These
|
|
are the default factories. The default factories are simply base classes for
|
|
defining hierarchical data using inheritance. For instance we have three
|
|
types of accounts in cloudstack - DomainAdmin, Admin and User
|
|
|
|
Each of these accounttypes represents an inheritance from the AccountFactory.
|
|
And for each factory we have a specific value for the `accounttype`. In fact we
|
|
don't have to repeat ourselves when defining a factory for each type of account:
|
|
|
|
> UserAccount(AccountFactory)
|
|
|
|
> AdminAccount(UserAccount) with (accounttype=1)
|
|
|
|
> DomainAdminAccount(UserAccount) with (accounttype=2)
|
|
|
|
By simply altering the accounttype and having Admin and DomainAdmin inherit
|
|
from User we have defined factories for all types of accounts in cloudstack
|
|
|
|
In order to create accounts in our tests all we have to do is the following:
|
|
|
|
```python
|
|
class TestAccounts(cloudstackTestCase):
|
|
|
|
def setUp(...):
|
|
apiclient = getApiClient()
|
|
|
|
def test_AccountForUser(...):
|
|
user = UserAccount(apiclient)
|
|
assert user is valid
|
|
|
|
def test_AccountForAdmin(...):
|
|
admin = AdminAccount(apiclient)
|
|
assert admin is valid
|
|
|
|
def test_AccountForDomainAdmin(...):
|
|
domadmin = DomainAdminAccount(apiclient)
|
|
assert domadmin is active
|
|
|
|
def tearDown(...):
|
|
user.delete()
|
|
admin.delete()
|
|
domadmin.delete()
|
|
```
|
|
|
|
## Basic tools for extending factories
|
|
|
|
### Sequences
|
|
Sequences are provided by factory boy to randomize the object generated by each
|
|
call to the factory. Typically these are incremented integers but for the
|
|
CloudStack objects each distinguishing attribute is randomized to prevent
|
|
collisions and duplicate objects.
|
|
|
|
To define an attribute as a sequence we simply call the factory.Sequence(..)
|
|
method with a lambda function defining said sequence.
|
|
|
|
eg:
|
|
|
|
```python
|
|
class SharedNetworkOffering(NetworkOfferingFactory):
|
|
name = factory.Sequence(lambda n: 'SharedOffering' + my_random_generator_function(n))
|
|
...
|
|
```
|
|
|
|
### SubFactory
|
|
SubFactories are an important factory_boy building block for creating factories
|
|
that depend on other factories.
|
|
|
|
For eg: in order to create a SharedNetwork a networkofferingid of a
|
|
SharedNetworkOffering is required. So we first call on the factory of
|
|
SharedNetworkOffering using the factory.SubFactory(..) and use the id to create
|
|
the SharedNetwork using the SharedNetwork's factory
|
|
|
|
```python
|
|
class SharedNetwork(NetworkFactory):
|
|
name = factory.Sequence(...)
|
|
networkoffering = \
|
|
factory.SubFactory(
|
|
SharedNetworkOffering,
|
|
attr1=val1
|
|
)
|
|
networkofferingid = networkoffering.id
|
|
```
|
|
|
|
RelatedFactory is a special case of SubFactory in that RelatedFactories are
|
|
created after the existing factory is created.
|
|
|
|
SubFactories are very powerful to chain many factories together to compose
|
|
complex objects in cloudstack.
|
|
|
|
### PostGeneration Hooks
|
|
In many cases additional hooks are done to simplify working with cloud
|
|
resources. For instance, when creating a virtual machine in an advanced zone it
|
|
is useful to associate a NAT rule to be able to SSH into the virtual machine
|
|
for post processing the effects on the virtualmachine like testing connectivity
|
|
to the internet for instance. PostGeneration hooks work after factories have
|
|
been created to perform such special functions. For examples, check the
|
|
`marvin.factory.data.vm` module for the VirtualMachineWithStaticNat factory
|
|
where we create a static nat rule allowing SSH access to the created VM.
|
|
|
|
## Guidelines for defining new factories
|
|
All factories are auto-generated and there is no need to define the default
|
|
factories. Test case authors will mostly be creating data factories inherited
|
|
from the default factories. All the data factories are defined in
|
|
`marvin.factory.data`. Currently implementations are provided for often used
|
|
data objects.
|
|
|
|
1. networkoffering
|
|
2. networks
|
|
3. service and disk offerings
|
|
4. security groups
|
|
5. virtualmachine
|
|
6. vpcoffering
|
|
7. vpcvirtualmachine
|
|
8. firewallrules
|
|
9. ingress and egress rules
|
|
|
|
and many more implementations should serve as examples to extend new data
|
|
objects.
|
|
|
|
Factory naming convention is simple. Any data inheriting from default factory
|
|
`EntityFactory` should be named without the suffix `Factory`. The data should
|
|
take the name of the purpose of the factory. Use simple prepositions
|
|
(Of,And,With etc) to combine words. For instance: VirtualMachineWithStaticNat
|
|
or VirtualMachineInIsolatedNetwork. Naming the data clearly aids its widespread
|
|
use. A badly named factory will likely not be used in more than one test.
|
|
|
|
## Should DSL assertions
|
|
The typical assertion capabilites of unittest are enough to express all
|
|
validation but it does not read naturally. Should_dsl is a library that makes
|
|
the assertions read like natural language. This is installed by default with
|
|
marvin now enabling all test cases to write assertions using simple dsl
|
|
statements
|
|
|
|
eg:
|
|
|
|
```python
|
|
vm = VirtualMachineIsolatedNetwork(apiclient)
|
|
vm.state | should | equal_to('Running')
|
|
vm.nic | should_not | be(None)
|
|
```
|
|
|
|
## Utilities
|
|
All the pre-existing utilities from the previous `util.py` are still available
|
|
with enhancements in the util.py module. The legacy util.py module is
|
|
deprecated but retained since older tests refer to this module. All new changes
|
|
should go to the util.py under marvin/
|
|
|
|
## unittest2 and nose2
|
|
Marvin earlier was coupled with Python2.7 since python's unittest did not have
|
|
the same capabilites in versions <2.7. With unittest2 all features are now
|
|
backported to older python implementations. Marvin has also switched to
|
|
unittest2 so that we don't have to depend on the specific version of python to
|
|
be able to install and use marvin for testing. This change is internal and
|
|
should not be felt by the test case writer.
|
|
|
|
> There are plans to move to nose2 as well but this is separated from factory
|
|
> work at the moment.
|
|
|
|
## Legacy Libraries and Tests
|
|
In order to not disrupt the running of existing tests all the older libraries
|
|
in `base.py`, `common.py` and `util.py` are moved to the legacy module. Any new
|
|
tests should be written using factories. Older libraries are retained to be
|
|
able to run our existing tests whose imports will be switched as part of this
|
|
refactor.
|