+
+
+
+
+
+
+
+ These are end-to-end integration tests for the NetApp ONTAP primary storage plugin in Apache
+ CloudStack.
+ Every test drives real CloudStack API calls and then cross-checks the result on the actual ONTAP
+ system.
+ Both sides must agree for a test to pass.
+
+
+
+
+
🧪
+
Test Code
+
Python
Marvin framework
your laptop
+
+
+
+
+
+
☁️
+
CloudStack
+
Management server
KVM agent
10.193.56.62
+
+
+
+
+
+
🔷
+
NetApp ONTAP
+
ONTAP REST API
SVM: vs0
10.196.38.187
+
+
+
+
+
+
☁️
+
CloudStack side
+
Pool state, volume listing, VM state — verified via
+ listStoragePools, listVolumes, listVirtualMachines.
+
+
+
+
🔷
+
ONTAP side
+
FlexVol state, LUNs, igroups, export policies, LUN-maps — verified via
+ direct ONTAP REST API calls from OntapRestClient.
+
+
+
🔗
+
Both must agree
+
A test only passes if the CS API and the ONTAP REST API both report
+ the expected state. Orphaned ONTAP objects cause test failures.
+
+
+
+
+
+
+
+
+
+ All test files live under test/integration/plugins/ontap/. The tree mirrors the protocol
+ × concern matrix.
+
+
+ test/integration/plugins/ontap/
+ ├── ontap.cfg ← environment
+ config: IPs, credentials, zone info
+ ├── ontap_test_base.py ←
+ shared base class + ONTAP REST client (imported by all test files)
+ ├── TEST_CASES.html ← test
+ case reference (this repo)
+ ├── OVERVIEW.html ← this
+ file
+ │
+ ├── nfs3/ ← NFS3 protocol
+ tests
+ │ ├── pool/
+ │ │ ├── test_pool_lifecycle.py 8 tests — create/disable/enable/maintenance/delete
+ │ │ ├── test_pool_with_volumes.py 7 tests — same lifecycle with a live CS volume
+ │ │ └── test_zone_scoped_pool.py 4 tests — zone scope (attachZone)
+ │ ├── volume/
+ │ │ └── test_volume_lifecycle.py 5 tests — CS volume create/delete semantics
+ │ └── instance/
+ │ └── test_vm_volume_attach.py 8 tests — pool + VM + hot attach/detach
+ │
+ └── iscsi/ ← iSCSI protocol
+ tests (mirrors nfs3/)
+ ├── pool/
+ │ ├── test_pool_lifecycle.py 8 tests — iSCSI pool + igroup assertions
+ │ ├── test_pool_with_volumes.py 7 tests — pool with LUN-backed volume
+ │ └── test_zone_scoped_pool.py 4 tests — zone scope
+ ├── volume/
+ │ └── test_volume_lifecycle.py 5 tests — LUN create/delete per CS volume
+ └── instance/
+ └── test_vm_volume_attach.py 8 tests — pool + VM + LUN-map lifecycle
+
+
+
+
💡
+
+ Why is ontap_test_base.py in the parent folder?
+ All 10 test files share the same base class. Keeping it at the top level means one import
+ (from ontap_test_base import …) works from any subdirectory — as long as you set
+ PYTHONPATH=test/integration/plugins/ontap when running the tests.
+
+
+
+
+
+
+
+
+
+ Marvin is CloudStack's own Python-based integration test framework. It ships inside the
+ CloudStack repo at tools/marvin/.
+
+
+
+
What Marvin gives you
+
+ cloudstackTestCase — base class for all test classes
+ getClsTestClient() — reads ontap.cfg, connects to CloudStack
+ API and MySQL
+ getApiClient() — pre-authenticated CloudStack API client
+ getParsedTestDataConfig() — parsed ontap.cfg as a Python dict
+
+ - Auto-discovery of all
test_NN_* methods and runs them sorted
+ @attr(tags=[…]) — tag-based test filtering
+
+
+
+
What Marvin does NOT do
+
+ - Marvin does not talk to ONTAP — that's our
OntapRestClient
+ - Marvin does not spin up CloudStack — you need a running management server
+ - Marvin does not clean up after failed tests automatically —
tearDownClass
+ handles it
+ - Marvin is not pytest — it uses Python's
unittest runner under the hood via
+ nosetests
+
+
+
+
+
+
⚠️
+
+ Running Marvin — always use python3 -m nose, not the installed
+ nosetests binary. On macOS the binary may have a stale shebang pointing to a
+ non-existent Python from the CLT toolchain. The -m nose form always uses the
+ correct interpreter.
+
+
+
+
+
+
+
+
+
+ Every test file follows the same 5-part structure. The example below is from
+ nfs3/pool/test_pool_lifecycle.py.
+
+
+
+# ① Apache 2.0 license header (required on every source file)
+# Licensed to the Apache Software Foundation ...
+
+"""
+② Module docstring — workflow summary, prerequisites, run command
+Sequential workflow integration tests for NFS3 primary storage pool.
+Workflow: 01 Create 02 Disable 03 Enable 04 Maintenance ...
+"""
+
+# ③ Imports — Marvin + ONTAP base classes
+from marvin.cloudstackAPI import createStoragePool as createStoragePoolAPI
+from marvin.lib.base import StoragePool
+from ontap_test_base import OntapRestClient, OntapTestBase
+
+# ④ TestData — config values + createStoragePool parameters
+class TestData:
+ def __init__(self, storage_ip, svm_name, username, password, ...):
+ self.testdata = {
+ "primaryStorage": {
+ "managed": True, "capacitybytes": 3355443200,
+ "details": { "protocol": "NFS3", "storageIP": storage_ip, ... }
+ }, ...
+ }
+
+# ⑤ Test class — sequential numbered methods
+class TestOntapNFS3PrimaryStorageWorkflow(OntapTestBase):
+
+ pool = None # ← class-level shared state
+ volume = None
+ pool_ep_name = None
+
+ @classmethod
+ def setUpClass(cls): ... # connect, resolve zone/cluster/hosts
+
+ def _create_pool(self): ... # helper — NOT a test
+
+ @attr(tags=["nfs3_workflow"], required_hardware=True)
+ def test_01_create_primary_storage_pool(self): ...
+ def test_02_disable_storage_pool(self): ...
+ def test_03_enable_storage_pool(self): ...
+
+
+
+ ①
+ Apache 2.0 license header — required by repo policy. Checked by
+ Apache RAT on every PR.
+
+
+ ②
+ Module docstring — tells the reader what workflow the file covers and
+ how to run it standalone.
+
+
+ ③
+ Marvin API imports + our shared ontap_test_base. No
+ credentials in code.
+
+
+ ④
+ TestData holds all config values. It reads from
+ ontap.cfg via setUpClass — never hard-codes IPs or
+ passwords.
+
+
+ ⑤
+ The test class. Methods named test_NN_* are discovered
+ and run in sorted order by nosetests.
+
+
+
+
+
+
+
+
+
+ Four patterns appear in every test file. Understanding these is the key to reading any test.
+
+
+
+
+
+
+
+
+
+def test_01_create_pool(self):
+ pool = self._create_pool()
+ self.pool = pool # instance attr
+ # ← GONE after test_01 ends!
+
+def test_02_disable_pool(self):
+ self.pool.id # ← AttributeError
+
+
+
+
+def test_01_create_pool(self):
+ pool = self._create_pool()
+ self.__class__.pool = pool
+ # ↑ class attr — survives all tests
+
+def test_02_disable_pool(self):
+ self.__class__.pool.id # ✓ works
+
+
+
+
ℹ️
+
nosetests creates a new instance of the test class for every test
+ method. Instance attributes (self.pool) are thrown away between tests.
+ Class attributes (self.__class__.pool) persist for the lifetime of the
+ class — i.e., for the whole suite.
+
+
+
+
+
+
+
+
+
+
+
+@attr(tags=["nfs3_workflow"], required_hardware=True)
+def test_03_enable_storage_pool(self):
+ self.assertIsNotNone(
+ self.__class__.pool,
+ "Pool absent — test_01 must pass first"
+ )
+ # rest of test ...
+
+
+
✅
+
Without this guard, a missing pool causes an AttributeError
+ deep in the test body — confusing to read. With the guard, the failure message
+ immediately tells you which earlier test to fix.
+
+
+
+
+
+
+
+
+
+
+
+def _create_pool(self):
+ ps = self.testdata["primaryStorage"]
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = "OntapNFS3_12345"
+ cmd.url = "nfs://10.196.38.187/ontap"
+ cmd.zoneid = self.zone.id
+ cmd.clusterid = self.cluster.id
+ cmd.podid = self.cluster.podid
+ cmd.scope = "CLUSTER"
+ cmd.provider = "NetApp ONTAP"
+ cmd.tags = "ontap-nfs3"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+ # ↑ This produces: details[1].protocol="NFS3", details[2].storageIP=..., etc.
+ # NEVER use StoragePool.create() — it does not support this indexed syntax.
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+
+
⚠️
+
The CloudStack API for createStoragePool passes plugin-specific details as
+ numbered index parameters (details[1].key,
+ details[1].value, …). The Marvin helper StoragePool.create()
+ does not generate this format. Always build the command manually as shown above.
+
+
+
+
+
+
+
+
+
+
+
+# CloudStack operations are asynchronous — state changes take time.
+# Never read state directly after an API call:
+def test_04_enter_maintenance_mode(self):
+ cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ cmd.id = self.__class__.pool.id
+ self.apiClient.enableStorageMaintenance(cmd)
+
+ result = self._poll_pool_state(
+ self.__class__.pool.id,
+ "Maintenance",
+ timeout=120 # seconds to wait
+ )
+ self.assertEqual(result.state, "Maintenance")
+
+# _poll_pool_state() calls listStoragePools every 5s until
+# the state matches or timeout is exceeded.
+
+
+
+
+
+
+
+
+
+
+
+ All 10 test classes extend OntapTestBase. It handles the boilerplate that would
+ otherwise appear in every file: connecting to CloudStack, resolving zone/cluster/hosts, creating a
+ test account, and cleaning up after the suite runs.
+
+
+
+
+
OntapTestBase — what setUpClass does
+
+
+
+
+
Connect to CloudStack
+
Reads ontap.cfg, opens API client on port 8096,
+ opens MySQL connection.
+
+
+
+
+
+
Resolve zone, pod, cluster
+
Calls get_zone(), list_clusters()
+ to find the first available KVM cluster.
+
+
+
+
+
+
List cluster hosts
+
Calls listHosts to get all KVM hosts — needed
+ for export policy and igroup assertions.
+
+
+
+
+
+
Create test account + disk offering
+
Creates a temporary CloudStack account and a matching disk
+ offering used for volumes.
+
+
+
+
+
+
tearDownClass (cleanup)
+
Best-effort: force-deletes the pool, volume, disk offering,
+ account. Runs even if tests fail.
+
+
+
+
+
+
+
OntapTestBase — methods you call in tests
+
+
+
+
+ | Method |
+ Purpose |
+
+
+
+
+ | _poll_pool_state(id, state, timeout) |
+ Polls listStoragePools until target state or
+ timeout |
+
+
+ | _create_volume(pool_id) |
+ Creates a CloudStack data volume on the given pool |
+
+
+ | _delete_pool(pool_id, forced) |
+ Enters Maintenance then calls deleteStoragePool
+ |
+
+
+ | _parse_pool_details(pool) |
+ Extracts key→value dict from pool details attribute |
+
+
+
+
+
+
Class attributes set by setUpClass
+
+
+
+
+ | Attribute |
+ What it holds |
+
+
+
+
+ | cls.zone |
+ First available CloudStack zone |
+
+
+ | cls.cluster |
+ First KVM cluster in that zone |
+
+
+ | cls.cluster_hosts |
+ List of KVM hosts in the cluster |
+
+
+ | cls.account |
+ Temporary test account object |
+
+
+ | cls.domain |
+ Root domain |
+
+
+ | cls.ontap |
+ OntapRestClient instance (set by each subclass) |
+
+
+
+
+
+
+
+
+
+ OntapRestClient — the ONTAP side verifier
+ OntapRestClient is a thin HTTPS wrapper around the ONTAP REST API. Every assertion that
+ starts with "ONTAP:" uses one of these methods.
+
+
+
+
+
+ | Method |
+ ONTAP REST endpoint called |
+ Used to assert |
+
+
+
+
+ | get_volume(name) |
+ GET /api/storage/volumes?name=<n> |
+ FlexVol exists and state == "online" |
+
+
+ | get_export_policy(name) |
+ GET /api/protocols/nfs/export-policies?name=<n> |
+ NFS3 export policy exists with correct client IPs |
+
+
+ | get_data_lifs(svm_name) |
+ GET /api/network/ip/interfaces?svm.name=<n> |
+ At least one NFS/iSCSI data LIF is present on SVM |
+
+
+ | get_igroup(svm_name, name) |
+ GET /api/protocols/san/igroups?name=<n> |
+ iSCSI igroup exists; host IQN is in its initiator list |
+
+
+ | list_luns_in_volume(svm, vol_name) |
+ GET /api/storage/luns?location.volume.name=<n> |
+ LUN created/removed inside the pool's FlexVol |
+
+
+ | list_lun_maps_for_volume(svm, vol_name) |
+ GET /api/protocols/san/lun-maps?lun.location.volume.name=<n> |
+ LUN-map created on attach, removed on VM stop/detach |
+
+
+ | list_files_in_volume(svm, vol_name) |
+ GET /api/storage/volumes/<uuid>/files |
+ Data file for volume UUID present after NFS3 attach |
+
+
+
+
+
+
+
+
+# After createStoragePool succeeds on CloudStack side,
+# verify the corresponding ONTAP FlexVol was created and is online:
+ontap_vol = self.ontap.get_volume(pool.name)
+
+self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol not found for pool '%s'" % pool.name
+)
+self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'" % ontap_vol.get("state")
+)
+
+
+
+
+
+
+
+
+ ontap.cfg is a JSON file that Marvin reads at startup to find CloudStack, MySQL, and
+ ONTAP. Never commit real credentials. The file is gitignored.
+
+
+
+
+
+
+{
+ "mgtSvr": [{
+ "mgtSvrIp": "<CS_IP>",
+ "port": 8096,
+ "user": "admin",
+ "passwd": "password"
+ }],
+ "dbSvr": {
+ "dbSvr": "<CS_IP>",
+ "port": 3306,
+ "user": "cloud",
+ "passwd": "cloud"
+ },
+ "ontap": {
+ "storageIP": "<ONTAP_IP>",
+ "svmName": "vs0",
+ "username": "admin",
+ "password": "<pw>"
+ }
+}
+
+
+
+
+
Key fields explained
+
+
mgtSvr[0].mgtSvrIp
+
CloudStack management server IP — where the CS API runs
+
mgtSvr[0].port
+
8096 = integration API (no auth). Must be enabled in CS
+ config.
+
dbSvr.dbSvr
+
MySQL server IP — Marvin uses this for direct DB queries
+
ontap.storageIP
+
ONTAP cluster management IP — used by OntapRestClient for
+ REST calls
+
ontap.svmName
+
The SVM (Storage Virtual Machine) that hosts NFS and iSCSI services
+
+
ontap.username/password
+
ONTAP admin credentials — used for REST API authentication only
+
+
+
+
+
+
+
+
+
+
+
+
📌
+
+ Always run from the repo root — PYTHONPATH must point at the
+ ontap/ folder so Python can find ontap_test_base.py when running files
+ in subdirectories.
+
+
+
+
+
+
+
+ PYTHONPATH=test/integration/plugins/ontap \
+ python3 -m nose --with-marvin \
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \
+ test/integration/plugins/ontap/ -v
+
+
+
+
+
+
+
+
+ PYTHONPATH=test/integration/plugins/ontap \
+ python3 -m nose --with-marvin \
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \
+ test/integration/plugins/ontap/nfs3/pool/test_pool_lifecycle.py
+ -v
+
+
+
+
+
+
+
+
+ PYTHONPATH=test/integration/plugins/ontap \
+ python3 -m nose --with-marvin \
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \
+ -a tags=iscsi_workflow \
+ test/integration/plugins/ontap/ -v
+
+
+
+
+ Where to find test results
+
+
+
/tmp/marvin_last_run.txt
stdout + stderr summary of the last
+ run
+
+
+
/tmp/MarvinLogs/<timestamp>/
results.txt — per-test
+ pass/fail · runinfo.txt — full API trace
+
+
+
+
+
+
+
+
+
+ Here is exactly what happens when you run a test suite, step by step.
+
+
+
+
+
+ | Phase |
+ Who runs it |
+ What happens |
+
+
+
+
+ | Startup |
+ nosetests / Marvin |
+ Reads ontap.cfg, connects to CloudStack API port 8096,
+ opens MySQL connection |
+
+
+ | setUpClass |
+ Test class |
+ Resolves zone → pod → cluster → hosts; creates test account + disk
+ offering; creates OntapRestClient |
+
+
+ | test_01 |
+ Test method |
+ Creates storage pool via CloudStack API; asserts CS state; asserts
+ ONTAP FlexVol state; stores pool in class attr |
+
+
+ | test_02 … test_N |
+ Test methods |
+ Each reads state from the previous step via class attrs; performs one
+ CloudStack operation; asserts both CS and ONTAP outcomes |
+
+
+ | tearDownClass |
+ OntapTestBase |
+ Best-effort cleanup: force-delete pool (enters Maintenance first),
+ delete volume, delete disk offering, delete account. Runs even if tests failed. |
+
+
+
+
+
+
+
🔗
+
+ Sequential dependency — every test in a suite depends on the one before it. If
+ test_02 fails, tests 03–08 will hit the guard assertion and fail immediately with a clear
+ message. Fix earlier failures first. The test order is enforced by alphabetic sorting of method
+ names — that's why they're all named test_01_…, test_02_… etc.
+
+
+
+
+
NFS3 vs iSCSI — what changes between protocols
+
+
+
+
+ | Aspect |
+ NFS3 |
+ iSCSI |
+
+
+
+
+ | Pool URL |
+ nfs://<ip>/ontap |
+ iscsi://<ip>/ontap |
+
+
+ | ONTAP object per pool |
+ FlexVol + NFS export policy |
+ FlexVol + one igroup per KVM host |
+
+
+ | ONTAP object per CS volume |
+ None — FlexVol is shared across volumes |
+ One LUN inside the pool's FlexVol |
+
+
+ | Host connectivity verified via |
+ get_export_policy() — checks client IP rules |
+ get_igroup() — checks IQN initiator in igroup |
+
+
+ | VM start/stop ONTAP effect |
+ Export policy retained (NFS mount persists) |
+ LUN-map removed on stop; re-created on start |
+
+
+ | CS tags |
+ ontap-nfs3 |
+ ontap-iscsi |
+
+
+
+
+
+
+
+