Automation And Release
Most of the automation in mtv-api-tests is configuration-driven. The repository tells tools what to check, how to build the test image, how to cut a release, how dependencies
should be updated, and how pull requests should be reviewed. What it does not include is the CI/CD pipeline definition that decides when those things run.
Note: No GitHub Actions workflows,
Jenkinsfile, Tekton definitions, or.gitlab-ci.ymlfiles are present in this repository. Treat the repo as the source of automation policy, not as the orchestration layer.
What Is Automated Here
-
pre-commitenforces repository hygiene, Python linting and formatting, type checking, markdown linting, secret scanning, and a set of local AST checks that turnAGENTS.mdrules into blocking hooks. -
release-ithandles version bumping, commit and tag creation, pushing, changelog generation, and GitHub release creation. - Renovate manages dependency update PRs and weekly lock-file maintenance.
- The
Dockerfiledefines a repeatable, multi-stage container build for the test suite. - CodeRabbit and Qodo Merge/PR-Agent automate pull request review.
pytestandtoxexpose CI-friendly entry points and artifacts such as JUnit XML.- The
mtv-api-testsTyper CLI generates a providers file and an OpenShift Job manifest, then runs tests locally or as a Job. - rootcoz post-processes failed JUnit XML with AI analysis when
--analyze-with-aiis used. pi-docsitebuilds the documentation site indocs/from Markdown sources.
There is one more marker file worth knowing about: MTV-VERSION is a tracked plain-text file holding the MTV release this suite is currently
aligned with (currently 2.12). No code in the repository reads it, so changing it is a human decision, not a mechanical one.
Pre-commit Quality Gates
The first automation layer is .pre-commit-config.yaml. It combines generic repository safety checks with Python tooling, security scanning,
and repo-specific AST checks, so a single pre-commit run catches a lot of problems early.
---
ci:
autofix_prs: false
autoupdate_commit_msg: "chore: pre-commit autoupdate"
autoupdate_schedule: weekly
default_language_version:
python: python3.13
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: check-docstring-first
- id: check-executables-have-shebangs
- id: check-merge-conflict
- id: check-symlinks
- id: detect-private-key
- id: mixed-line-ending
- id: debug-statements
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md] # Do not process Markdown files.
- id: end-of-file-fixer
- id: check-ast
- id: check-builtin-literals
- id: check-docstring-first
- id: check-toml
- repo: https://github.com/PyCQA/flake8
rev: 7.4.1
hooks:
- id: flake8
args: [--config=.flake8]
additional_dependencies: [flake8-mutable]
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
exclude: ^docs/
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.9
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0
hooks:
- id: gitleaks
exclude: ^docs/
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.3.1
hooks:
- id: mypy
additional_dependencies:
[
"types-pyvmomi",
"types-requests",
"types-six",
"types-pytz",
"types-PyYAML",
"types-paramiko",
]
- repo: https://github.com/DavidAnson/markdownlint-cli2
rev: v0.23.3
hooks:
- id: markdownlint-cli2
args: ["--fix"]
exclude: ^docs/
- repo: local
hooks:
- id: check-no-kubernetes-runtime
name: ban kubernetes runtime imports (except kubernetes.dynamic.exceptions and from kubernetes.dynamic import exceptions)
entry: scripts/hooks/check_no_kubernetes_runtime.py
language: python
types: [python]
- id: check-no-dynamicclient-construct
name: ban DynamicClient() construction
entry: scripts/hooks/check_no_dynamicclient_construct.py
language: python
types: [python]
- id: check-no-except-exception
name: ban except Exception (except pytest_* in conftest.py)
entry: scripts/hooks/check_no_except_exception.py
language: python
types: [python]
- id: check-no-module-load-source-providers
name: ban module-level load_source_providers in tests/
entry: scripts/hooks/check_no_module_load_source_providers.py
language: python
types: [python]
files: ^tests/
- id: check-exceptions-location
name: require Exception subclasses in exceptions/exceptions.py
entry: scripts/hooks/check_exceptions_location.py
language: python
types: [python]
- id: check-test-file-location
name: ban test_*.py directly under tests/
entry: scripts/hooks/check_test_file_location.py
language: python
types: [python]
files: ^tests/
- id: check-autouse-fixtures
name: only autouse_fixtures may use autouse=True
entry: scripts/hooks/check_autouse_fixtures.py
language: python
types: [python]
- id: check-no-runtimeerror
name: ban raise RuntimeError (except pytest_* in conftest.py)
entry: scripts/hooks/check_no_runtimeerror.py
language: python
types: [python]
AGENTS.md Enforcement Hooks
The repo: local block is the most distinctive part of this file. Each of those eight hooks is a small AST-based script under
scripts/hooks/ that turns one "MUST" rule from AGENTS.md into a blocking check.
| Hook id | Enforces |
|---|---|
check-no-kubernetes-runtime |
No kubernetes.* runtime imports, except kubernetes.dynamic.exceptions and from kubernetes.dynamic import exceptions |
check-no-dynamicclient-construct |
No DynamicClient() construction; use get_client() instead |
check-no-except-exception |
No except Exception, except in pytest_* functions in conftest.py |
check-no-module-load-source-providers |
No module-level load_source_providers() calls under tests/ |
check-exceptions-location |
Exception subclasses belong in exceptions/exceptions.py |
check-test-file-location |
No test_*.py directly under tests/ |
check-autouse-fixtures |
Only the autouse_fixtures fixture in conftest.py may declare autouse=True |
check-no-runtimeerror |
No raise RuntimeError, except in pytest_* functions in conftest.py |
Two of them are scoped further with files: ^tests/, so they only run on files under tests/.
Run the whole suite, one hook, or one script directly:
pre-commit run --all-files
pre-commit run check-no-except-exception --all-files
.venv/bin/python scripts/hooks/check_no_except_exception.py
Baseline Ratchet
Four of these hooks still fail on legacy code. Rather than blocking every contribution, those findings are grandfathered in
scripts/hooks/baselines/<hook_id>.txt. The current baselines are:
check_exceptions_location.txtcheck_no_except_exception.txtcheck_no_kubernetes_runtime.txtcheck_no_runtimeerror.txt
The filename is the snake_case script stem, not the kebab-case pre-commit id: check_no_except_exception.py maps to
check_no_except_exception.txt.
Each baseline line is relative/posix/path:<16-char-sha256-hex>, where the fingerprint is sha256(span)[:16]. For a single-line finding the
span is that line; for a multi-line AST node it is the joined source lines. Matching is on path plus content fingerprint with occurrence
counts, not on line number, so refactoring that moves a violation does not silently un-baseline it.
Tip: Shrink a baseline row when you fix the underlying violation. Do not expand baselines casually; a new path/fingerprint pair that is not listed must fail the hook.
Warning: Baselines are a ratchet, not an amnesty. Adding a line to a baseline is a deliberate reviewable act, not a way to get a red hook to go green.
Generic Hook Behavior
That hook list tells you what the repo cares about:
- repository safety: large files, merge conflicts, broken symlinks, stray debug statements, invalid TOML, and missing EOF newlines
- Python quality:
flake8,ruff,ruff-format, andmypy - secret prevention:
detect-private-key,detect-secrets, andgitleaks - docs hygiene:
markdownlint-cli2 --fix - project rules: the eight
AGENTS.mdAST hooks listed above
The ci: block at the top pins the behavior of pre-commit.ci itself: autoupdate_schedule: weekly and autofix_prs: false. That means hook
revisions are bumped on a schedule, and no autofix PR is opened for you.
Three hooks carry exclude: ^docs/: detect-secrets, gitleaks, and markdownlint-cli2. The documentation tree is generated-site territory,
so it is excluded from linting and secret scanning by design.
Note: Because
markdownlint-cli2skipsdocs/, the Markdown underdocs/is never auto-fixed and never fails CI..markdownlint.yamlstill documents the house rules (MD013at 180 characters,MD033allowing onlydetails,summary, andstrong), and they are what a reviewer expects those pages to follow.
The repo-specific behavior for ruff and mypy lives in pyproject.toml, so the hooks follow local rules rather than generic defaults.
[tool.ruff]
preview = true
line-length = 120
fix = true
output-format = "grouped"
[tool.ruff.format]
exclude = [".git", ".venv", ".mypy_cache", ".tox", "__pycache__"]
[tool.ruff.lint]
select = ["PLC0415"]
[tool.mypy]
disallow_any_generics = false
disallow_incomplete_defs = true
no_implicit_optional = true
show_error_codes = true
warn_unused_ignores = true
[[tool.mypy.overrides]]
module = "paramiko"
ignore_missing_imports = true
A few practical takeaways matter for contributors and CI maintainers:
ruffis allowed to auto-fix code.mypyis configured to reject incomplete function definitions and implicit optional types.flake8is intentionally narrow here:.flake8selectsM511and loadsflake8-mutable.- Markdown formatting can be auto-corrected during a hook run instead of being fixed manually.
Because the repo runs both detect-secrets and gitleaks, example files can contain # pragma: allowlist secret comments to keep fake credentials from being flagged. That is
why files such as .providers.json.example contain secret-looking placeholders with allowlist annotations.
.gitleaksignore holds the remaining accepted false positives, one path:rule:line per entry, so the scanners stay quiet on known example
credentials without disabling the scanners.
Warning: Pre-commit hook environments are pinned to
python3.13, while the project itself allows>=3.12,<3.14and the container image setsUV_PYTHON=python3.12. Make sure your local machine or CI runner can provide the hook interpreter.Tip: If you copy snippets out of example JSON-like files, remove
# pragma: allowlist secretcomments before using them as real JSON. Those comments exist for secret scanners, not for JSON parsers.
Release Automation With release-it
Release configuration lives in .release-it.json. This repository uses release-it for Git and GitHub release operations, not for publishing an npm package.
{
"npm": {
"publish": false
},
"git": {
"requireCleanWorkingDir": true,
"requireBranch": false,
"requireUpstream": true,
"requireCommits": false,
"addUntrackedFiles": false,
"commit": true,
"commitMessage": "Release ${version}",
"commitArgs": [],
"tag": true,
"tagName": null,
"tagMatch": null,
"tagAnnotation": "Release ${version}",
"tagArgs": [],
"push": true,
"pushArgs": ["--follow-tags"],
"pushRepo": "",
"changelog": "git log --no-merges --pretty=format:\"* %s (%h) by %an on %as\" ${from}...${to}"
},
"github": {
"release": true,
"releaseName": "Release ${version}",
"releaseNotes": null,
"autoGenerate": false,
"preRelease": false,
"draft": false,
"tokenRef": "GITHUB_TOKEN",
"assets": null,
"host": null,
"timeout": 0,
"proxy": null,
"skipChecks": false,
"web": false
},
"plugins": {
"@release-it/bumper": {
"in": "pyproject.toml",
"out": { "file": "pyproject.toml", "path": "project.version" }
}
},
"hooks": {
"after:bump": "uv sync"
}
}
Here is what that means in practice:
- the release job must start from a clean working tree
- the branch must have an upstream remote
release-itcreates a release commit and tag, then pushes both with--follow-tags- GitHub release creation is enabled, using
GITHUB_TOKEN - GitHub's auto-generated release notes are disabled
- changelog text is built from
git log --no-merges ... ${from}...${to}
The version source is the Python project metadata in pyproject.toml:
[project]
requires-python = ">=3.12, <3.14"
name = "mtv-api-tests"
version = "5.0.0"
description = "MTV API Tests"
That is important because the release flow is Python-package-centric even though the release tool comes from the Node ecosystem. The @release-it/bumper plugin updates
project.version, and the after:bump hook runs uv sync so the environment and uv.lock stay aligned with the new release.
pyproject.toml also declares the console entry point that the CLI is installed from, and the wheel packages that get shipped:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["cli", "utilities", "libs", "exceptions"]
[project.scripts]
mtv-api-tests = "cli.mtv_api_tests:app"
Two details are easy to miss:
requireBranchisfalse, so the repo itself does not enforce a release branch policyrequireCommitsisfalse, so the repo itself does not require new commits before a release
If you want stricter rules, enforce them in your external release pipeline.
Warning: The repository contains
.release-it.json, but it does not containpackage.json,package-lock.json,yarn.lock, orpnpm-lock.yaml. Your release runner must providerelease-it,@release-it/bumper, andGITHUB_TOKENfrom outside the repo.
Dependency Updates With Renovate
Renovate behavior is defined in renovate.json:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"baseBranches": ["main", "/^v\\d+\\.\\d+$/"],
"extends": [
":dependencyDashboard",
":maintainLockFilesWeekly",
":prHourlyLimitNone",
":semanticCommitTypeAll(ci )"
],
"prConcurrentLimit": 0,
"recreateWhen": "never",
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [
{
"matchPackagePatterns": ["*"],
"groupName": "python-deps"
}
]
}
This gives the repo a clear dependency-update strategy:
- a dependency dashboard is enabled
- lock-file maintenance runs weekly
- Renovate is not throttled by an hourly PR cap
- concurrent PRs are unlimited
- closed PRs are not automatically recreated
- matched dependencies are grouped under
python-deps
baseBranches is the part that ties Renovate to the release flow: update PRs target main and any v<major>.<minor> maintenance branch. Those
maintenance branches are not created by release-it — .release-it.json only commits, tags, and pushes the branch you run it on, so a maintainer
must create and push v<major>.<minor> by hand when a release line needs one.
Because this project uses uv and checks in uv.lock, Renovate is not just bumping top-level requirements. It is also part of keeping the lock file fresh.
Tip: Grouping everything under
python-depsreduces PR noise, but it also means update PRs can be broader than a one-package-at-a-time workflow.
Container Image Build Inputs
The container image is built from Dockerfile. It is a two-stage build: a builder stage that compiles dependencies, and a slim runtime
stage that carries only the runtime libraries.
# syntax=docker/dockerfile:1
# ------------------------------------------------------------------------------
# Stage 1: builder - install build deps, compile extensions, sync dependencies
# ------------------------------------------------------------------------------
FROM registry.access.redhat.com/ubi10/ubi-minimal:10.1@sha256:c858c2eb5bd336d8c400f6ee976a9d731beccf3351fa7a6f485dced24ae4af17 AS builder
ARG APP_DIR=/app
ARG OPENSHIFT_PYTHON_WRAPPER_COMMIT=''
ARG OPENSHIFT_PYTHON_UTILITIES_COMMIT=''
ENV UV_PYTHON=python3.12
ENV UV_COMPILE_BYTECODE=1
ENV UV_NO_SYNC=1
ENV UV_NO_CACHE=1
RUN microdnf -y install \
libxml2-devel \
libcurl-devel \
openssl \
openssl-devel \
gcc \
clang \
git \
python3-devel \
&& microdnf clean all
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR ${APP_DIR}
COPY cli cli
COPY docs docs
COPY utilities utilities
COPY tests tests
COPY libs libs
COPY exceptions exceptions
COPY README.md pyproject.toml uv.lock conftest.py pytest.ini ./
RUN mkdir -p ${APP_DIR}/output
RUN uv sync --locked \
&& if [ -n "${OPENSHIFT_PYTHON_WRAPPER_COMMIT}" ]; then uv pip install "git+https://github.com/RedHatQE/openshift-python-wrapper.git@${OPENSHIFT_PYTHON_WRAPPER_COMMIT}"; fi \
&& if [ -n "${OPENSHIFT_PYTHON_UTILITIES_COMMIT}" ]; then uv pip install "git+https://github.com/RedHatQE/openshift-python-utilities.git@${OPENSHIFT_PYTHON_UTILITIES_COMMIT}"; fi \
&& find ${APP_DIR}/ -type d -name "__pycache__" -print0 | xargs -0 -r rm -rfv \
&& rm -rf ${APP_DIR}/.cache
# ------------------------------------------------------------------------------
# Stage 2: runtime - clean image with only runtime dependencies
# ------------------------------------------------------------------------------
FROM registry.access.redhat.com/ubi10/ubi-minimal:10.1@sha256:c858c2eb5bd336d8c400f6ee976a9d731beccf3351fa7a6f485dced24ae4af17
ARG APP_DIR=/app
# Runtime shared libraries required by compiled Python extensions:
# libxml2 - lxml
# libcurl - pycurl (libcurl-minimal from the base image provides libcurl.so)
# openssl - cryptography / TLS
RUN microdnf -y install \
python3 \
libxml2 \
openssl \
git \
&& microdnf clean all
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
ENV JUNITFILE=${APP_DIR}/output/
ENV UV_PYTHON=python3.12
ENV UV_COMPILE_BYTECODE=1
ENV UV_NO_SYNC=1
ENV UV_NO_CACHE=1
# Prevents Python from writing .pyc files to disk
ENV PYTHONDONTWRITEBYTECODE=1
# Ensures Python output is logged straight to the terminal (useful for OpenShift logs)
ENV PYTHONUNBUFFERED=1
WORKDIR ${APP_DIR}
COPY --from=builder ${APP_DIR} ${APP_DIR}
RUN chgrp -R 0 ${APP_DIR} && chmod -R g=u ${APP_DIR}
USER 1001
CMD ["uv", "run", "pytest", "--collect-only"]
This build has a few important characteristics:
- the base image is
registry.access.redhat.com/ubi10/ubi-minimal:10.1, pinned to a digest, not a floating Fedora tag - the runtime stage installs only
python3,libxml2,openssl, andgit; build tooling never reaches the shipped layer - Python dependency installation is driven by
uv sync --locked, souv.lockmatters - the copied subset is explicit:
cli,docs,utilities,tests,libs,exceptions, plusREADME.md,pyproject.toml,uv.lock,conftest.py, andpytest.ini - the build can optionally swap in development commits of
openshift-python-wrapperandopenshift-python-utilities - the image runs as non-root
USER 1001, withchgrp -R 0 && chmod -R g=ufor OpenShift's arbitrary-UID model PYTHONUNBUFFERED=1means pytest output reachesoc logswithout buffering delays- the default container command only performs test collection
That last point is intentional: a plain container run validates the test suite can be discovered, but it does not launch a real migration test job.
The repository's .dockerignore is minimal, so the effective build-input boundary is the COPY list in Dockerfile, not the ignore file. In other words, the image is controlled
more by what is explicitly copied than by what is excluded.
Tip:
OPENSHIFT_PYTHON_WRAPPER_COMMITandOPENSHIFT_PYTHON_UTILITIES_COMMITare practical escape hatches when you need to validate this test suite against unreleased helper-library commits.Warning: The default container command is
uv run pytest --collect-only. If your CI job should run real tests, it must overrideCMDor pass an explicit command.
AI Review Bots
This repository configures two PR review bots: CodeRabbit and Qodo Merge/PR-Agent. They both automate review, but they are wired differently.
CodeRabbit
CodeRabbit is configured for assertive automatic review on non-draft PRs targeting main, and it can request changes.
reviews:
# Review profile: assertive for strict enforcement
profile: assertive
# Request changes for critical violations
request_changes_workflow: true
# Review display settings
high_level_summary: true
poem: false
review_status: true
collapse_walkthrough: false
# Abort review if PR is closed
abort_on_close: true
# Auto-review configuration
auto_review:
auto_pause_after_reviewed_commits: 0
auto_incremental_review: true
ignore_title_keywords:
- "WIP"
enabled: true
drafts: false
base_branches:
- main
That configuration tells users a lot about expected review behavior:
- reviews are direct rather than gentle
- draft PRs are skipped
- PRs with
WIPin the title are ignored for auto-review - the bot can ask for changes
- review coverage is broader than just Python style, including security, YAML, shell, and Dockerfile checks
Above the reviews block the same file sets language: en-US, an explicit severity ladder in tone_instructions (CRITICAL, HIGH, MEDIUM,
LOW), and early_access: true. Below it, a knowledge_base block opts out of nothing, enforces code_guidelines sourced from CLAUDE.md, and
scopes learnings, issues, and pull_requests learning to auto. auto_pause_after_reviewed_commits: 0 means CodeRabbit never stops
re-reviewing an open PR, and chat.auto_reply: true lets it answer PR comments.
CodeRabbit therefore points its knowledge-base and guideline logic at CLAUDE.md, so it is expected to review against repository-specific rules
instead of only generic style advice.
Qodo Merge / PR-Agent
Qodo Merge is configured in .pr_agent.toml:
[config]
response_language = "en-US"
add_repo_metadata = true
add_repo_metadata_file_list = ["CLAUDE.md"]
ignore_pr_title = [
"^\\[WIP\\]",
"^WIP:",
"^Draft:",
"chore: pre-commit autoupdate",
]
ignore_pr_labels = ["wip", "work-in-progress"]
[github_app]
handle_push_trigger = true
pr_commands = ["/agentic_review", "/agentic_describe"]
push_commands = ["/agentic_review"]
handle_pr_actions = ["opened", "reopened", "ready_for_review"]
feedback_on_draft_pr = false
[review_agent]
comments_location_policy = "both"
inline_comments_severity_threshold = 1
[pr_reviewer]
extra_instructions = """
Review Style:
- Be direct and specific. Explain WHY rules exist.
Focus Areas:
- Python code quality (type annotations, exception handling)
- Security vulnerabilities (injection, credential exposure)
- YAML syntax validation
- Follow CLAUDE.md guidelines for project-specific standards
"""
require_security_review = true
require_tests_review = true
require_score_review = false
[pr_code_suggestions]
extra_instructions = "Focus on Python best practices, security, and maintainability. Follow CLAUDE.md standards."
focus_only_on_problems = false
suggestions_score_threshold = 5
[ignore]
# Generated by pi-docsite (see AGENTS.md "Documentation Site"). The Markdown
# sources (docs/*.md) and docs/nav.json stay in review scope on purpose.
glob = [
"docs/*.html",
"docs/assets/*",
"docs/search-index.json",
"docs/llms.txt",
"docs/llms-full.txt",
]
regex = []
Compared to CodeRabbit, this config emphasizes command-driven interaction:
- GitHub App events trigger reviews on open, reopen, ready-for-review, and push
- reviewers ask for
/agentic_reviewor/agentic_describe; pushes only answer/agentic_review [review_agent]posts inline comments at severity 1 and up, in both the diff and a summary- security review and tests review are required focus areas
- WIP and draft states are intentionally ignored, and so is the automated
chore: pre-commit autoupdatecommit - generated documentation artifacts are excluded from review by glob, while
docs/*.mdanddocs/nav.jsonstay in scope
Note: Both bot configs pull repository-specific guidance from
CLAUDE.md. They are meant to reinforce the repo's own standards, not replace human ownership or branch policy.
AI Failure Analysis With rootcoz
There is a separate, opt-in AI feature inside the pytest plugin, and it is unrelated to the PR review bots above. conftest.py adds
--analyze-with-ai, and utilities/pytest_utils.py POSTs the JUnit XML to a rootcoz server's /analyze-failures endpoint and writes the
enriched XML back over the same file. That is test-report post-processing, not a pull-request review bot.
Warning:
/analyze-failuresis the contract this repository's client expects, and it is not the rootcoz CLI's Jenkinsanalyzejob API. The request isPOST {ROOTCOZ_SERVER_URL}/analyze-failureswith a JSON body of{"raw_xml": "<junit xml>"}(plus optionalai_providerandai_model), and the response must be JSON containingenriched_xml. The call is synchronous — it blocks for up toROOTCOZ_TIMEOUTseconds and returns the enriched report in the same response, with no job id to poll. A server that only exposesPOST /analyze(which returns a job id for the Jenkins-oriented flow) cannot serve this client; enrichment then fails, the exception is logged, and the original JUnit file is left untouched.
def enrich_junit_xml(session: pytest.Session) -> None:
"""Read JUnit XML, send to server for analysis, write enriched XML back.
...
"""
...
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
The client half of the contract is four environment variables: ROOTCOZ_SERVER_URL (required), ROOTCOZ_TIMEOUT (default 600), and the
optional ROOTCOZ_AI_PROVIDER / ROOTCOZ_AI_MODEL overrides. This repository also carries .rootcoz/settings.json, which the rootcoz server reads after
cloning this repository: it pins the AI provider and model, an AI call timeout, peer AI configs, and the additional_repos rootcoz may consult. The rootcoz CLI
does not read that file — it uses its own ~/.config/rootcoz/config.toml (rootcoz config show), so CLI runs need the provider and model set there or on the
command line.
Note: The payload carries only
raw_xmland the optional overrides — it sends no repository URL..rootcoz/settings.jsonin this repo is therefore applied only when the server already knows about this repository (server-side configuration or a configuredadditional_reposentry). Without that, rootcoz has no repository path from which to load the settings and falls back to its own defaults, which is also whyROOTCOZ_AI_PROVIDERandROOTCOZ_AI_MODELexist: set them explicitly when you need a specific provider or model.
Enrichment is skipped when the session exit code is 0, and it is force-disabled under --collect-only and --setup-plan. The original JUnit
file is preserved if the call fails.
Note: If you operate rootcoz yourself,
ROOTCOZ_SERVER_URLis the only required setting, plus a server that exposes/analyze-failures. Register this repository with the server if you want.rootcoz/settings.jsonapplied, or setROOTCOZ_AI_PROVIDER/ROOTCOZ_AI_MODELexplicitly. If you do not, the feature is simply off and nothing changes about your test run.Warning:
enrich_junit_xml()sets noauth=value and noAuthorizationheader, because the request is a barerequests.post(json=payload, timeout=...)(utilities/pytest_utils.py:535-539). Requests may still send HTTP Basic Auth from a matching.netrcentry forROOTCOZ_SERVER_URL. When the rootcoz deployment requires authentication and neither a matching.netrcentry nor an authenticating proxy in front ofROOTCOZ_SERVER_URLsupplies it, the call fails and enrichment logs the exception while preserving the original JUnit file.
Documentation Site Rebuild
The docs/ tree is a pi-docsite project. Only the Markdown sources and docs/nav.json are hand-edited;
everything else in docs/ is generated and committed.
| Path | Role |
|---|---|
docs/*.md |
Sources — the only files humans edit |
docs/nav.json |
Sidebar layout: groups and page order |
docs/*.html, docs/assets/, docs/search-index.json, docs/llms.txt, docs/llms-full.txt |
Generated — never hand-edit |
Rebuild after any change to a docs/*.md file or to docs/nav.json:
uvx pi-docsite --docs-dir docs \
--tagline "Pytest-based integration suite for validating Migration Toolkit for Virtualization migrations into OpenShift Virtualization."
The rules that matter for automation:
- edit the Markdown, never the generated HTML; fix the source and rebuild
- generated files are committed, so a source change and its rebuilt output belong in the same commit
- a new page needs a
docs/<slug>.mdwith exactly one H1 and a matchingnav.jsonentry; a slug with no.mdbehind it is a build error - output is byte-for-byte reproducible, so churn in untouched files means the generator changed, not a stale build
- search only works when
docs/is served over HTTP, becausesearch-index.jsonis fetched withfetch()
This is also why .pr_agent.toml carries an [ignore] block for the generated artifacts and why three pre-commit hooks exclude ^docs/. The
generated files are real deliverables, but they are never a review or lint target.
What External CI/CD Must Do
Because orchestration lives outside this repository, your CI/CD platform needs to call the repo's entry points explicitly. The repo already provides a good integration contract for that.
pytest.ini makes test output CI-friendly by default, especially through JUnit XML generation:
[pytest]
testpaths = tests
addopts =
-s
-o log_cli=true
-p no:logging
--tc-file=tests/tests_config/config.py
--tc-format=python
--junit-xml=junit-report.xml
--show-progress
--strict-markers
--dist=loadscope
markers =
tier0: Core functionality tests (smoke tests)
tier1: Extended functionality tests
remote: Remote cluster migration tests
warm: Warm migration tests
copyoffload: Copy-offload (XCOPY) tests
copyoffload_sanity: Copy-offload sanity tests - core scenarios for quick validation
copyoffload_snapshots: Copy-offload snapshot tests (vSphere only)
shared_disk: Shared disk migration tests (vSphere only)
incremental: marks tests as incremental (xfail on previous failure)
min_mtv_version: mark test to require minimum MTV version (e.g., @pytest.mark.min_mtv_version("2.6.0"))
ova: OVA provider-specific tests
vsphere: vSphere provider-specific tests
esxi: ESXi provider-specific tests
rhv: RHV provider-specific tests
hyperv: Hyper-V provider-specific tests
openstack: OpenStack provider-specific tests
openshift: OpenShift provider-specific tests
deep_inspection: Deep Inspection / Conversion CR tests (vSphere only)
ca_crt: CA certificate field (ca.crt) provider secret tests
aap: AAP (Ansible Automation Platform) hook integration tests (vSphere only)
upgrade: MTV operator upgrade tests
junit_logging = all
That default configuration is designed for automation consumers:
- JUnit XML is always generated as
junit-report.xml, withjunit_logging = allso the logs ride along - marker handling is strict, so an unregistered marker fails the run rather than silently running everything
- xdist is configured with
--dist=loadscope, so parallel workers group by class testpaths = tests, so a barepytestnever picks up the docs or tooling directories
--jira is deliberately absent from addopts. pytest-jira is installed and jira.cfg.example exists, but Jira reporting is opt-in per run.
The repo also ships a small tox surface for automation:
skipsdist = true
env_list = ["pytest-check", "unused-code"]
[env.pytest-check]
commands = [
[
"uv",
"run",
"pytest",
"--setup-plan",
],
[
"uv",
"run",
"pytest",
"--collect-only",
],
]
description = "Run pytest collect-only and setup-plan"
deps = ["uv"]
[env.unused-code]
description = "Find unused code"
deps = ["python-utility-scripts"]
commands = [["pyutils-unusedcode", "--exclude-function-prefixes", "pytest_"]]
That means an external pipeline can use the repository in layers:
- run
pre-commit run --all-files - run
tox -e pytest-checkoruv run pytest --collect-onlyas a fast wiring check - build the image from
Dockerfile - generate the Job manifest with
mtv-api-tests generateif you want cluster-side isolation, or pass--tc=flags directly - run real, cluster-backed test jobs with the required provider credentials and OpenShift access
- collect
junit-report.xmlas an artifact - optionally enable
--analyze-with-aiif you operate a rootcoz server - run
release-itin a dedicated release job when you are ready to cut a version
tox.toml also carries an unused-code environment that runs pyutils-unusedcode --exclude-function-prefixes pytest_, which is useful as a
periodic dead-code sweep even though nothing in the repo depends on it.
The repo adds pytest switches in conftest.py too: --analyze-with-ai, --providers-json, --skip-data-collector, --data-collector-path,
--skip-teardown, and --openshift-python-wrapper-log-debug. Those are useful knobs for external jobs, but they are not pipeline definitions
by themselves.
The mtv-api-tests CLI
pyproject.toml installs one console script, mtv-api-tests, backed by the Typer app in cli/mtv_api_tests/. It has exactly two subcommands.
| Command | What it does |
|---|---|
mtv-api-tests generate |
Interactive wizard that discovers datastores, VMs, ESXi hosts, and storage classes, then writes .providers.json and a Job manifest |
mtv-api-tests run |
Runs the tests. --mode local shells out to uv run pytest; --mode job runs oc apply -f against the manifest |
Both accept --category (a pytest marker), and run accepts --source-provider, --destination-provider, --storage-class,
--test-filter / -k, and --job-yaml. generate additionally accepts --image for the Job container image.
Useful entry points for automation and debugging:
# Interactive configuration, writes .providers.json and the Job manifest
mtv-api-tests generate
# Run locally with explicit provider keys
mtv-api-tests run --mode local --source-provider <key> --destination-provider <key> --storage-class <sc>
# Deploy the previously generated manifest and follow its logs
mtv-api-tests run --mode job
In local mode the CLI passes the cluster host and SSL setting as --tc= arguments but the username and password as CLUSTER_USERNAME and
CLUSTER_PASSWORD environment variables, specifically so credentials do not appear in process listings. If no OpenShift provider is available it
falls back to the token from oc whoami -t.
Both commands read the providers file from PROVIDERS_JSON_PATH, falling back to .providers.json in the working directory — the same
resolution order pytest uses for --providers-json.
Tip: The cleanest mental model is: this repository defines automation rules, while your CI/CD platform supplies the runner, credentials, scheduling, and environment needed to execute them.