Optional Integrations And Secrets
mtv-api-tests keeps its optional integrations outside the main test logic. In practice, that means you only add extra local files or environment variables when you want one of
these features:
- JIRA-aware test behavior through
pytest-jira - AI-powered enrichment of failed JUnit reports
- Copy-offload credential overrides for storage and ESXi access
Note: The repository already ignores the most important local-secret files:
.providers.json,jira.cfg,.env, andjunit-report.xml.
JIRA Integration
JIRA support ships as a dependency, but it is off by default. pytest-jira is declared in pyproject.toml and registers a --jira flag whose default is False. That flag is
not part of addopts in pytest.ini, so nothing JIRA-related happens unless you ask for it.
To connect the plugin to your JIRA instance, use the shipped template in jira.cfg.example and create a local jira.cfg with the same shape:
[DEFAULT]
url = <Jira URL>
token = <User Token>
Then enable the plugin per run:
uv run pytest -m warm --jira --tc=source_provider:ovirt-4.4.9 --tc=storage_class:my-block-storageclass
At the moment no test class in tests/ carries a pytest.mark.jira marker, so --jira changes nothing until a test adds one.
Tip: Keep
jira.cfglocal, or generate it at job runtime from your CI secret store. The repo already ignoresjira.cfg, so there is no reason to commit a token.
AI Failure Analysis
AI failure analysis is opt-in. It only activates when you pass --analyze-with-ai.
From conftest.py:
analyze_with_ai_group = parser.getgroup(name="Analyze with AI")
analyze_with_ai_group.addoption("--analyze-with-ai", action="store_true", help="Analyze test failures using AI")
When that flag is present, the suite loads .env, then checks for the rootcoz server URL. It does not invent a provider or model: those defaults belong to the service and live
in .rootcoz/settings.json.
From utilities/pytest_utils.py:
def setup_ai_analysis(session: pytest.Session) -> None:
"""Configure AI analysis for test failure reporting.
Loads environment variables and validates prerequisites. Disables AI analysis
if ROOTCOZ_SERVER_URL is missing or if pytest was invoked with --collectonly
or --setupplan. Provider/model defaults live in `.rootcoz/settings.json` and
are applied by rootcoz, not by pytest.
Args:
session (pytest.Session): The pytest session object.
"""
if is_dry_run(session.config):
session.config.option.analyze_with_ai = False
return
load_dotenv()
LOGGER.info("Setting up AI-powered test failure analysis")
if not os.environ.get("ROOTCOZ_SERVER_URL"):
LOGGER.warning("ROOTCOZ_SERVER_URL is not set. Analyze with AI features will be disabled.")
session.config.option.analyze_with_ai = False
The environment variables are:
| Variable | Required | Default in code | What it controls |
|---|---|---|---|
ROOTCOZ_SERVER_URL |
Yes | none | Base URL of the rootcoz service |
ROOTCOZ_TIMEOUT |
No | 600 |
HTTP timeout in seconds for the analysis request. Invalid values fall back to 600 |
ROOTCOZ_AI_PROVIDER |
No | none | Provider name sent to rootcoz. When unset, rootcoz applies .rootcoz/settings.json |
ROOTCOZ_AI_MODEL |
No | none | Model name sent to rootcoz. When unset, rootcoz applies .rootcoz/settings.json |
The suite already writes a JUnit report by default because pytest.ini sets --junit-xml=junit-report.xml. When there are failures, the AI integration reads that XML, posts it
to the rootcoz service, and writes the enriched XML back to the same file.
From utilities/pytest_utils.py:
server_url = os.environ["ROOTCOZ_SERVER_URL"]
raw_xml = xml_path.read_text()
try:
timeout_value = int(os.environ.get("ROOTCOZ_TIMEOUT", "600"))
except ValueError:
LOGGER.warning("Invalid ROOTCOZ_TIMEOUT value, using default 600 seconds")
timeout_value = 600
# Optional overrides; when omitted, rootcoz uses .rootcoz/settings.json
payload: dict[str, str] = {"raw_xml": raw_xml}
if ai_provider := os.environ.get("ROOTCOZ_AI_PROVIDER"):
payload["ai_provider"] = ai_provider
if ai_model := os.environ.get("ROOTCOZ_AI_MODEL"):
payload["ai_model"] = ai_model
try:
response = requests.post(
f"{server_url.rstrip('/')}/analyze-failures",
json=payload,
timeout=timeout_value,
A few practical details matter here:
- If
ROOTCOZ_SERVER_URLis missing, the feature disables itself with a warning. - If
ROOTCOZ_TIMEOUTis invalid, the code falls back to600. - Dry-run modes such as
--collect-onlyand--setup-plandisable the feature. - Successful runs skip enrichment because there are no failures to analyze.
- If the JUnit XML path is unset or the file is missing, enrichment is skipped.
- If enrichment fails, the original JUnit XML is preserved.
Warning: The AI path sends the raw JUnit XML to
${ROOTCOZ_SERVER_URL}/analyze-failures. That report can contain test names, failure messages, resource names, and any other details included in the report. Only enable this against a service you trust.Tip: Because
.envis gitignored and only loaded when--analyze-with-aiis enabled, it is a good place for localROOTCOZ_*settings.
Cluster Credential Environment Variables
Cluster access is resolved from tests/tests_config/config.py first, then from the environment:
| Variable | Equivalent --tc key |
Notes |
|---|---|---|
CLUSTER_HOST |
cluster_host |
Cluster API URL |
CLUSTER_USERNAME |
cluster_username |
Cluster user |
CLUSTER_PASSWORD |
cluster_password |
Cluster password |
CLUSTER_VERIFY_SSL |
none | Overrides insecure_verify_skip. Semantics are inverted: true means insecure_verify_skip=False |
PROVIDERS_JSON_PATH |
none | Path to the providers JSON file. Overridden by --providers-json |
Passing a credential through the environment keeps it out of the process argument list, which is why the repository's OpenShift Job template wires the first three through a Secret.
Copy-Offload Credential Overrides
Copy-offload is the most secret-heavy optional path in the repository. It is also the strictest one: the fixture fails early if required copy-offload configuration is missing.
The base configuration lives under the source provider entry in .providers.json, which the suite locates through --providers-json, PROVIDERS_JSON_PATH, or the default
.providers.json path.
A relevant excerpt from .providers.json.example shows the expected shape:
"copyoffload": {
# Supported storage_vendor_product values:
# - "ontap" (NetApp ONTAP)
# - "vantara" (Hitachi Vantara)
# - "primera3par" (HPE Primera/3PAR)
# - "pureFlashArray" (Pure Storage FlashArray)
# - "powerflex" (Dell PowerFlex)
# - "powermax" (Dell PowerMax)
# - "powerstore" (Dell PowerStore)
# - "infinibox" (Infinidat InfiniBox)
# - "flashsystem" (IBM FlashSystem)
"storage_vendor_product": "ontap",
# Primary datastore for copy-offload operations (required)
# This is the vSphere datastore ID (e.g., "datastore-12345") where VMs reside
# Get via vSphere: Datacenter → Storage → Datastore → Summary → More Objects ID
"datastore_id": "datastore-12345",
# Optional: Secondary datastore for multi-datastore copy-offload tests
# Only needed when testing VMs with disks spanning multiple datastores
# When specified, tests can validate copy-offload with disks on different datastores
"secondary_datastore_id": "datastore-67890",
# Optional: Non-XCOPY datastore for mixed datastore tests
# This should be a datastore that does NOT support XCOPY/VAAI primitives
# Used for testing VMs with disks on both XCOPY and non-XCOPY datastores
"non_xcopy_datastore_id": "datastore-99999",
"default_vm_name": "rhel9-template",
# Optional: vSphere resource pool for VM placement during cloning
# Priority order for resource pool selection:
# 1. This configured value (highest priority - explicit override)
# 2. Target ESXi host's pool (when esxi_host is set - automatic compatibility)
# 3. Source VM/template's pool (preserve original location)
# 4. Cluster-wide search with datastore compatibility check (fallback)
# Example: "e2e-qe-resources" or leave commented to use automatic selection
# "resource_pool": "<VSPHERE RESOURCE POOL NAME>",
"storage_hostname": "storage.example.com",
"storage_username": "admin",
"storage_password": "your-password-here", # pragma: allowlist secret
And for SSH-based cloning, the same example file includes:
# ESXi SSH configuration (optional, for SSH-based cloning):
# Can be overridden via environment variables: COPYOFFLOAD_ESXI_HOST, COPYOFFLOAD_ESXI_USER, COPYOFFLOAD_ESXI_PASSWORD
"esxi_clone_method": "ssh", # "vib" (default) or "ssh"
"esxi_host": "your-esxi-host.example.com", # required for ssh method
"esxi_user": "root", # required for ssh method
"esxi_password": "your-esxi-password", # pragma: allowlist secret # required for ssh method
Warning: The comments in
.providers.json.exampleare not valid JSON. The real.providers.jsonfile is parsed withjson.loads(...), so remove the# pragma: allowlist secretcomments when creating your own file.
How Environment Overrides Work
The override rule is simple and explicit. For the fields that support overrides, environment variables win over values from .providers.json.
From utilities/copyoffload_migration.py:
env_var_name = f"COPYOFFLOAD_{credential_name.upper()}"
return os.getenv(env_var_name) or copyoffload_config.get(credential_name)
That helper is used for the credential-like copy-offload inputs. In the current code, the supported override names are:
.providers.json key |
Environment variable |
|---|---|
storage_hostname |
COPYOFFLOAD_STORAGE_HOSTNAME |
storage_username |
COPYOFFLOAD_STORAGE_USERNAME |
storage_password |
COPYOFFLOAD_STORAGE_PASSWORD |
ontap_svm |
COPYOFFLOAD_ONTAP_SVM |
vantara_storage_id |
COPYOFFLOAD_VANTARA_STORAGE_ID |
vantara_storage_port |
COPYOFFLOAD_VANTARA_STORAGE_PORT |
vantara_hostgroup_id_list |
COPYOFFLOAD_VANTARA_HOSTGROUP_ID_LIST |
pure_cluster_prefix |
COPYOFFLOAD_PURE_CLUSTER_PREFIX |
powerflex_system_id |
COPYOFFLOAD_POWERFLEX_SYSTEM_ID |
powermax_symmetrix_id |
COPYOFFLOAD_POWERMAX_SYMMETRIX_ID |
esxi_host |
COPYOFFLOAD_ESXI_HOST |
esxi_user |
COPYOFFLOAD_ESXI_USER |
esxi_password |
COPYOFFLOAD_ESXI_PASSWORD |
Only some vendors need extra vendor-specific secret values:
storage_vendor_product |
Required vendor fields | Secret keys added |
|---|---|---|
ontap |
ontap_svm |
ONTAP_SVM |
vantara |
vantara_storage_id, vantara_storage_port, vantara_hostgroup_id_list |
STORAGE_ID, STORAGE_PORT, HOSTGROUP_ID_LIST |
primera3par |
none | none |
pureFlashArray |
pure_cluster_prefix |
PURE_CLUSTER_PREFIX |
powerflex |
powerflex_system_id |
POWERFLEX_SYSTEM_ID |
powermax |
powermax_symmetrix_id |
POWERMAX_SYMMETRIX_ID |
powerstore |
none | none |
infinibox |
none | none |
flashsystem |
none | none |
Warning: Not every
copyoffloadkey is overrideable. In the current code paths,storage_vendor_product,datastore_id,secondary_datastore_id,non_xcopy_datastore_id,rdm_lun_uuid,esxi_clone_method,resource_pool,default_vm_name, anddedicated_migration_hostsare read directly from.providers.json.Tip: A good working pattern is to keep stable, non-secret facts in
.providers.jsonand move only the sensitive pieces, such as passwords and vendor credentials, intoCOPYOFFLOAD_*environment variables.
Extra Secret Keys
storage_secret_extra adds arbitrary string keys to the storage Secret stringData, for vendor settings that have no dedicated field. Keys must be non-empty strings and should
match the Secret names the Forklift populator expects, such as POWERMAX_PORT_GROUP_NAME or STORAGE_SKIP_SSL_VERIFICATION.
COPYOFFLOAD_STORAGE_SECRET_EXTRA overrides it. The variable holds a JSON object:
export COPYOFFLOAD_STORAGE_SECRET_EXTRA='{"POWERMAX_PORT_GROUP_NAME": "mtv_group_pg", "STORAGE_SKIP_SSL_VERIFICATION": "true"}'
Values from .providers.json are applied first, and the environment object replaces matching keys. A malformed value raises, because the code requires a JSON object with
non-empty string keys.
How Those Values Become Runtime Secrets
The copy-offload fixture turns the resolved values into a Kubernetes Secret. Every vendor gets the same three base keys:
secret_data: dict[str, str] = {
"STORAGE_HOSTNAME": storage_hostname,
"STORAGE_USERNAME": storage_username,
"STORAGE_PASSWORD": storage_password,
}
Vendor-specific and extra keys are merged in afterwards. That secret is then referenced from the StorageMap config used by the copy-offload tests.
From tests/copyoffload/test_copyoffload_migration.py:
offload_plugin_config = {
"vsphereXcopyConfig": {
"secretRef": copyoffload_storage_secret.name,
"storageVendorProduct": storage_vendor_product,
}
}
This is why environment overrides are useful: they let you keep the StorageMap logic unchanged while changing only the secret material injected into the run.
If you use SSH-based ESXi cloning, the vSphere provider also patches the provider setting to esxiCloneMethod: ssh. If you omit esxi_clone_method or leave it as vib, the
code treats vib as the default and does not patch anything.
Secret Scanning
Two repository files exist specifically to keep secret scanning useful:
.providers.json.exampleannotates placeholder credentials with# pragma: allowlist secret, which tells gitleaks those values are intentional placeholders..gitleaksignoresuppressesgeneric-api-keyfindings in generated documentation artifacts such asdocs/llms-full.txtanddocs/search-index.json, plus a few documentation pages whose command examples resemble keys.
Note: The allowlist annotations live in the template, not in
.providers.json. When you write your own provider file, drop the comments entirely.
Handling Sensitive Values In Practice
The safest way to work with this repository is to separate stable configuration from secrets:
- Keep stable settings in
.providers.json: provider type, version, datastore IDs, vendor selection, VM names, and clone method. - Keep tokens and passwords in
jira.cfg,.env, orCOPYOFFLOAD_*environment variables. - In automation, create those files at runtime from your CI or cluster secret store instead of baking them into images or checking them into Git.
- Treat
junit-report.xmlas sensitive if it may contain failure details you would not want to share broadly, especially when AI analysis is enabled.
The OpenShift Job guidance in guides/copyoffload/how-to-run-copyoffload-tests.md already demonstrates a good secret-injection pattern for automation:
read -sp "Enter cluster password: " CLUSTER_PASSWORD && echo
oc create secret generic mtv-test-config \
--from-file=providers.json=.providers.json \
--from-literal=cluster_host=https://api.your-cluster.com:6443 \
--from-literal=cluster_username=kubeadmin \
--from-literal=cluster_password="${CLUSTER_PASSWORD}" \
-n mtv-tests
unset CLUSTER_PASSWORD
That pattern is preferable to hardcoding credentials in manifests or committing local config files.
There is also one explicit redaction path worth knowing about: the SSH helper masks the OpenShift token before logging the virtctl command.
From utilities/ssh_utils.py:
cmd_str = " ".join(cmd)
if self.ocp_token:
cmd_str = cmd_str.replace(self.ocp_token, "[REDACTED]")
LOGGER.info(f"Full virtctl command: {cmd_str}")
Note: That masking is useful, but it is not a guarantee that every secret in every code path will be redacted automatically.
Warning: Copy the keys from
.providers.json.example, not the comments. The# pragma: allowlist secretannotations are there for repository scanning and will break a real JSON file.Tip: For day-to-day use, a practical split is: keep
.providers.jsonfor non-secret structure, keepjira.cfgand.envlocal, and useCOPYOFFLOAD_*or your CI secret manager for the values you would least want to store on disk.