From 2b913edd4ff6dc69056ebeb89394a6fb4c2875c1 Mon Sep 17 00:00:00 2001 From: Mateusz Chrominski Date: Fri, 11 Jul 2025 14:20:53 +0200 Subject: [PATCH] feat: Initial commit. Signed-off-by: Mateusz Chrominski --- .github/workflows/build_upload_whl.yml | 205 +++++ .github/workflows/codeql.yml | 98 +++ .github/workflows/manual_release.yml | 33 + .github/workflows/pull_requests.yml | 29 + .gitignore | 236 +++++ .pre-commit-config.yaml | 6 + AUTHORS.md | 7 + CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 4 +- LICENSE.md | 8 + README.md | 129 +++ configure.py | 12 + .../getting_vswitch_interfaces_examples.py | 22 + examples/simple_example.py | 168 ++++ mfd_hyperv/__init__.py | 4 + mfd_hyperv/attributes/__init__.py | 3 + .../vm_network_interface_attributes.py | 62 ++ mfd_hyperv/attributes/vm_params.py | 41 + .../attributes/vm_processor_attributes.py | 14 + mfd_hyperv/attributes/vswitchattributes.py | 26 + mfd_hyperv/base.py | 31 + mfd_hyperv/exceptions.py | 12 + mfd_hyperv/helpers.py | 13 + mfd_hyperv/hw_qos.py | 282 ++++++ mfd_hyperv/hypervisor.py | 741 ++++++++++++++++ mfd_hyperv/instances/__init__.py | 3 + mfd_hyperv/instances/vm.py | 227 +++++ mfd_hyperv/instances/vm_network_interface.py | 206 +++++ mfd_hyperv/instances/vswitch.py | 144 +++ mfd_hyperv/vm_network_interface_manager.py | 420 +++++++++ mfd_hyperv/vswitch_manager.py | 258 ++++++ pyproject.toml | 54 ++ requirements-dev.txt | 12 + requirements-docs.txt | 3 + requirements-test.txt | 6 + requirements.txt | 5 + sphinx-doc/Makefile | 20 + sphinx-doc/README.md | 13 + sphinx-doc/conf.py | 166 ++++ sphinx-doc/generate_docs.py | 18 + sphinx-doc/genindex.rst | 4 + sphinx-doc/index.rst | 21 + sphinx-doc/py-modindex.rst | 4 + tests/__init__.py | 2 + tests/system/.gitkeep | 0 tests/unit/__init__.py | 2 + tests/unit/test_mfd_hyperv/__init__.py | 2 + tests/unit/test_mfd_hyperv/const.py | 322 +++++++ tests/unit/test_mfd_hyperv/test_hw_qos.py | 342 ++++++++ tests/unit/test_mfd_hyperv/test_hypervisor.py | 822 ++++++++++++++++++ tests/unit/test_mfd_hyperv/test_vm.py | 156 ++++ .../test_vm_network_interface.py | 91 ++ .../test_vm_network_interface_manager.py | 263 ++++++ tests/unit/test_mfd_hyperv/test_vswitch.py | 44 + .../test_mfd_hyperv/test_vswitch_manager.py | 362 ++++++++ 55 files changed, 6177 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/build_upload_whl.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/manual_release.yml create mode 100644 .github/workflows/pull_requests.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 AUTHORS.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 configure.py create mode 100644 examples/getting_vswitch_interfaces_examples.py create mode 100644 examples/simple_example.py create mode 100644 mfd_hyperv/__init__.py create mode 100644 mfd_hyperv/attributes/__init__.py create mode 100644 mfd_hyperv/attributes/vm_network_interface_attributes.py create mode 100644 mfd_hyperv/attributes/vm_params.py create mode 100644 mfd_hyperv/attributes/vm_processor_attributes.py create mode 100644 mfd_hyperv/attributes/vswitchattributes.py create mode 100644 mfd_hyperv/base.py create mode 100644 mfd_hyperv/exceptions.py create mode 100644 mfd_hyperv/helpers.py create mode 100644 mfd_hyperv/hw_qos.py create mode 100644 mfd_hyperv/hypervisor.py create mode 100644 mfd_hyperv/instances/__init__.py create mode 100644 mfd_hyperv/instances/vm.py create mode 100644 mfd_hyperv/instances/vm_network_interface.py create mode 100644 mfd_hyperv/instances/vswitch.py create mode 100644 mfd_hyperv/vm_network_interface_manager.py create mode 100644 mfd_hyperv/vswitch_manager.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements-docs.txt create mode 100644 requirements-test.txt create mode 100644 requirements.txt create mode 100644 sphinx-doc/Makefile create mode 100644 sphinx-doc/README.md create mode 100644 sphinx-doc/conf.py create mode 100644 sphinx-doc/generate_docs.py create mode 100644 sphinx-doc/genindex.rst create mode 100644 sphinx-doc/index.rst create mode 100644 sphinx-doc/py-modindex.rst create mode 100644 tests/__init__.py create mode 100644 tests/system/.gitkeep create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_mfd_hyperv/__init__.py create mode 100644 tests/unit/test_mfd_hyperv/const.py create mode 100644 tests/unit/test_mfd_hyperv/test_hw_qos.py create mode 100644 tests/unit/test_mfd_hyperv/test_hypervisor.py create mode 100644 tests/unit/test_mfd_hyperv/test_vm.py create mode 100644 tests/unit/test_mfd_hyperv/test_vm_network_interface.py create mode 100644 tests/unit/test_mfd_hyperv/test_vm_network_interface_manager.py create mode 100644 tests/unit/test_mfd_hyperv/test_vswitch.py create mode 100644 tests/unit/test_mfd_hyperv/test_vswitch_manager.py diff --git a/.github/workflows/build_upload_whl.yml b/.github/workflows/build_upload_whl.yml new file mode 100644 index 0000000..38fec87 --- /dev/null +++ b/.github/workflows/build_upload_whl.yml @@ -0,0 +1,205 @@ +name: CI Build Reusable Workflow +on: + workflow_call: + secrets: + GH_TOKEN: + description: 'GitHub token for authentication' + required: true + PYPI_TOKEN: + description: 'PyPI API token to publish package' + required: false + inputs: + UPLOAD_PACKAGE: + description: 'Should the package be uploaded to PyPI?' + required: false + default: false + type: boolean + REPOSITORY_NAME: + description: 'Repository name' + required: false + type: string + BRANCH_NAME: + description: 'Branch name to checkout' + required: true + type: string + PYTHON_VERSION: + description: 'Python version to use' + required: false + default: '3.10.11' + type: string + PUSH_TAG: + description: 'Push tag after version bump' + required: false + default: false + type: boolean + RELEASE_BUILD: + description: 'Is release build?' + required: false + default: false + type: boolean + GIT_USER: + description: 'Git user name for commit and tag' + required: true + type: string + GIT_EMAIL: + description: 'Git user email for commit and tag' + required: true + type: string + PROJECT_NAME: + description: 'Project name for tests' + required: true + type: string + SOURCE_PATH: + description: 'Path to the source code directory' + required: false + default: 'src' + type: string + RUNS_ON: + description: 'Runner type for the job' + required: false + default: 'ubuntu-latest' + type: string + +jobs: + build_whl: + permissions: + contents: write + id-token: write + environment: + name: "pypi" + url: https://pypi.org/p/${{ inputs.PROJECT_NAME }} + runs-on: ${{ inputs.RUNS_ON }} + steps: + - uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + path: ${{ inputs.SOURCE_PATH }} + ref: ${{ inputs.BRANCH_NAME }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.PYTHON_VERSION }} + cache: 'pip' + + - name: Version bumping + id: VERSION_BUMP + if: inputs.RELEASE_BUILD == true + env: + GIT_AUTHOR_NAME: ${{ inputs.GIT_USER }} + GIT_AUTHOR_EMAIL: ${{ inputs.GIT_EMAIL }} + GIT_COMMITTER_NAME: ${{ inputs.GIT_USER }} + GIT_COMMITTER_EMAIL: ${{ inputs.GIT_EMAIL }} + shell: bash + run: | + python -m pip install --upgrade pip + python -m venv bump_version + source bump_version/bin/activate + pip install python-semantic-release~=10.2 + pip install -r ${{ inputs.SOURCE_PATH }}/requirements-dev.txt + mfd-create-config-files --project-dir ./${{ inputs.SOURCE_PATH }} + cd ${{ inputs.SOURCE_PATH }} + version_after_bump=$(semantic-release version --print | tail -n 1 | tr -d '\n') + version_from_tag=$(git describe --tags --abbrev=0 | tr -d '\n' | sed 's/^v//') + echo "Version after semantic-release bump is: ${version_after_bump}" + echo "Version from tag: ${version_from_tag}" + # Only check version equality if RELEASE_BUILD is true + if [ "${{ inputs.RELEASE_BUILD }}" == "true" ]; then + if [ "$version_after_bump" == "$version_from_tag" ]; then + echo "Version would not change: version_after_bump=${version_after_bump}, version_from_tag=${version_from_tag}" + exit 1 + fi + fi + semantic-release version --no-push --no-vcs-release + cat pyproject.toml + echo "version_after_bump=v${version_after_bump}" >> $GITHUB_OUTPUT + - name: Create virtual environment for whl creation + shell: bash + run: | + python -m venv whl_creation + source whl_creation/bin/activate + pip install build==1.2.2.post1 + cd ${{ inputs.SOURCE_PATH }} + ../whl_creation/bin/python -m build --wheel --outdir ../whl_creation/dist + ls -l ../whl_creation/dist + + - name: Determine if unit and functional tests should run + id: test_check + shell: bash + run: | + REPO_NAME=$(echo "${{ inputs.PROJECT_NAME }}") + echo "Repository name extracted: $REPO_NAME" + + UNIT_TEST_DIR="${{ inputs.SOURCE_PATH }}/tests/unit/test_$(echo "${REPO_NAME}" | tr '-' '_')" + FUNC_TEST_DIR="${{ inputs.SOURCE_PATH }}/tests/system/test_$(echo "${REPO_NAME}" | tr '-' '_')" + if [ -d "$UNIT_TEST_DIR" ]; then + echo "Unit tests directory exists: $UNIT_TEST_DIR" + echo "run_unit_tests=true" >> $GITHUB_OUTPUT + else + echo "Unit tests directory does not exist: $UNIT_TEST_DIR" + echo "run_unit_tests=false" >> $GITHUB_OUTPUT + fi + if [ -d "$FUNC_TEST_DIR" ]; then + echo "Functional tests directory exists: $FUNC_TEST_DIR" + echo "run_functional_tests=true" >> $GITHUB_OUTPUT + else + echo "Functional tests directory does not exist: $FUNC_TEST_DIR" + echo "run_functional_tests=false" >> $GITHUB_OUTPUT + fi + + - name: Install dependencies for tests + if: steps.test_check.outputs.run_unit_tests == 'true' || steps.test_check.outputs.run_functional_tests == 'true' + shell: bash + run: | + python -m venv test_env + source test_env/bin/activate + python -m pip install -r "${{ inputs.SOURCE_PATH }}/requirements.txt" -r "${{ inputs.SOURCE_PATH }}/requirements-test.txt" -r "${{ inputs.SOURCE_PATH }}/requirements-dev.txt" + + - name: Run unit tests if test directory exists + if: steps.test_check.outputs.run_unit_tests == 'true' + shell: bash + run: | + source test_env/bin/activate + mfd-unit-tests --project-dir ${{ github.workspace }}/${{ inputs.SOURCE_PATH }} + + - name: Run functional tests if test directory exists + if: steps.test_check.outputs.run_functional_tests == 'true' + shell: bash + run: | + source test_env/bin/activate + mfd-system-tests --project-dir ${{ github.workspace }}/${{ inputs.SOURCE_PATH }} + - name: Publish package distributions to PyPI + if: ${{ inputs.RELEASE_BUILD == true && inputs.UPLOAD_PACKAGE == true }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: 'whl_creation/dist' + password: ${{ secrets.PYPI_TOKEN }} + + - name: Publish comment how to build .whl + if: inputs.RELEASE_BUILD == false + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + const commentBody = "We don't publish DEVs .whl.\n To build .whl, run 'pip install git+https://github.com/${{ inputs.REPOSITORY_NAME }}@${{ inputs.BRANCH_NAME }}'"; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: commentBody + }); + + - name: Push git tag after version bump + if: ${{ inputs.RELEASE_BUILD == true && inputs.PUSH_TAG == true }} + shell: bash + env: + GIT_AUTHOR_NAME: ${{ inputs.GIT_USER }} + GIT_AUTHOR_EMAIL: ${{ inputs.GIT_EMAIL }} + GIT_COMMITTER_NAME: ${{ inputs.GIT_USER }} + GIT_COMMITTER_EMAIL: ${{ inputs.GIT_EMAIL }} + version_after_bump: ${{ steps.VERSION_BUMP.outputs.version_after_bump }} + run: | + cd ${{ inputs.SOURCE_PATH }} + git push origin "${version_after_bump}" \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4c43daf --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,98 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + pull_request: + branches: [ "main" ] + push: + branches: [ "main" ] + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/manual_release.yml b/.github/workflows/manual_release.yml new file mode 100644 index 0000000..3292a7f --- /dev/null +++ b/.github/workflows/manual_release.yml @@ -0,0 +1,33 @@ +name: CI BUILD - RELEASE MODE +on: + workflow_dispatch: + +jobs: + build_upload_whl: + strategy: + matrix: + include: + - name: python-version-3-10 + python_version: '3.10' + push_tag: false + upload_package: false + continue-on-error: true + - name: python-version-3-13 + python_version: '3.13' + push_tag: true + upload_package: true + continue-on-error: true + uses: ./.github/workflows/build_upload_whl.yml + secrets: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + with: + REPOSITORY_NAME: ${{ github.repository }} + BRANCH_NAME: ${{ github.ref_name }} + PYTHON_VERSION: ${{ matrix.python_version }} + PUSH_TAG: ${{ matrix.push_tag }} + RELEASE_BUILD: true + UPLOAD_PACKAGE: ${{ matrix.upload_package }} + GIT_USER: 'mfd-intel-bot' + GIT_EMAIL: 'mfd_intel_bot@intel.com' + PROJECT_NAME: 'mfd-hyperv' \ No newline at end of file diff --git a/.github/workflows/pull_requests.yml b/.github/workflows/pull_requests.yml new file mode 100644 index 0000000..fa83091 --- /dev/null +++ b/.github/workflows/pull_requests.yml @@ -0,0 +1,29 @@ +name: DEV BUILD + +on: + pull_request: + types: [opened, synchronize] + +jobs: + build_upload_whl: + strategy: + matrix: + include: + - name: python-version-3-10 + python_version: '3.10' + push_tag: false + - name: python-version-3-13 + python_version: '3.13' + push_tag: false + uses: ./.github/workflows/build_upload_whl.yml + secrets: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + with: + REPOSITORY_NAME: ${{ github.repository }} + BRANCH_NAME: ${{ github.head_ref }} + PYTHON_VERSION: ${{ matrix.python_version }} + PUSH_TAG: ${{ matrix.push_tag }} + RELEASE_BUILD: false + GIT_USER: 'mfd-intel-bot' + GIT_EMAIL: 'mfd_intel_bot@intel.com' + PROJECT_NAME: 'mfd-hyperv' \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c26cb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,236 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/pycharm+all,python +# Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all,python + +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +coverage.json +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +pytestdebug.log + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +doc/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +pythonenv* + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# profiling data +.prof + +# End of https://www.toptal.com/developers/gitignore/api/pycharm+all,python +ruff.toml \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..eb82bc1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: +- repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + additional_dependencies: ['click==8.2.1'] \ No newline at end of file diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000..2c515bb --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,7 @@ +# AUTHORS + +* Arkadiusz Baczek (arkadiusz.baczek@intel.com) +* Mateusz Chrominski (mateusz.chrominski@intel.com) +* Hubert Cymerys (hubert.cymerys@intel.com) +* Agnieszka Flizikowska (agnieszka.flizikowska@intel.com) +* Adrian Lasota (adrian.lasota@intel.com) \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 134f4b2..0f3a971 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -129,4 +129,4 @@ For answers to common questions about this code of conduct, see the FAQ at [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq -[translations]: https://www.contributor-covenant.org/translations +[translations]: https://www.contributor-covenant.org/translations \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f682f4e..d5e4dc9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ### License - is licensed under the terms in [LICENSE]. By contributing to the project, you agree to the license and copyright terms therein and release your contribution under these terms. +MFD HyperV is licensed under the terms in [LICENSE](LICENSE.md). By contributing to the project, you agree to the license and copyright terms therein and release your contribution under these terms. ### Sign your work @@ -54,4 +54,4 @@ Then you just add a line to every git commit message: Use your real name (sorry, no pseudonyms or anonymous contributions.) If you set your `user.name` and `user.email` git configs, you can sign your -commit automatically with `git commit -s`. +commit automatically with `git commit -s`. \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..78c3ec7 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright © 2025 Intel Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..703438a --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +> [!IMPORTANT] +> This project is under development. All source code and features on the main branch are for the purpose of testing or evaluation and not production ready. + +# MFD Hyperv + +Module for handling functionalities of HyperV hypervisor. + +## Usage + +```python +from mfd_hyperv.base import HyperV +from mfd_connect import LocalConnection + +conn = LocalConnection() +hyperv = HyperV(connection=conn) +hyperv.hw_qos.delete_scheduler_queue("vSwitch00", "5") +``` + +## Implemented methods + +### Hardware QoS Offload functionality: + +* `create_scheduler_queue(vswitch_name: str, sq_id: str, sq_name: str, limit: bool, tx_max: str, tx_reserve: str, rx_max: str) -> None` - create scheduler queue +* `update_scheduler_queue(vswitch_name: str, limit: str, tx_max: str, tx_reserve: str, rx_max: str, sq_id: str) -> None` - update existing scheduler queue +* `delete_scheduler_queue(vswitch_name: str, sq_id: str) -> None` - delete existing scheduler queue +* `get_qos_config(vswitch_name: str) -> Dict[str, Union[str, bool]]` - get current QoS configuration for the vSwitch +* `set_qos_config(vswitch_name: str, hw_caps: bool, hw_reserv: bool, sw_reserv: bool, flags: str) -> None` - set QoS configuration on the vSwitch +* `disassociate_scheduler_queues_with_vport(vswitch_name: str, vport: int) -> None` - disassociate scheduler queues with virtual port +* `list_scheduler_queues_with_vport(vswitch_name: str, vport: str) -> List[str]` - list scheduler queues associated with virtual port +* `associate_scheduler_queues_with_vport(vswitch_name: str, vport: str, sq_id: str, lid: int, lname: str) -> None` - associate scheduler queues with virtual port +* `get_vmswitch_port_name(switch_friendly_name: str, vm_name: str) -> str` - get vmswitch port name +* `is_scheduler_queues_created(vswitch_name: str, sq_id: int, sq_name: str, tx_max: str) -> bool` - check if scheduler queues was properly created +* `list_queue(vswitch_name: str) -> str` - list queue from vSwitch +* `get_queue_all_info(vswitch_name: str) -> str` - get queue info for flag `all` +* `get_queue_offload_info(vswitch_name: str, sq_id: int) -> str` - get queue info for flag `offload` and indicated queue + +### Hypervisor: + +* `is_hyperv_enabled() -> bool` - check status of Hyper-V service on the machine +* `create_vm(vm_params: VMParams, owner: Optional[NetworkAdapterOwner] = None, hyperv=None, connection_timeout=3600, dynamic_mng_ip=False) -> VM` - create Hyper-V Virtual Machine (VM). Passing "hyperv" object to created VM allows for using VM object methods. +* `remove_vm(vm_name: str = "*") -> None` - remove VM with given name or all VMs +* `start_vm(vm_name: str = "*") -> None` - start VM with given name or all VMs +* `stop_vm(vm_name: str = "*", turnoff: bool = False) -> None` - stop VM with given name or all VMs. Allows to choose between graceful shutdown and forcible turnoff. +* `_vm_connectivity_test(ip_address: IPAddress) -> bool` - check ping connectivity with provided IP address +* `wait_vm_functional(vm_name: str, vm_mng_ip: IPAddress, timeout: int = 300) -> None` - wait for VM status "Running" and successful ping response +* `wait_vm_stopped(self, vm_name: str, timeout: int = 300) -> None` - wait for VM status "Off" +* `get_vm_state(vm_name: str) -> str` - get current VM state +* `restart_vm(vm_name: str = "*") -> None` - restart VM with given name or all VMs +* `clear_vm_locations() -> None` - check paths all paths where VM files could be stored and delete all remaining files +* `get_vm_attributes(vm_name: str) -> Dict[str, str]` - get VM attributes from host +* `get_vm_processor_attributes(vm_name: str) -> Dict[str, str]` - get processor attributes of given VM +* `set_vm_processor_attribute(vm_name: str, attribute: Union[VMProcessorAttributes, str], value: Union[str, int, bool]) -> None` - set VM Processor attribute +* `_get_disks_free_space() -> Dict[str, Dict[str, str]]` - return information such as the amount of free space and the total amount of space for all fixed drives that are not the system partition C +* `get_disk_paths_with_enough_space(bytes_required: int) -> str` - get disk with free space that exceeds given amount +* `copy_vm_image(vm_image: str, dst_location: "Path", src_location: str) -> str` - copy VM image from source location to destination location. If available compressed archive file with image will be copied +* `_get_file_metadata(file_path) -> Dict[str, str]` - get metadata of file. Metadata consists of LastWriteTime and Length (size in bytes) of given file. +* `_is_same_metadata(file_1, file_2, max_difference=300) -> bool` - check if metadata are the same. LastWriteTime is allowed to differ provided maximum number pof seconds. +* `is_latest_image(local_img_path: "Path", fresh_images_path: str) -> bool` - check if given image is up-to-date with remote VM location. +* `get_vm_template(vm_base_image: str, src_location: str) -> str` - get local path to VM image that will serve as a template for differencing disks. +* `create_differencing_disk(base_image_path: str, diff_disk_dir_path: str, diff_disk_name: str) -> str` - create differencing disk for VM from base image. +* `remove_differencing_disk(diff_disk_path: str) -> None` - remove differencing disk. +* `get_hyperv_vm_ips(file_path: str) -> List[IPAddress]` - retrieve vm ip list from file. +* `_get_mng_mask() -> int` - return Network Mask of management adapter (managementvSwitch). +* `get_free_ips(ips, required=5, timeout=600) -> List[str]` - get IP addresses that are not taken and can't pi successfully pinged +* `format_mac(ip, guest_mac_prefix: str = "52:5a:00") -> str` - get MAC address string based on mng IP address. +* `_wait_vm_mng_ips(vm_name: str = "*", timeout: int = 3600) -> str` - wait for specified VM or all VMs management adapters to receive correct IP address. +* `_remove_folder_contents(dir_path) -> None` - remove files from specified folder. +* `_is_folder_empty(dir_path) -> bool` - check if specified folder is empty. +* `get_file_size(path: str) -> int` - return size in bytes of specified file. + +### VSwitch manager: + +* `create_vswitch(interface_names: List[str], vswitch_name: str = vswitch_name_prefix, enable_iov: bool = False, enable_teaming: bool = False, mng: bool = False, interfaces: Optional[List[WindowsNetworkInterface]] = None) -> VSwitch` - create vSwitch. Passing interfaces object to created VSwitch allows for using VSwitch object methods. +* `_generate_name(vswitch_name: str, enable_teaming: bool) -> str` - create unified vswitch name with updated counter +* `create_mng_vswitch() -> VSwitch` - create management vSwitch. Only object is created when management vSwitch is already present on the machine +* `remove_vswitch(interface_name: str) -> None` - remove vswitch identified by its 'interface_name'. +* `get_vswitch_mapping(self) -> dict[str, str]` - Get a list of Hyper-V vSwitches and the adapters they are mapped to. + Returns: Dictionary where key are names of vswitches, values are Friendly names of an interfaces connect to (NetAdapterInterfaceDescription field from powershell output) +* `get_vswitch_attributes(interface_name: str) -> Dict[str, str]` - get vSwitch attributes in form of dictionary. +* `set_vswitch_attribute(interface_name: str, attribute: Union[VSwitchAttributes, str], value: Union[str, int, bool]) -> None` - set attribute on VSwitch. +* `remove_tested_vswitches() -> None` - remove all tested vSwitches, doesn't remove management vSwitch. +* `is_vswitch_present(interface_name: str) -> bool` - check if given virtual switch is present. +* `wait_vswitch_present(vswitch_name: str, timeout: int = 60, interval: int = 10) -> None` - wait for timeout duration for vSwitch to appear present. +* `rename_vswitch(interface_name: str, new_name: str) -> None:` - rename vSwitch and check if the change was successful + +### VMNetworkInterfaceManager manager: + +* `create_vm_network_interface(vm_name: str, vswitch_name: str | None = None, sriov: bool = False, vmq: bool = True, get_attributes: bool = False, vm: VM | None = None, vswitch: VSwitch | None = None) -> VMNetworkInterface` - add network interface to VM. +* `remove_vm_interface(vm_interface_name: str, vm_name: str) -> None` - remove network interface from VM. +* `connect_vm_interface(vm_interface_name: str, vm_name: str, vswitch_name: str) -> None` - connect vm adapter to virtual switch. +* `disconnect_vm_interface(vm_interface_name: str, vm_name: str) -> None` - disconnect VM Network Interface from vswitch. +* `clear_vm_interface_attributes_cache(self, vm_name=None) -> None` - clear cached vnics attributes information of specified VM. +* `set_vm_interface_attribute(vm_interface_name: str, vm_name: str, attribute: Union[VMNetworkInterfaceAttributes, str], value: Union[str, int]) -> None` - set attribute on vm adapter. +* `get_vm_interface_attributes(vm_interface_name: str, vm_name: str) -> Dict[str, str]` - get attributes of VM network interface. +* `get_vm_interfaces(vm_name: str) -> List[Dict[str, str]]` - return dictionary of VM Network interfaces. +* `_generate_name(vm_name) -> str` - create unified vn adapter interface name with updated counter +* `set_vm_interface_vlan(state, vm_name, interface_name, vlan_type, vlan_id, management_os) -> None` - configures the VLAN settings for the traffic through a virtual network adapter. +* `set_vm_interface_rdma(vm_name, interface_name, state) -> None` - set RDMA on VM nic (enable or disable) +* `get_vm_interface_vlan(vm_name, interface_name) -> Dict[str, str]` - get VLAN settings for the traffic through a virtual network adapter. +* `get_vm_interface_rdma(vm_name, interface_name) -> Dict[str, str]` - get RDMA settings for VM network adapter. +* `get_adapters_vf_datapath_active() -> bool` - Return Vfdatapathactive status of all VM adapters. +* `get_vm_interface_attached_to_vswitch(self, vswitch_name: str) -> str` - get the VMNetworkAdapter name that is attached to the vswitch. + Parameters: + :param `vswitch_name`: name of vswitch interface + Raises: `HyperVExecutionException` on any Powershell command execution error + Returns: `name` of interfaces attached to the `vswitch_name` +* `get_vlan_id_for_vswitch(self, vswitch_name: str) -> int` - get VLAN tagging set for Hyper-V vSwitch. Only access and untagged modes are supported at this point. + Parameters: + :param `vswitch_name`: vswitch adapter object + Raises: `HyperVExecutionException` on any Powershell command execution error + Returns: `vlan number`. `0` if untagged + +### VSwitch: + +* `interfaces()` - interfaces property representing list of interfaces that vswitch is created on. +* `interfaces(value)` - interfaces property setter +* `interfaces_binding() -> None` - create bindings between vswitch and network interfaces objects +* `get_attributes() -> Dict[str, str]` - return vSwitch attributes in form of dictionary. +* `set_and_verify_attribute(attribute: Union[VSwitchAttributes, str], value: Union[str, int, bool], sleep_duration: int = 1) -> bool` - set specified vswitch attribute to specified value and check if results where applied in the OS. +* `remove()` - remove vswitch identified by its 'interface_name' +* `rename(new_name: str) -> None` - rename vswitch with a specific name + +## OS supported: + +* WINDOWS + +## Issue reporting + +If you encounter any bugs or have suggestions for improvements, you're welcome to contribute directly or open an issue [here](https://github.com/intel/mfd-hyperv/issues). \ No newline at end of file diff --git a/configure.py b/configure.py new file mode 100644 index 0000000..4742b65 --- /dev/null +++ b/configure.py @@ -0,0 +1,12 @@ +"""Configure pre-commit hooks.""" + +import subprocess +import sys + +subprocess.run([sys.executable, "-m", "pip", "install", "pre-commit"], check=True) +print("pre-commit version:") +subprocess.run(["pre-commit", "--version"], check=True) +print("python version:") +subprocess.run([sys.executable, "--version"], check=True) + +subprocess.run(["pre-commit", "install"], check=True) diff --git a/examples/getting_vswitch_interfaces_examples.py b/examples/getting_vswitch_interfaces_examples.py new file mode 100644 index 0000000..374feb9 --- /dev/null +++ b/examples/getting_vswitch_interfaces_examples.py @@ -0,0 +1,22 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Example for get VLAN ID for switches, for mapping vswitches interfaces etc.""" +import logging + +from mfd_common_libs import add_logging_level, log_levels +from mfd_connect import RPyCConnection + +from mfd_hyperv.vm_network_interface_manager import VMNetworkInterfaceManager +from mfd_hyperv.vswitch_manager import VSwitchManager + +logger = logging.getLogger(__name__) +add_logging_level(level_name="MODULE_DEBUG", level_value=log_levels.MODULE_DEBUG) + +conn = RPyCConnection(ip="10.10.10.10") + +vswitch_mng = VMNetworkInterfaceManager(connection=conn) +print(vswitch_mng.get_vm_interface_attached_to_vswitch("managementvSwitch")) +print(vswitch_mng.get_vlan_id_for_vswitch("managementvSwitch")) + +vswitch_mng = VSwitchManager(connection=conn) +print(vswitch_mng.get_vswitch_mapping()) diff --git a/examples/simple_example.py b/examples/simple_example.py new file mode 100644 index 0000000..c724bb5 --- /dev/null +++ b/examples/simple_example.py @@ -0,0 +1,168 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT + +import pathlib +import time +import logging + +from time import sleep +from mfd_typing import MACAddress +from mfd_hyperv import HyperV +from mfd_connect import RPyCConnection +from mfd_hyperv.hypervisor import VMProcessorAttributes, VMParams +from mfd_hyperv.vswitch_manager import VSwitchAttributes, VSwitch +from mfd_hyperv.vm_network_interface_manager import VMNetworkInterfaceAttributes + + +class Owner: + def __init__(self, connection): + self.connection = connection + + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +conn = RPyCConnection(ip="10.10.10.10") +hyperv = HyperV(connection=conn) + + +hyperv.hypervisor.stop_vm("*", turnoff=True) + +# remove vms +hyperv.hypervisor.remove_vm() + +# delete leftover tested vswitches +hyperv.vswitch_manager.remove_tested_vswitches() + +hyperv.hypervisor.clear_vm_locations() + +mng_vswitch = hyperv.vswitch_manager.create_mng_vswitch() + +# remove vms leftovers +possible_locations = list(hyperv.hypervisor._get_disks_free_space().keys()) +for path in possible_locations: + vms_path = pathlib.PureWindowsPath(path, "VMs") + try: + hyperv.hypervisor.clear_vms_folders(vms_path) + except Exception: + pass + +ips = hyperv.hypervisor.get_hyperv_vm_ips() +mac = hyperv.hypervisor.format_mac(ips[0]) +# +free_vm_ips = hyperv.hypervisor.get_free_ips(ips, 1) +free_vm_macs = [hyperv.hypervisor.format_mac(ip) for ip in free_vm_ips] +dd = 1 +############################################################################################################# VSwitch + +if hyperv.vswitch_manager.is_vswitch_present("vs"): + vs = VSwitch("vs", create=False) + try: + hyperv.vswitch_manager.remove_vswitch(vs) + except Exception: + pass + + +if not hyperv.hypervisor.is_latest_image("D:\\VM-Template\\Base_W19.vhdx"): + hyperv.hypervisor.replace_image("D:\\VM-Template\\Base_W19.vhdx") + +vswitch = hyperv.vswitch_manager.create_vswitch(["SLOT 1 Port 1"], "vs", enable_iov=False) +hyperv.vswitch_manager.rename_vswitch(vswitch.interface_name, "new_name") + +hyperv.vswitch_manager.set_vswitch_attribute(vswitch.interface_name, VSwitchAttributes.EnableRscOffload, True) +attrs = hyperv.vswitch_manager.get_vswitch_attributes(vswitch.interface_name) +rsc = attrs.get(VSwitchAttributes.RscOffloadEnabled) +valu = hyperv.vswitch_manager.set_vswitch_attribute(vswitch.interface_name, VSwitchAttributes.EnableRscOffload, True) +val = hyperv.vswitch_manager.set_vswitch_attribute( + vswitch.interface_name, VSwitchAttributes.DefaultQueueVmmqEnabled, True +) + +############################################################################################################# VM + +image_path = hyperv.hypervisor.get_vm_template("Base_W19") +size = hyperv.hypervisor.get_file_size(image_path) +full_size = int(size + 3 * (0.75 * size)) +path = hyperv.hypervisor.get_disk_paths_with_enough_space(full_size) + +diff_disk_path = hyperv.hypervisor.create_differencing_disk(image_path, path, "dd_1.vhdx") + +vm_params = VMParams( + name="Base_W19_VM001", + cpu_count=4, + hw_threads_per_core=0, + memory=4096, + generation=2, + vm_dir_path=path, + diff_disk_path=diff_disk_path, + mng_interface_name="mng", + mng_mac_address=MACAddress(free_vm_macs[0]), + mng_ip=free_vm_ips[0], + vswitch_name="managementvSwitch", +) + +vm = hyperv.hypervisor.create_vm(vm_params) + +attrs = hyperv.hypervisor.get_vm_attributes("Base_W19_VM001") + +vnic = hyperv.vm_network_interface_manager.create_vm_network_interface( + vm.name, vswitch.interface_name, sriov=True, vmq=True +) +vnic2 = hyperv.vm_network_interface_manager.create_vm_network_interface( + vm.name, vswitch.interface_name, sriov=False, vmq=True +) + + +hyperv.vm_network_interface_manager.set_vm_interface_attribute( + vnic.interface_name, vm.name, VMNetworkInterfaceAttributes.IovWeight, 0 +) +time.sleep(1) +read_value = hyperv.vm_network_interface_manager.get_vm_interface_attributes(vnic.interface_name, vm.name)[ + VMNetworkInterfaceAttributes.IovWeight +] + +hyperv.vm_network_interface_manager.set_vm_interface_attribute( + vnic2.interface_name, vm.name, VMNetworkInterfaceAttributes.IovWeight, 100 +) +time.sleep(1) +read_value2 = hyperv.vm_network_interface_manager.get_vm_interface_attributes(vnic2.interface_name, vm.name)[ + VMNetworkInterfaceAttributes.IovWeight +] + +ifaces_info = hyperv.vm_network_interface_manager.get_vm_interfaces(vm.name) + +hyperv.vm_network_interface_manager.remove_vm_interface(vnic2.interface_name, vm.name) + +proc_attrs = hyperv.hypervisor.get_vm_processor_attributes(vm.name) +tpc = proc_attrs[VMProcessorAttributes.HWThreadCountPerCore] +count = proc_attrs[VMProcessorAttributes.Count] + +hyperv.hypervisor.stop_vm(vm.name) + +hyperv.hypervisor.set_vm_processor_attribute(vm.name, VMProcessorAttributes.HWThreadCountPerCore, 1) +hyperv.hypervisor.set_vm_processor_attribute(vm.name, VMProcessorAttributes.Count, 8) + +proc_attrs = hyperv.hypervisor.get_vm_processor_attributes(vm.name) +tpc_new = proc_attrs[VMProcessorAttributes.HWThreadCountPerCore] +count_new = proc_attrs[VMProcessorAttributes.Count] + +hyperv.hypervisor.start_vm(vm.name) +hyperv.hypervisor.wait_vm_functional(vm.name, vm.mng_ip) + +hyperv.hypervisor.restart_vm(vm.name) +hyperv.hypervisor.wait_vm_functional(vm.name, vm.mng_ip) + +# cleanup +hyperv.hypervisor.stop_vm("*", turnoff=True) +sleep(15) + +# remove vms +hyperv.hypervisor.remove_vm() +sleep(5) + +# delete leftover tested vswitches +hyperv.vswitch_manager.remove_tested_vswitches() + +# remove vms leftovers +hyperv.hypervisor.clear_vm_locations() + +dd = hyperv.hypervisor.is_hyperv_enabled() diff --git a/mfd_hyperv/__init__.py b/mfd_hyperv/__init__.py new file mode 100644 index 0000000..325f3e6 --- /dev/null +++ b/mfd_hyperv/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Module for Hyperv.""" +from .base import HyperV diff --git a/mfd_hyperv/attributes/__init__.py b/mfd_hyperv/attributes/__init__.py new file mode 100644 index 0000000..cf9f52f --- /dev/null +++ b/mfd_hyperv/attributes/__init__.py @@ -0,0 +1,3 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Module for attributes of Hyper-V objects.""" diff --git a/mfd_hyperv/attributes/vm_network_interface_attributes.py b/mfd_hyperv/attributes/vm_network_interface_attributes.py new file mode 100644 index 0000000..dc2e7c6 --- /dev/null +++ b/mfd_hyperv/attributes/vm_network_interface_attributes.py @@ -0,0 +1,62 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""VMNetworkInterfaceAttributes class.""" +from enum import Enum + + +class VMNetworkInterfaceAttributes(str, Enum): + """Attributes of VM network interface.""" + + def __str__(self) -> str: + return str.__str__(self) + + DynamicMacAddressEnabled = "dynamicmacaddressenabled" + AllowPacketDirect = "allowpacketdirect" + NumaAwarePlacement = "numaawareplacement" + MacAddressSpoofing = "macaddressspoofing" + AllowTeaming = "allowteaming" + RouterGuard = "routerguard" + DhcpGuard = "dhcpguard" + StormLimit = "stormlimit" + PortMirroringMode = "portmirroringmode" + IeeePriorityTag = "ieeeprioritytag" + VirtualSubnetId = "virtualsubnetid" + DynamicIPAddressLimit = "dynamicipaddresslimit" + DeviceNaming = "devicename" + VmqWeight = "vmqweight" + VmqUsage = "vmqusage" + IovWeight = "iovweight" + IovUsage = "iovusage" + IovQueuePairsRequested = "iovqueuepairsrequested" + IovQueuePairsAssigned = "iovqueuepairsassigned" + IovInterruptModeration = "iovinterruptmoderation" + PacketDirectNumProcs = "packetdirectnumprocs" + PacketDirectModerationCount = "packetdirectmoderationcount" + PacketDirectModerationInterval = "packetdirectmoderationinterval" + VrssEnabledRequested = "vrssenabledrequested" + VrssEnabled = "vrssenabled" + VmmqEnabledRequested = "vmmqenabledrequested" + VmmqEnabled = "vmmqenabled" + VrssMaxQueuePairsRequested = "vrssmaxqueuepairsrequested" + VrssMaxQueuePairs = "vrssmaxqueuepairs" + VrssMinQueuePairsRequested = "vrssminqueuepairsrequested" + VrssMinQueuePairs = "vrssminqueuepairs" + VrssQueueSchedulingModeRequested = "vrssqueueschedulingmoderequested" + VrssQueueSchedulingMode = "vrssqueueschedulingmode" + VrssExcludePrimaryProcessorRequested = "vrssexcludeprimaryprocessorrequested" + VrssExcludePrimaryProcessor = "vrssexcludeprimaryprocessor" + VrssIndependentHostSpreadingRequested = "vrssindependenthostspreadingrequested" + VrssIndependentHostSpreading = "vrssindependenthostspreading" + VrssVmbusChannelAffinityPolicyRequested = "vrssvmbuschannelaffinitypolicyrequested" + VrssVmbusChannelAffinityPolicy = "vrssvmbuschannelaffinitypolicy" + RscEnabledRequested = "rscenabledrequested" + RscEnabled = "rscenabled" + IPsecOffloadMaxSA = "ipsecoffloadmaxsa" + IPsecOffloadSAUsage = "ipsecoffloadsausage" + VFDataPathActive = "vfdatapathactive" + MaximumBandwidth = "maximumbandwidth" + MinimumBandwidthAbsolute = "minimumbandwidthabsolute" + MinimumBandwidthWeight = "minimumbandwidthweight" + BandwidthPercentage = "bandwidthpercentage" + VmmqQueuePairs = "vmmqqueuepairs" + StaticMacAddress = "staticmacaddress" diff --git a/mfd_hyperv/attributes/vm_params.py b/mfd_hyperv/attributes/vm_params.py new file mode 100644 index 0000000..ed38a05 --- /dev/null +++ b/mfd_hyperv/attributes/vm_params.py @@ -0,0 +1,41 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""VMParams class.""" +from dataclasses import dataclass, fields +from typing import Optional, Union + +from mfd_typing import MACAddress +from mfd_typing.dataclass_utils import convert_value_field_to_typehint_type + + +@dataclass +class VMParams: + """Configuration for VM. + + name: Name of VM + cpu_count: Count of CPUs + hw_threads_per_core: number of virtual SMT threads exposed to the virtual machine + memory: Memory value in MB + generation: Generation of VM + vm_dir_path: Path to directory where VM will be stored + img_file_name: Name of VM image used for VM + mng_interface_name: Name of management vSwitch + mng_mac_address: Mac address for management vSwitch + vswitch_name: Name of management vSwitch used + """ + + name: str = "vm" + cpu_count: int = 2 + hw_threads_per_core: int = 0 + memory: int = 2048 + generation: int = 2 + vm_dir_path: Optional[str] = None + diff_disk_path: Optional[str] = None + mng_interface_name: str = "mng" + mng_mac_address: Optional[Union[MACAddress, str]] = None + mng_ip: Optional[str] = None + vswitch_name: Optional[str] = None + + def __post_init__(self): + for field in fields(self): + convert_value_field_to_typehint_type(self, field) diff --git a/mfd_hyperv/attributes/vm_processor_attributes.py b/mfd_hyperv/attributes/vm_processor_attributes.py new file mode 100644 index 0000000..e8cca40 --- /dev/null +++ b/mfd_hyperv/attributes/vm_processor_attributes.py @@ -0,0 +1,14 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""VMProcessorAttributes class.""" +from enum import Enum + + +class VMProcessorAttributes(str, Enum): + """Enum for VM processor attributes.""" + + def __str__(self) -> str: + return str.__str__(self) + + Count: str = "count" + HWThreadCountPerCore: str = "hwthreadcountpercore" diff --git a/mfd_hyperv/attributes/vswitchattributes.py b/mfd_hyperv/attributes/vswitchattributes.py new file mode 100644 index 0000000..633ad42 --- /dev/null +++ b/mfd_hyperv/attributes/vswitchattributes.py @@ -0,0 +1,26 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""VSwitchAttributes class.""" +from enum import Enum + + +class VSwitchAttributes(str, Enum): + """Enum of attributes that can be set on vSwitch.""" + + def __str__(self) -> str: + return str.__str__(self) + + DefaultQueueVmmqEnabled: str = "defaultqueuevmmqenabled" + DefaultQueueVmmqQueuePairs: str = "defaultqueuevmmqqueuepairs" + EmbeddedTeamingEnabled: str = "embeddedteamingenabled" + EnableSoftwareRsc: str = "enablesoftwarersc" + EnableRscOffload: str = "enablerscoffload" + IovEnabled: str = "iovenabled" + IovQueuePairsInUse: str = "iovqueuepairsinuse" + IovSupport: str = "iovsupport" + IovVirtualFunctionCount: str = "iovvirtualfunctioncount" + IovVirtualFunctionsInUse: str = "iovvirtualfunctionsinuse" + NumberVmqAllocated: str = "numbervmqallocated" + RscOffloadEnabled: str = "rscoffloadenabled" + SoftwareRscEnabled: str = "softwarerscenabled" + SwitchType: str = "switchtype" diff --git a/mfd_hyperv/base.py b/mfd_hyperv/base.py new file mode 100644 index 0000000..83f77b1 --- /dev/null +++ b/mfd_hyperv/base.py @@ -0,0 +1,31 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Main module.""" + +from typing import TYPE_CHECKING + +from mfd_common_libs import os_supported +from mfd_typing import OSName + +from mfd_hyperv.hw_qos import HWQoS +from mfd_hyperv.hypervisor import HypervHypervisor +from mfd_hyperv.vm_network_interface_manager import VMNetworkInterfaceManager +from mfd_hyperv.vswitch_manager import VSwitchManager + +if TYPE_CHECKING: + from mfd_connect import Connection + + +class HyperV: + """Module for HyperV.""" + + @os_supported(OSName.WINDOWS) + def __init__(self, *, connection: "Connection"): + """Class constructor. + + :param connection: connection instance of MFD connect class. + """ + self.hw_qos = HWQoS(connection) + self.hypervisor = HypervHypervisor(connection=connection) + self.vswitch_manager = VSwitchManager(connection=connection) + self.vm_network_interface_manager = VMNetworkInterfaceManager(connection=connection) diff --git a/mfd_hyperv/exceptions.py b/mfd_hyperv/exceptions.py new file mode 100644 index 0000000..c774de0 --- /dev/null +++ b/mfd_hyperv/exceptions.py @@ -0,0 +1,12 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Module for exceptions.""" +import subprocess + + +class HyperVException(Exception): + """Handle HyperV module exceptions.""" + + +class HyperVExecutionException(subprocess.CalledProcessError): + """Handle execution exceptions.""" diff --git a/mfd_hyperv/helpers.py b/mfd_hyperv/helpers.py new file mode 100644 index 0000000..8fde766 --- /dev/null +++ b/mfd_hyperv/helpers.py @@ -0,0 +1,13 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Module for helper functions.""" +from typing import Union + + +def standardise_value(value: Union[int, str, bool]) -> str: + """Make input value standardised, no matter it's type to make comparisons more easily.""" + value = str(value) + value = value.lower() + if value in ["true", "false"]: + return f"${value}" + return value diff --git a/mfd_hyperv/hw_qos.py b/mfd_hyperv/hw_qos.py new file mode 100644 index 0000000..bb73921 --- /dev/null +++ b/mfd_hyperv/hw_qos.py @@ -0,0 +1,282 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Main module.""" + +import logging +import re + +from typing import TYPE_CHECKING +from mfd_common_libs import log_levels, add_logging_level + +from mfd_hyperv.exceptions import HyperVExecutionException, HyperVException + +if TYPE_CHECKING: + from mfd_connect import Connection + +logger = logging.getLogger(__name__) +add_logging_level(level_name="MODULE_DEBUG", level_value=log_levels.MODULE_DEBUG) + + +class HWQoS: + """Class for Hyper-V Hardware QoS Offload functionality.""" + + def __init__(self, connection: "Connection") -> None: + """ + Class constructor. + + :param connection: connection instance. + """ + self._connection = connection + + def create_scheduler_queue( + self, vswitch_name: str, sq_id: str, sq_name: str, limit: bool, tx_max: str, tx_reserve: str, rx_max: str + ) -> None: + """ + Create scheduler queue. + + :param vswitch_name: name of the virtual switch. + :param sq_id: ID for new scheduler queue. + :param sq_name: name for new scheduler queue. + :param limit: determine if enforcing infra-host limits. + :param tx_max: transmit limit. + :param tx_reserve: transmit reservation. + :param rx_max: receive limit. + :raises HyperVExecutionException: when command execution fails. + """ + limit = str(limit).lower() + cmd = ( + f"vfpctrl /switch {vswitch_name} /add-queue " f'"{sq_id} {sq_name} {limit} {tx_max} {tx_reserve} {rx_max}"' + ) + self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException) + + def update_scheduler_queue( + self, vswitch_name: str, limit: bool, tx_max: str, tx_reserve: str, rx_max: str, sq_id: str + ) -> None: + """ + Update existing scheduler queue. + + :param vswitch_name: name of the virtual switch. + :param limit: determine if enforcing infra-host limits. + :param tx_max: queue configuration value which determines transmit limit. + :param tx_reserve: transmit reservation. + :param rx_max: receive limit. + :param sq_id: ID of existing scheduler queue. + :raises HyperVExecutionException: when command execution fails. + """ + limit = str(limit).lower() + cmd = ( + f"vfpctrl /switch {vswitch_name} /set-queue-config " + f'"{limit} {tx_max} {tx_reserve} {rx_max}" /queue "{sq_id}"' + ) + self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException) + + def delete_scheduler_queue(self, vswitch_name: str, sq_id: str) -> None: + """ + Delete existing scheduler queue. + + :param vswitch_name: name of the virtual switch. + :param sq_id: ID of existing scheduler queue. + :raises HyperVExecutionException: when command execution fails. + """ + cmd = f'vfpctrl /switch {vswitch_name} /remove-queue /queue "{sq_id}"' + self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException) + + def get_qos_config(self, vswitch_name: str) -> dict[str, str | bool]: + """ + Get current QoS configuration for the vSwitch. + + :param vswitch_name: name of the virtual switch. + :return: config from parsed command output, for example: + {'hw_caps': False, 'hw_reserv': False, 'sw_reserv': True, flags: 0x0} + :raises HyperVExecutionException: when command execution fails. + """ + cmd = f"vfpctrl /switch {vswitch_name} /get-qos-config" + result = self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException) + + pattern = ( + r"Caps:+\s(?P\S+)[\s\S]*?" + r"Hardware Reservations:+\s(?P\S+)[\s\S]*?" + r"Software Reservations:+\s(?P\S+)[\s\S]*?" + r"Flags:+\s(?P\S+)" + ) + parsed_config = {} + matches = re.finditer(pattern, result.stdout) + params = ["hw_caps", "hw_reserv", "sw_reserv", "flags"] + + for match in matches: + for param in params: + if param == "flags": + parsed_config[param] = match.group(param) + else: + parsed_config[param] = True if match.group(param) == "TRUE" else False + + return parsed_config + + def set_qos_config(self, vswitch_name: str, hw_caps: bool, hw_reserv: bool, sw_reserv: bool, flags: str) -> None: + """ + Set QoS configuration on the vSwitch. + + :param vswitch_name: name of the virtual switch. + :param hw_caps: determine if enable hardware caps. + :param hw_reserv: determine if enable hardware reservations. + :param sw_reserv: determine if enable software reservations. + :param flags: flags. + :raises HyperVExecutionException: when command execution fails. + """ + hw_caps = str(hw_caps).lower() + hw_reserv = str(hw_reserv).lower() + sw_reserv = str(sw_reserv).lower() + cmd = f'vfpctrl /switch {vswitch_name} /set-qos-config "{hw_caps} {hw_reserv} {sw_reserv} {flags}"' + self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException) + + def disassociate_scheduler_queues_with_vport(self, vswitch_name: str, vport: str) -> None: + """ + Disassociate scheduler queues with virtual port. + + :param vswitch_name: name of the virtual switch. + :param vport: virtual port. + :raises HyperVExecutionException: when command execution fails. + """ + cmd = f"vfpctrl /switch {vswitch_name} /port {vport} /clear-port-queue" + self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException) + + def list_scheduler_queues_with_vport(self, vswitch_name: str, vport: str) -> list[str]: + """ + List scheduler queues associated with virtual port. + + :param vswitch_name: name of the virtual switch. + :param vport: virtual port. + :return: list of SQ IDs associated with given port, eg. ["1", "5", "2"] + :raises HyperVExecutionException: when command execution fails. + """ + cmd = f"vfpctrl /switch {vswitch_name} /port {vport} /get-port-queue" + result = self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException) + pattern = r"(?<=QOS QUEUE: ).*" + return re.findall(pattern, result.stdout) + + def associate_scheduler_queues_with_vport( + self, vswitch_name: str, vport: str, sq_id: str, lid: int, lname: str + ) -> None: + """ + Associate scheduler queues with virtual port. + + :param vswitch_name: name of the virtual switch. + :param vport: virtual port. + :param sq_id: ID of existing scheduler queue. + :param lid: layer ID + :param lname: layer name + :raises HyperVExecutionException: when any command execution fails. + """ + base_cmd = f"vfpctrl /switch {vswitch_name} /port {vport}" + params = [ + "/enable-port", + "/unblock-port", + f"/add-layer '{lid} {lname} stateless 100 1'", + f"/set-port-queue {sq_id}", + ] + for param in params: + cmd = f"{base_cmd} {param}" + self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException) + + def get_vmswitch_port_name(self, switch_friendly_name: str, vm_name: str) -> str: + """ + Get vmswitch port name. + + :param switch_friendly_name: switch friendly name. + :param vm_name: vm name. + :return: vmswitch port name. + :raises HyperVExecutionException: when command execution fails. + :raises HyperVException: when no vmswitch port name found. + """ + cmd = "vfpctrl /list-vmswitch-port" + + result = self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException) + + pattern = ( + r"Port\sname\s+:+\s(?P\S+)[\s\S]*?" + r"Switch\sFriendly\sname\s+:+\s(?P\S+)[\s\S]*?" + r"VM\sname\s+:+\s(?P\S+)" + ) + + matches = re.finditer(pattern, result.stdout) + + for match in matches: + if match.group("friendly_name") == switch_friendly_name and match.group("vm_name") == vm_name: + return match.group("port_name") + + raise HyperVException( + f"Couldn't find VM Switch port name for Switch Friendly name: {switch_friendly_name} and " + f"VM name: {vm_name} in output: {result.stdout}" + ) + + def is_scheduler_queues_created(self, vswitch_name: str, sq_id: int, sq_name: str, tx_max: str) -> bool: + """ + Verify that scheduler queues with provided name was created. + + :param vswitch_name: name of vswitch. + :param sq_id: scheduler queues for verification. + :param sq_name: name of scheduler queues. + :param tx_max: transmit limit value + :return: True if SQ found, False otherwise. + :raises HyperVExecutionException: when command execution fails. + """ + pattern = ( + r"(?<=QOS QUEUE: )(?P.*)\s+" + r"Friendly\sname\s+:+\s(?P\S+)[\s\S]*?\s+.+" + r"Transmit\sLimit:+\s(?P\S+)" + ) + + listed_queue = self.list_queue(vswitch_name=vswitch_name) + all_info = self.get_queue_all_info(vswitch_name=vswitch_name) + offload_info = self.get_queue_offload_info(vswitch_name=vswitch_name, sq_id=sq_id) + + for result in [listed_queue, all_info, offload_info]: + matches = re.finditer(pattern, result) + for match in matches: + if ( + match.group("friendly_name") == sq_name + and match.group("sq_id") == str(sq_id) + and match.group("tx_max") == str(tx_max) + ): + is_scheduler_queues_created = True + break + else: + is_scheduler_queues_created = False + + return is_scheduler_queues_created + + def list_queue(self, vswitch_name: str) -> str: + """ + List quested scheduler queues on a vswitch. + + :param vswitch_name: name of vswitch. + :return: output from command. + :raises HyperVExecutionException: when command execution fails. + """ + cmd = f"vfpctrl /switch {vswitch_name} /list-queue" + return self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException).stdout + + def get_queue_all_info(self, vswitch_name: str) -> str: + """ + List quested scheduler queues on a vswitch. + + :param vswitch_name: name of vswitch. + :return: output from command. + :raises HyperVExecutionException: when command execution fails. + """ + cmd = f'vfpctrl /switch {vswitch_name} /get-queue-info "all"' + + return self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException).stdout + + def get_queue_offload_info(self, vswitch_name: str, sq_id: int) -> str: + """ + List quested scheduler queues on a vswitch. + + :param vswitch_name: name of vswitch. + :param sq_id: number of scheduler queues. + :return: output from command. + :raises HyperVExecutionException: when command execution fails. + """ + cmd = f'vfpctrl /switch {vswitch_name} /get-queue-info "offload" /queue "{sq_id}"' + + return self._connection.execute_powershell(command=cmd, custom_exception=HyperVExecutionException).stdout diff --git a/mfd_hyperv/hypervisor.py b/mfd_hyperv/hypervisor.py new file mode 100644 index 0000000..4d1958c --- /dev/null +++ b/mfd_hyperv/hypervisor.py @@ -0,0 +1,741 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Module for Hyper-V hypervisor. + +Contents: +-VMParams + dataclass for parameters required for Virtual Machine creation + +-VMProcessorAttributes + enum class with set of most used setting names of VMProcessor + +-VM + representation of single Hyper-V Virtual Machine (guest) which manages operations executed on the guest + +-HypervHypervisor + representation of Hypervisor containing API for Powershell cmdlets that manage Virtual Machines on the Host +""" + +import logging +import random +import re +import time +from datetime import datetime +from pathlib import Path +from typing import Dict, Union, List, Optional, TYPE_CHECKING + +from mfd_common_libs import os_supported, add_logging_level, log_levels, TimeoutCounter +from mfd_connect import Connection, RPyCConnection +from mfd_connect.util.powershell_utils import parse_powershell_list +from mfd_connect.util.rpc_copy_utils import copy +from mfd_network_adapter import NetworkAdapterOwner +from mfd_ping import Ping +from mfd_typing import OSName +from netaddr.ip import IPAddress, IPNetwork + +from mfd_hyperv.attributes.vm_params import VMParams +from mfd_hyperv.attributes.vm_processor_attributes import VMProcessorAttributes +from mfd_hyperv.exceptions import HyperVExecutionException, HyperVException +from mfd_hyperv.helpers import standardise_value +from mfd_hyperv.instances.vm_network_interface import VM + +if TYPE_CHECKING: + from mfd_hyperv import HyperV + + +logger = logging.getLogger(__name__) +add_logging_level(level_name="MODULE_DEBUG", level_value=log_levels.MODULE_DEBUG) + + +class HypervHypervisor: + """Module for HyperV.""" + + @os_supported(OSName.WINDOWS) + def __init__(self, *, connection: "Connection"): + """Class constructor. + + :param connection: connection instance of MFD connect class. + """ + self._connection = connection + self.vms = [] + + def is_hyperv_enabled(self) -> bool: + """Check if Hyper-V is enabled. + + :raises: HyperVException when Hyper-V is unavailable + :return: True if enabled, False otherwise + """ + status_correlation = {"Enabled": True, "Disabled": False} + result = self._connection.execute_powershell( + "Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V", expected_return_codes={} + ) + if result.return_code: + raise HyperVException("Hyper-V status unavailable") + + status_regex = r"State\s+:\s+(?P(Disabled|Enabled))" + match = re.search(status_regex, result.stdout) + if match: + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Hyper-V status: {match.group('status')}") + return status_correlation[match.group("status")] + raise HyperVException("Hyper-V status unavailable") + + def create_vm( + self, + vm_params: VMParams, + owner: Optional[NetworkAdapterOwner] = None, + hyperv: "HyperV" = None, + connection_timeout: int = 3600, + dynamic_mng_ip: bool = False, + ) -> VM: + """Create a new VM using the specified vm_params. + + :param vm_params: dictionary of VM parameters + :param owner: SUT host that will host new Vm + :param hyperv: Hyperv object that will be used by Vm instance + :param connection_timeout: timeout of RPyCConnection to VM + :param dynamic_mng_ip: To enable or disable dynamic mng ip allocation + """ + commands = [ + f'New-VM "{vm_params.name}" -Generation {vm_params.generation} -Path {vm_params.vm_dir_path}', + f'Add-VMHardDiskDrive -VMName "{vm_params.name}" -Path {vm_params.diff_disk_path}', + f"Set-VMProcessor -VMName {vm_params.name} -Count {vm_params.cpu_count}", + f"Set-VMMemory -VMName {vm_params.name} -StartupBytes {vm_params.memory}MB", + f"Remove-VMNetworkAdapter -VMName {vm_params.name} -Name *Adapter*", + f"Add-VMNetworkAdapter -VMName '{vm_params.name}' -Name '{vm_params.mng_interface_name}'" + f" -StaticMacAddress {str(vm_params.mng_mac_address).replace(':', '')}" + f" -SwitchName '{vm_params.vswitch_name}'", + f'Set-VMNetworkAdapter -Name "{vm_params.mng_interface_name}" -VMName "{vm_params.name}" -VmqWeight 0', + f'Set-VMFirmware -EnableSecureBoot Off -VMName "{vm_params.name}"', + f'Enable-VMIntegrationService -VMName "{vm_params.name}" -Name "Guest Service Interface"', + ] + + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Create VM {vm_params.name}") + for command in commands: + self._connection.execute_powershell(command=command, custom_exception=HyperVExecutionException) + + self.start_vm(vm_params.name) + mng_ip = self._wait_vm_mng_ips(vm_params.name) + if dynamic_mng_ip: + vm_params.mng_ip = mng_ip + + vm_connection = RPyCConnection(ip=vm_params.mng_ip, connection_timeout=connection_timeout) + vm = VM(vm_connection, vm_params, owner, hyperv, connection_timeout) + + self.vms.append(vm) + return vm + + def remove_vm(self, vm_name: str = "*") -> None: + """Remove specified VM or all VMs. + + :param vm_name: Virtual Machine name + """ + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Removing {vm_name if vm_name != '*' else 'all'} VM") + self._connection.execute_powershell( + f"Remove-VM -name {vm_name} -force -Confirm:$false", custom_exception=HyperVExecutionException + ) + if vm_name == "*": + self.vms.clear() + else: + vm = next((vm for vm in self.vms if vm.name == vm_name), None) + if vm is not None: + self.vms.remove(vm) + + def start_vm(self, vm_name: str = "*") -> None: + """Start VM with given name, if no name will be provided all VMs will be started. + + :param vm_name: Name of VM or * + :raises: HyperVException when VM cannot be started + """ + result = self._connection.execute_powershell(f"Start-VM {vm_name}", expected_return_codes={}) + if result.return_code: + raise HyperVException(f"Cannot start VM{'s' if vm_name == '*' else f' {vm_name}'}") + + def stop_vm(self, vm_name: str = "*", turnoff: bool = False) -> None: + """Stop VM with given name, if no name will be provided all VMs will be stopped. + + :param vm_name: Name of VM or * + :param turnoff: whether let VM shutdown or "disconnect power from VM" + :raises: HyperVException when VM cannot be stopped + """ + cmd = f"Stop-VM {vm_name}" + if turnoff: + cmd += " -force -TurnOff -confirm:$false" + + result = self._connection.execute_powershell(cmd, expected_return_codes={}) + if result.return_code: + raise HyperVException(f"Cannot stop VM{'s' if vm_name == '*' else f' {vm_name}'}") + + def _vm_connectivity_test(self, ip_address: IPAddress, timeout: int = 10) -> bool: + """Ping given IP address and report result. + + :param ip_address: ip address to ping + :param timeout: time given for ping process to reach expected state + """ + ping = Ping(connection=self._connection) + ping_process = ping.start(dst_ip=ip_address) + timeout_reached = TimeoutCounter(timeout) + while not (ping_process.running or timeout_reached): + time.sleep(1) + + timeout_reached = TimeoutCounter(timeout) + while ping_process.running and not timeout_reached: + time.sleep(1) + results = ping.stop(ping_process) + return results.fail_count == 0 + + def wait_vm_functional(self, vm_name: str, vm_mng_ip: IPAddress, timeout: int = 300) -> None: + """Wait for Vm to be functional. + + VM is considered running and functional if its state is running and its management interface can be pinged. + :param vm_name: virtual machine name + :param vm_mng_ip: Ip address of VM management interface to ping + :param timeout: maximum time duration for VM to become pingable + :raises: HyperVException when VM management IP cannot be pinged after several attempts indicating an issue + """ + timeout_counter = TimeoutCounter(timeout) + while not timeout_counter: + connectivity_result = self._vm_connectivity_test(vm_mng_ip, int(timeout / 10)) + current_state = self.get_vm_state(vm_name) + if current_state == "Running" and connectivity_result: + return + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"Waiting 5s for VM state 'Running' and a successful ping. Current VM state is {current_state}" + f" and ping is {'PASSing' if connectivity_result else 'FAILing'}", + ) + time.sleep(5) + raise HyperVException(f"VM {vm_name} cannot cannot reach state where it is pingable.") + + def wait_vm_stopped(self, vm_name: str, timeout: int = 300) -> None: + """Wait for Vm to stop running. + + :param vm_name: virtual machine name + :param timeout: maximum time duration for VM to become Off + :raises: HyperVException when VM doesn't reach 'Off' state after duration of time indicating an issue + """ + timeout_counter = TimeoutCounter(timeout) + while not timeout_counter: + current_state = self.get_vm_state(vm_name) + if current_state == "Off": + return + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"Waiting 5s for VM state 'Off'. Current VM state is {current_state}", + ) + time.sleep(5) + raise HyperVException(f"VM {vm_name} cannot reach 'Off' state.") + + def get_vm_state(self, vm_name: str) -> str: + """Get current vm state from host. + + :param vm_name: name of virtual machine + """ + result = self._connection.execute_powershell(f"get-vm '{vm_name}' | fl").stdout + return parse_powershell_list(result)[0].get("State") + + def restart_vm(self, vm_name: str = "*") -> None: + """Restart VM with given name, if no name will be provided all VMs will be restarted. + + :param vm_name: Name of VM or * + :raises: HyperVException when VM cannot be restarted + """ + result = self._connection.execute_powershell( + f"Restart-VM {vm_name} -force -confirm:$false", expected_return_codes={} + ) + if result.return_code: + raise HyperVException(f"Cannot restart VM{'s' if vm_name == '*' else f' {vm_name}'}") + + def clear_vm_locations(self) -> None: + """Clear all possible VMs locations.""" + logger.log(level=log_levels.MODULE_DEBUG, msg="Clean all possible VMs locations.") + + locations = list(self._get_disks_free_space().keys()) + vms_locations = [f"{location}\\VMs" for location in locations] + + for location in vms_locations: + try: + self._connection.execute_powershell(f"Remove-Item -Recurse -Force {location}\\*") + except Exception: + pass + + def get_vm_attributes(self, vm_name: str) -> Dict[str, str]: + """Return VM attributes in form of dictionary. + + :param vm_name: name of virtual machine + :raises: HyperVException when attributes of VM cannot be retrieved + :return: dictionary with vm attributes + """ + result = self._connection.execute_powershell(f"Get-VM {vm_name} | select * | fl", expected_return_codes={}) + + if result.return_code: + raise HyperVException(f"Couldn't get VM {vm_name} attributes") + + return parse_powershell_list(result.stdout)[0] + + def get_vm_processor_attributes( + self, + vm_name: str, + ) -> Dict[str, str]: + """Get value of specified attribute of VMProcessor. + + :param vm_name: name of VM + :raises: HyperVException when VMProcessor attributes cannot be retrieved + """ + cmd = f"Get-VMProcessor -VMName {vm_name} | select * | fl" + result = self._connection.execute_powershell(cmd, expected_return_codes={}) + + if result.return_code: + raise HyperVException(f"Couldn't get VMProcessor attributes of VM {vm_name}") + + return parse_powershell_list(result.stdout.lower())[0] + + def set_vm_processor_attribute( + self, vm_name: str, attribute: Union[VMProcessorAttributes, str], value: Union[str, int, bool] + ) -> None: + """Set value of specified attribute of VMProcessor. + + :param vm_name: name of VM + :param attribute: attribute that value is requested + :param value: new value + :raises: HyperVException when VMProcessor attributes cannot be set + :return: new assigned value + """ + if isinstance(attribute, VMProcessorAttributes): + attribute = attribute.value + value = standardise_value(value) + + cmd = f"Set-VMProcessor -VMName {vm_name} -{attribute} {value}" + result = self._connection.execute_powershell(cmd, expected_return_codes={}) + + if result.return_code: + raise HyperVException(f"Couldn't set VMProcessor attribute {attribute} value {value} of VM {vm_name}") + + def _get_disks_free_space(self) -> Dict[str, Dict[str, str]]: + """Get each disk free space. + + :return: dictionary {"partition_name": {"free": 0, "total": 5000}} + """ + drive_types = { + "Unknown": "0", + "NoRootDirectory": "1", + "Removable": "2", + "Fixed": "3", + "Network": "4", + "CDRom": "5", + "Ram": "6", + } + + logger.log(level=log_levels.MODULE_DEBUG, msg="Get available space for each disk present on the system") + result = self._connection.execute_powershell( + "Get-WmiObject -Class Win32_LogicalDisk | Select-Object -Property Caption, DriveType, FreeSpace, Size | " + "Out-File -FilePath wmi_file.txt; Get-Content wmi_file.txt; Remove-Item -Path wmi_file.txt", + custom_exception=HyperVExecutionException, + ) + + # matches C: 3 288 300 + disk_pattern = re.compile(r"\s*(?P\w:)\s+(?P\d)\s+(?P\d+)\s+(?P\d+)") + disk_dict = {} + for match in disk_pattern.finditer(result.stdout): + # only take non-removable disks (fixed) + if match.group("type") == drive_types.get("Fixed"): + disk = f"{match.group('partition')}\\" + disk_dict[disk] = { + "free": match.group("free"), + "total": match.group("total"), + } + + if len(disk_dict) < 1: + raise HyperVException("Expected partition was not found.") + + # sort by free space amount descending + return dict(sorted(disk_dict.items(), key=lambda item: int(item[1]["free"]), reverse=True)) + + def get_disk_paths_with_enough_space(self, bytes_required: int) -> str: + """Return available_paths to disks that have enough free space for given bytes required. + + :param bytes_required: size of required VM images and differencing disks + :raises: HyperVException when no partition is big enough + :return: list of paths where VMs should be stored, list is ordered by the amount of free space descending + """ + partitions = self._get_disks_free_space() + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"Looking for partition with {bytes_required / 1_000_000_000} GB free space..", + ) + + big_enough_partitions = [key for key, value in partitions.items() if int(value["free"]) > bytes_required] + + if len(big_enough_partitions) == 0: + raise HyperVException("No disk that has enough space") + + partition = big_enough_partitions[0] + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Found enough space on disk: {partition}") + + # check if VMS folder exist, if not create it + if not self._connection.path(partition, "VMs").exists(): + self._connection.execute_command("mkdir VMs", cwd=f"{partition}") + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Created VMs folder on disk {partition}") + logger.log(level=log_levels.MODULE_DEBUG, msg=f"VMs folder is present on disk {partition}") + return str(self._connection.path(partition, "VMs")) + + def copy_vm_image( + self, + vm_image: str, + dst_location: "Path", + src_location: str, + ) -> str: + """Copy VM image from source location to destination location. + + If available compressed archive file with image will be copied. + + :param vm_image: VM image to be copied + :param dst_location: location for VM image to be stored in + :param src_location: source location of VM image + """ + vm_image = f"{vm_image}.vhdx" + src_img_path = self._connection.path(src_location, vm_image) + dst_img_path = self._connection.path(dst_location, vm_image) + + if dst_img_path.exists(): + self._connection.execute_powershell( + f"remove-item {dst_img_path} -force", custom_exception=HyperVExecutionException + ) + time.sleep(3) + + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Copying {vm_image} from {src_location}") + src_zip_path = src_img_path.with_suffix(".zip") + if src_zip_path.exists(): + dst_zip_path = dst_img_path.with_suffix(".zip") + + copy( + self._connection, + self._connection, + src_zip_path, + dst_zip_path, + ) + + logger.log(level=log_levels.MODULE_DEBUG, msg="Unpacking image from .zip archive and removing archive") + # tar is preferred tool for decompression since it is about twice as fast as cmdlet + # tar, by default is not available on Windows Server 2016 thus in that case use cmdlet + if "16" in self._connection.get_system_info().os_name: + self._connection.execute_powershell( + f"Expand-Archive -LiteralPath {dst_zip_path} {str(self._connection.path(dst_zip_path).parent)}", + cwd=str(self._connection.path(dst_zip_path).parent), + ) + else: + self._connection.execute_powershell( + f"tar -xf {dst_zip_path}", cwd=self._connection.path(dst_zip_path).parent + ) + time.sleep(3) + self._connection.execute_powershell(f"remove-item {dst_zip_path} -force") + time.sleep(3) + else: + copy( + self._connection, + self._connection, + src_img_path, + dst_img_path, + ) + return dst_img_path + + def _get_file_metadata(self, file_path: Union[str, Path]) -> Dict[str, str]: + """Get metadata of file. + + Metadata consists of LastWriteTime and Length (size in bytes) of given file. + + :param file_path: file path + """ + result = self._connection.execute_powershell(f"get-itemproperty {file_path} | fl") + pattern = r"Length\s+:\s*(?P[0-9]+)[a-zA-Z0-9:\s\n\/]+LastWriteTime\s+:\s*(?P.*[AP]M)" + match = re.search(pattern, result.stdout) + if not match: + raise HyperVException(f"Could not read file {file_path} metadata") + length = match.group("length") + last_write_time = match.group("lwt") + + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"Image {file_path} LastWriteTime: {last_write_time}, Length: {length}", + ) + return {"lwt": last_write_time, "length": length} + + def _is_same_metadata(self, file_1: Dict[str, str], file_2: Dict[str, str], max_difference: str = 300) -> bool: + """Check if metadata are the same. + + LastWriteTime is allowed to differ provided maximum number pof seconds. + + :param file_1: file metadata + :param file_2: file metadata + :param max_difference: maximum number of seconds 2 metadata can differ to be considered the same metadata + """ + date_time_1 = datetime.strptime(file_1["lwt"], "%m/%d/%Y %I:%M:%S %p") + date_time_2 = datetime.strptime(file_2["lwt"], "%m/%d/%Y %I:%M:%S %p") + difference = date_time_1 - date_time_2 + + diff_seconds = int(abs(difference.total_seconds())) + if file_1["length"] == file_2["length"]: + if diff_seconds == 0: + return True + if diff_seconds <= max_difference: + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"LastWriteTime attribute difference ({diff_seconds}s) of both images is less than" + f" {max_difference}s. Sizes of both images are the same. No need to copy new file.", + ) + return True + + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"LastWriteTime attribute difference ({diff_seconds}s) of both images is more than" + f" {max_difference}s. Sizes of both images are the same. Need to copy new file.", + ) + return False + + logger.log(level=log_levels.MODULE_DEBUG, msg="Sizes of both images are not the same. Need to copy new file.") + return False + + def is_latest_image( + self, + local_img_path: "Path", + fresh_images_path: str, + ) -> bool: + """Check if given image is up-to-date with remote VM location. + + :param local_img_path: VM image to be checked + :param fresh_images_path: location for VM image to be stored in + """ + remote_img_path = self._connection.path(fresh_images_path, local_img_path.name) + if not remote_img_path.exists(): + logger.log(level=log_levels.MODULE_DEBUG, msg="Image not found on remote sharepoint. No copying required.") + return True + + local_metadata = self._get_file_metadata(local_img_path) + remote_metadata = self._get_file_metadata(remote_img_path) + return self._is_same_metadata(local_metadata, remote_metadata) + + def get_vm_template(self, vm_base_image: str, src_location: str) -> str: + """Get local path to VM image that will serve as a template for differencing disks. + + :param vm_base_image: image file name to find + :param src_location: source location of VM image + :return: image absolute path + """ + # get location with most free space + location = list(self._get_disks_free_space().keys())[0] + logger.log( + level=log_levels.MODULE_DEBUG, msg=f"VM-Template should be located on disk with most space: {location}" + ) + + vm_temp_path = self._connection.path(location, "VM-Template") + if not vm_temp_path.exists(): + self._connection.execute_command("mkdir VM-Template", cwd=f"{location}") + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Created VM-Template folder on disk {location}") + logger.log(level=log_levels.MODULE_DEBUG, msg=f"VM-Template folder is present on disk {location}") + + # check if it contains disk with specified name + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Check if {vm_base_image} is present in {vm_temp_path}") + result = self._connection.execute_powershell( + command="Get-ChildItem | select -ExpandProperty FullName", + cwd=f"{vm_temp_path}", + custom_exception=HyperVExecutionException, + ) + + local_img_paths = [self._connection.path(item) for item in result.stdout.strip().splitlines()] + for path in local_img_paths: + filename = path.stem + if vm_base_image == filename: + if not self.is_latest_image(path, src_location): # pragma: no cover + self.copy_vm_image(vm_base_image, vm_temp_path, src_location) + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"Found base image {path}", + ) + return str(path) + else: + logger.log(level=log_levels.MODULE_DEBUG, msg="File not found. It must be copied from external source") + copied_image_path = self.copy_vm_image(vm_base_image, vm_temp_path, src_location) + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"Found base image {copied_image_path}", + ) + return str(copied_image_path) + + def create_differencing_disk(self, base_image_path: str, diff_disk_dir_path: str, diff_disk_name: str) -> str: + """Create differencing disk for VM from base image. + + :param base_image_path: Path to base disk file + :param diff_disk_dir_path: Absolute path to differencing disks directory + :param diff_disk_name: Name of differencing disk + :raises: HyperVException when differencing disk cannot be created + :return: absolute path to created differencing disk + """ + path_with_name = self._connection.path(diff_disk_dir_path, diff_disk_name) + cmd = f"New-VHD -ParentPath {base_image_path} {path_with_name} -Differencing" + result = self._connection.execute_powershell(cmd, expected_return_codes={}) + + if result.return_code: + raise HyperVException(f"Cannot create differencing disk {diff_disk_name}") + + # double check to make sure that disk exists even when command return code was 0 + if self._connection.path(diff_disk_dir_path, diff_disk_name).exists(): + logger.log(level=log_levels.MODULE_DEBUG, msg=f"VM differencing disk created: {path_with_name}") + return str(path_with_name) + + raise HyperVException("Command execution succeed but disk doesn't exist ") + + def remove_differencing_disk(self, diff_disk_path: str) -> None: + """Remove differencing disk. + + :param diff_disk_path: absolute path to differencing disk + """ + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Removing differencing disk {diff_disk_path}") + cmd = f"Remove-Item {diff_disk_path}" + self._connection.execute_powershell(cmd, custom_exception=HyperVExecutionException) + + def get_hyperv_vm_ips(self, file_path: str) -> List[IPAddress]: + """Retrieve vm ip list from file.""" + result = self._connection.path(file_path).read_text() + segments = result.split("\n\n") + hv_segment = next(seg for seg in segments if "[hv]" in seg) + + ips = hv_segment.split() + valid_ips = [IPAddress(ip.strip()) for ip in ips if "[" not in ip and "#" not in ip] + network = IPNetwork(f"{self._connection._ip}/{self._get_mng_mask()}") + return [ip for ip in valid_ips if ip in network] + + def _get_mng_mask(self) -> int: + """Return Network Mask of management adapter (managementvSwitch).""" + output = self._connection.execute_powershell("ipconfig").stdout + parsed_output = parse_powershell_list(output) + + mng_adapter_info = next( + adapter_info for adapter_info in parsed_output if str(self._connection._ip) in adapter_info.values() + ) + mask_key = next(key for key in mng_adapter_info.keys() if "Subnet Mask" in key) + mask = mng_adapter_info[mask_key] + return IPAddress(mask).netmask_bits() + + def get_free_ips(self, ips: List[IPAddress], required: int = 5, timeout: int = 1200) -> List[str]: + """Get IP addresses that are not taken and can't pi successfully pinged. + + Usually free IP addresses do not send response when pinged back. + :param ips: list of all VM IP addresses + :param required: required number of IP addresses + :param timeout: time given to check available IP addresses + :raises: HyperVException when number of available VM IP addresses is less that required + """ + taken_ips = [] + timeout_reached = TimeoutCounter(timeout) + while not timeout_reached: + not_choosen_ips = [item for item in ips if item not in taken_ips] + ip = random.choice(not_choosen_ips) + if not self._vm_connectivity_test(ip, 10): + taken_ips.append(ip) + if len(taken_ips) == required: + return taken_ips + raise HyperVException( + f"Not enough free VM IPs. Found only {len(taken_ips)} IPs but {required} was required." + f"Timeout of {timeout}s has been reached." + ) + + def format_mac(self, ip: IPAddress, guest_mac_prefix: str = "52:5a:00") -> str: + """Get MAC address string based on mng IP address. + + VM-guest's management adapter MAC address is encoded to following formula: + XX:XX:XX:YY:YY:YY, where + + XX:XX:XX - is constant part defined by 'guest_mac_prefix' field + YY:YY:YY - 3 least significant octets from ip address + + Example: + 52:54:00:66:16:7B in case of KVM and ip = 10.102.22.103 + + :param ip: IP address + :param guest_mac_prefix: first 3 bytes of MAC address that are const + :raises: HyperVException when MAC address cannot be produced from given IP address + """ + match = re.search(r"(?:[\d]{1,3})\.([\d]{1,3})\.([\d]{1,3})\.([\d]{1,3})", str(ip)) + if match: + return "{}:{:02x}:{:02x}:{:02x}".format(guest_mac_prefix, *[int(x) for x in match.groups()]).lower() + raise HyperVException(f"Couldn't format IP {ip} into MAC address.") + + def _wait_vm_mng_ips(self, vm_name: str = "*", timeout: int = 3600) -> str: + """Wait for specified VM or all VMs management adapters to receive correct IP address. + + During setup of many VMs DHCP takes long time to set up vm adapter IP address. + :param vm_name: name of VM or * + :param timeout: maximum time duration waited + :raises: HyperVException when VM management interface cannot set (DHCP) IP address after given amount of time + """ + timeout_reached = TimeoutCounter(timeout) + while not timeout_reached: + result = self._connection.execute_powershell( + f"Get-VMNetworkAdapter -VMName {vm_name}" + " | select vmname, ipaddresses, macaddress" + " | Sort-Object -Property VMName | fl", + expected_return_codes={0}, + ) + + data = parse_powershell_list(result.stdout)[0] + addresses = data["IPAddresses"] + + pattern = r"(?P([0-9]{1,3}\.){3}[0-9]{1,3})" + match = re.search(pattern, addresses) + + if not match: + logger.log( + level=log_levels.MODULE_DEBUG, + msg="Hosts not ready yet. No IPv4 address found. Waiting for IPs to set up", + ) + time.sleep(5) + continue + + ip4 = match.group("ipv4") + if re.search(r"169(\.[0-9]{1,3}){3}", ip4): + logger.log( + level=log_levels.MODULE_DEBUG, + msg="Hosts not ready yet. Default IPv4 address found. Waiting for IPs to set up", + ) + time.sleep(5) + continue + + return ip4 + + raise HyperVException("Problem with setting IP on mng adapter on one of VMs") + + def _remove_folder_contents(self, dir_path: Union[str, Path]) -> None: + """Empty specified folder. + + :param: dir_path: path to directory which has to be emptied + """ + logger.log( + level=log_levels.MODULE_DEBUG, msg=f"Remove all folders and files from {dir_path} after VMs cleanup" + ) + self._connection.execute_powershell( + "get-childitem -Recurse | remove-item -recurse -confirm:$false", cwd=dir_path + ) + + def _is_folder_empty(self, dir_path: Union[str, Path]) -> bool: + """Check if specified folder is empty. + + :param: dir_path: path to directory which has to be emptied + """ + # ensure directory is empty + result = self._connection.execute_powershell( + "Get-ChildItem | select -ExpandProperty fullname", cwd=dir_path, expected_return_codes={} + ) + return result.stdout == "" + + def get_file_size(self, path: str) -> int: + """Return size in bytes of specified file. + + :param path: absolute path of file + :return: size of specified file + """ + result = self._connection.execute_powershell( + f"(Get-Item -Path {path}).Length", custom_exception=HyperVExecutionException + ) + logger.log( + level=log_levels.MODULE_DEBUG, msg=f"File {path} is ({round(int(result.stdout) / 1_000_000_000_000, 2)}GB)" + ) + return int(result.stdout) diff --git a/mfd_hyperv/instances/__init__.py b/mfd_hyperv/instances/__init__.py new file mode 100644 index 0000000..b31fcfa --- /dev/null +++ b/mfd_hyperv/instances/__init__.py @@ -0,0 +1,3 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Classes that represent object created by Hyper-V entities.""" diff --git a/mfd_hyperv/instances/vm.py b/mfd_hyperv/instances/vm.py new file mode 100644 index 0000000..e17a7fe --- /dev/null +++ b/mfd_hyperv/instances/vm.py @@ -0,0 +1,227 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Vm class.""" +import logging +from dataclasses import asdict +from time import sleep +from typing import Dict, Union, List, TYPE_CHECKING + +from mfd_common_libs import add_logging_level, log_levels +from mfd_connect import RPyCConnection, Connection +from mfd_hyperv.attributes.vm_params import VMParams +from mfd_hyperv.exceptions import HyperVException +from mfd_hyperv.hypervisor import VMProcessorAttributes +from mfd_network_adapter import NetworkAdapterOwner +from mfd_typing import MACAddress +from mfd_typing.network_interface import InterfaceType + +if TYPE_CHECKING: + from mfd_hyperv import HyperV + from mfd_hyperv.instances.vm_network_interface import VMNetworkInterface + + +logger = logging.getLogger(__name__) +add_logging_level(level_name="MODULE_DEBUG", level_value=log_levels.MODULE_DEBUG) + + +class VM: + """VM class.""" + + def __init__( + self, + connection: "Connection", + vm_params: VMParams, + owner: NetworkAdapterOwner = None, + hyperv: "HyperV" = None, + connection_timeout: int = None, + ): + """VM constructor.""" + self.connection = connection + self.guest = NetworkAdapterOwner(connection=connection) + self.attributes = {} + self.owner = owner + self._hyperv = None + self.hyperv = hyperv + + self.connection_timeout = connection_timeout + self._propagate_params(vm_params) + + def __str__(self) -> str: + return f"{self.name}" + + @property + def hyperv(self) -> "HyperV": + """Hyperv property representing host's hyperv object. + + :raises: HyperVException when this property is empty + """ + if not self._hyperv: + raise HyperVException( + "VM has no access to HyperV object and cannot call this method. Create binding between objects" + ) + return self._hyperv + + @property + def interfaces(self) -> List["VMNetworkInterface"]: + """Hyperv property representing VM interfaces. + + :raises: HyperVException when this property is empty + """ + if not self._hyperv: + raise HyperVException( + "VM has no access to HyperV object and cannot call this method. Create binding between objects" + ) + return [nic for nic in self._hyperv.vm_network_interface_manager.vm_interfaces if nic.vm == self] + + @hyperv.setter + def hyperv(self, value: "HyperV") -> None: + """Hyperv property setter.""" + self._hyperv = value + + def _propagate_params(self, params: VMParams) -> None: + """Add VMParams as attributes of virtual machine object.""" + for key, value in asdict(params).items(): + setattr(self, key, value) + + def get_attributes(self) -> Dict[str, str]: + """Get Virtual machine attributes from host (hypervisor).""" + self.attributes = self.hyperv.hypervisor.get_vm_attributes(self.name) + return self.attributes + + def start(self, timeout: int = 300) -> None: + """Start VM from host (hypervisor) and wait for it to be functional. + + :param timeout: time given for VM to reach functional state + """ + self.hyperv.hypervisor.start_vm(self.name) + self.hyperv.hypervisor.wait_vm_functional(self.name, self.mng_ip, timeout) + self.connection = RPyCConnection(self.mng_ip, connection_timeout=self.connection_timeout) + + def stop(self, timeout: int = 300) -> None: + """Stop VM from host (hypervisor). + + :param timeout: time given for VM to reach "not running" state + """ + try: + self.connection.shutdown_platform() + self.hyperv.hypervisor.wait_vm_stopped(self.name, timeout) + sleep(timeout / 20) + except EOFError: + pass + + def restart(self, timeout: int = 300) -> None: + """Restart VM by stopping and starting again. + + :param timeout: time given for VM to reach functional state + """ + self.stop(timeout) + self.start(timeout) + + def reboot(self, timeout: int = 300) -> None: + """Reboot from guest and wait for it to be functional. + + :param timeout: time given for VM to reach functional state + """ + try: + self.connection.restart_platform() + sleep(timeout / 20) + except EOFError: + pass + self.wait_functional(timeout) + + def wait_functional(self, timeout: int = 300) -> None: + """Wait untill this VM can be pinged. + + :param timeout: time given for VM to reach functional state + """ + self.hyperv.hypervisor.wait_vm_functional(self.name, self.mng_ip, timeout) + + def get_vm_interfaces(self) -> Dict[str, str]: + """Return dictionary of VM Network interfaces.""" + return self.hyperv.vm_network_interface_manager.get_vm_interfaces(self.name) + + def get_processor_attributes(self) -> Dict[str, str]: + """Return dictionary of VMProcessorAttributes.""" + return self.hyperv.vm_network_interface_manager.get_vm_interfaces(self.name) + + def set_processor_attributes( + self, attribute: Union[VMProcessorAttributes, str], value: Union[str, int, bool] + ) -> None: + """Set specified VMProcessorAttributes to specified value.""" + if self.get_attributes()["State"] == "Off": + self.hyperv.vm_network_interface_manager.get_vm_interfaces(self.name, attribute, value) + return + + self.stop() + self.hyperv.vm_network_interface_manager.set_vm_processor_attribute(self.name, attribute, value) + self.start() + + def _get_ifaces_from_vm(self) -> Dict[str, str]: + """Get interfaces from VM. + + Use cached data if available. + """ + if self.hyperv.vm_network_interface_manager.all_vnics_attributes.get(self.name) is not None: + logger.log(level=log_levels.MODULE_DEBUG, msg="Getting cached interfaces") + return self.hyperv.vm_network_interface_manager.all_vnics_attributes[self.name] + else: + logger.log(level=log_levels.MODULE_DEBUG, msg="Retrieving Vm interfaces seen from Hypervisor") + return self.get_vm_interfaces() + + def _check_vnic_correct_matching(self, created_vm_interfaces: List["VMNetworkInterface"]) -> None: + """Check if matching was successful, if not raise Exception. + + :param created_vm_interfaces: list of vnics after matching VM interfaces witt VM Guest OS interfaces + """ + all_have_iface = all(hasattr(vnic, "interface") for vnic in created_vm_interfaces) + if not all_have_iface: + raise Exception(f"VM {self} nics couldn't be matched with interfaces on VM guest os") + sriov_have_vf = all(hasattr(vnic.interface, "vf") for vnic in created_vm_interfaces if vnic.sriov) + if not sriov_have_vf: + logger.warning("VM interfaces that were not matched with Virtual Function interface") + for iface in [nic.interface for nic in created_vm_interfaces if nic.sriov]: + logger.log(level=log_levels.MODULE_DEBUG, msg=iface.name) + raise Exception(f"VM {self} SRIOV nics couldn't be matched with Virtual Functions nics on VM guest os.") + + def match_interfaces(self) -> List["VMNetworkInterface"]: + """Match vm interfaces with interfaces seen from host. + + In addition, match virtual functions with normal adapters. + """ + logger.log(level=log_levels.MODULE_DEBUG, msg="Get VM interfaces seen from Hypervisor") + from_host_vm_interfaces = self._get_ifaces_from_vm() + logger.log(level=log_levels.MODULE_DEBUG, msg="Get interfaces seen from VM guest") + from_vm_interfaces = self.guest.get_interfaces() + created_vm_interfaces = [ + iface for iface in self.hyperv.vm_network_interface_manager.vm_interfaces if iface.vm == self + ] + + # matching will be performed using macaddress + for iface in created_vm_interfaces: + for vm_iface_info in from_host_vm_interfaces: + if iface.interface_name.lower() == vm_iface_info["name"]: + iface.mac = MACAddress(vm_iface_info["macaddress"]) + + for iface in created_vm_interfaces: + for vm_iface in from_vm_interfaces: + # match from-host and from-vm interfaces + if iface.mac == vm_iface.mac_address and vm_iface.interface_type in [ + InterfaceType.VMNIC, + InterfaceType.VMBUS, + ]: + iface.interface = vm_iface + vm_iface.owner = iface.vm.guest + + # match Vfs + for other_vm_iface in from_vm_interfaces: + if vm_iface == other_vm_iface: + continue + if ( + vm_iface.mac_address == other_vm_iface.mac_address + and other_vm_iface.interface_type == InterfaceType.VF + ): + vm_iface.vf = other_vm_iface + vm_iface.vf.owner = iface.vm.guest + + self._check_vnic_correct_matching(created_vm_interfaces) + return created_vm_interfaces diff --git a/mfd_hyperv/instances/vm_network_interface.py b/mfd_hyperv/instances/vm_network_interface.py new file mode 100644 index 0000000..1e10bdc --- /dev/null +++ b/mfd_hyperv/instances/vm_network_interface.py @@ -0,0 +1,206 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""VMNetworkInterface class.""" +import time +from typing import Union, Dict, Optional + +from mfd_connect import Connection + +from mfd_hyperv.attributes.vm_network_interface_attributes import VMNetworkInterfaceAttributes +from mfd_hyperv.exceptions import HyperVException +from mfd_hyperv.helpers import standardise_value +from mfd_hyperv.instances.vm import VM +from mfd_hyperv.instances.vswitch import VSwitch + + +class VMNetworkInterface: + """VMNetworkInterface class.""" + + def __init__( + self, + interface_name: str, + vm_name: str, + vswitch_name: str, + sriov: bool = False, + vmq: bool = True, + connection: Optional["Connection"] = None, + vm: VM = None, + vswitch: VSwitch = None, + ): + """Virtual Machine Interface constructor. + + Representation of VM Interface seen from Hypervisor. + """ + self.interface_name = interface_name # seen from hypervisor + self.vm_name = vm_name + self.vswitch_name = vswitch_name + self.sriov = sriov + self.vmq = vmq + self.connection = connection # connection from host + + self._vm = None + self.vm = vm + self._vswitch = None + self.vswitch = vswitch + self.interface = None # interface seen from guest + self.attributes = None + self.vlan_id = None + self.rdma_enabled = None + + def __str__(self): + vf_name = "" + if self.sriov: + vf_name = f" / VF: {self.interface.vf.name}" + return f"{self.interface_name} ({self.interface.name}{vf_name})" + + @property + def vswitch(self) -> VSwitch: + """Vswitch property. + + :raises: HyperVException when this property is empty + """ + if not self._vswitch: + raise HyperVException( + "VM Network Interface has no access to its vswitch objects and cannot call its method." + "Create binding between objects." + ) + return self._vswitch + + @vswitch.setter + def vswitch(self, value: VSwitch) -> None: + """Vswitch property setter.""" + self._vswitch = value + + @property + def vm(self) -> VM: + """VM property. + + :raises: HyperVException when this property is empty + """ + if not self._vm: + raise HyperVException( + "VM Network Interface has no access to VM object and cannot call its methods." + "Create binding between objects." + ) + return self._vm + + @vm.setter + def vm(self, value: VM) -> None: + """VM property setter.""" + self._vm = value + + def set_and_verify_attribute( + self, + attribute: Union[VMNetworkInterfaceAttributes, str], + value: Union[str, int, bool], + sleep_duration: int = 1, + ) -> bool: + """Set specified vm interface attribute to specified value and check if results where applied in the OS. + + :param attribute: attribute to set + :param value: new value + :param sleep_duration: sleep_duration between setting value and reading it + """ + self.vm.hyperv.vm_network_interface_manager.set_vm_interface_attribute( + self.interface_name, self.vm.name, attribute, value + ) + time.sleep(sleep_duration) + + vm_nics_attrs = self.vm.hyperv.vm_network_interface_manager.get_vm_interface_attributes(self.vm.name) + read_value = next(item for item in vm_nics_attrs if item["name"] == self.interface_name.lower())[attribute] + return standardise_value(value) == standardise_value(read_value) + + def get_attributes(self, refresh_data: bool = False) -> Dict[str, Dict[str, str]]: + """Return VM Network Interface attributes in form of dictionary. + + :return: dictionary with VM Network Interface attributes + """ + if refresh_data or self.vm.name not in self.vm.hyperv.vm_network_interface_manager.all_vnics_attributes: + self.vm.hyperv.vm_network_interface_manager.all_vnics_attributes[self.vm.name] = ( + self.vm.hyperv.vm_network_interface_manager.get_vm_interface_attributes(self.vm.name) + ) + self.attributes = next( + item + for item in self.vm.hyperv.vm_network_interface_manager.all_vnics_attributes[self.vm.name] + if item["name"] == self.interface_name.lower() + ) + return self.attributes + + def disconnect_from_vswitch(self) -> None: + """Disconnect from vswitch.""" + if not self.vswitch: + return + self.vm.hyperv.vm_network_interface_manager.disconnect_vm_interface(self.interface_name, self.vm.name) + self.vswitch = None + self.vswitch_name = None + + def connect_to_vswitch(self, vswitch: VSwitch) -> None: + """Connect vm network Interface to virtual switch. + + :param vswitch: virtual switch that vm adapter will be connected to + """ + self.vm.hyperv.vm_network_interface_manager.connect_vm_interface( + self.interface_name, self.vm.name, vswitch.interface_name + ) + self.vswitch = vswitch + self.vswitch_name = vswitch.interface_name + + def remove(self) -> None: + """Remove vm network interface.""" + self.vm.hyperv.vm_network_interface_manager.remove_vm_interface(self.interface_name, self.vm.name) + + def get_vlan_info(self) -> Dict[str, str]: + """Get information about VM adapter VLAN.""" + return self.vm.hyperv.vm_network_interface_manager.get_vm_interface_vlan( + vm_name=self.vm.name, interface_name=self.interface_name + ) + + def get_vlan_id(self) -> str: + """Get adapter VLAN ID.""" + return self.get_vlan_info()["AccessVlanId"] + + def set_vlan( + self, + state: str, + vlan_type: str, + vlan_id: Union[str, int], + ) -> None: + """Set VLAN on VM nadapter. + + :param state: one of ["access", "trunk", "promiscuous", "isolated", "untagged"] + :param vlan_type one of ["vlanid", "nativevlanid", "primaryvlanid"] + :param vlan_id: VLAN ID + """ + self.vm.hyperv.vm_network_interface_manager.set_vm_interface_vlan( + state=state, vm_name=self.vm.name, interface_name=self.interface_name, vlan_type=vlan_type, vlan_id=vlan_id + ) + self.vlan_id = int(vlan_id) + + def get_rdma_status(self) -> bool: + """Get adapter RDMA ststus.""" + return self.get_rdma_info()["RdmaWeight"] == "100" + + def get_rdma_info(self) -> Dict[str, str]: + """Get information about VM adapter RDMA.""" + return self.vm.hyperv.vm_network_interface_manager.get_vm_interface_rdma( + vm_name=self.vm.name, interface_name=self.interface_name + ) + + def set_rdma( + self, + state: bool, + ) -> None: + """Set RDMA on VM nic. + + :param state: enabled or disabled + """ + self.vm.stop() + + self.vm.hyperv.vm_network_interface_manager.set_vm_interface_rdma( + vm_name=self.vm.name, + interface_name=self.interface_name, + state=state, + ) + self.rdma_enabled = state + + self.vm.start() diff --git a/mfd_hyperv/instances/vswitch.py b/mfd_hyperv/instances/vswitch.py new file mode 100644 index 0000000..2ea4a68 --- /dev/null +++ b/mfd_hyperv/instances/vswitch.py @@ -0,0 +1,144 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Vswitch class.""" +import time +from typing import Union, Dict, List, Optional + +from mfd_connect import Connection +from mfd_network_adapter import NetworkInterface +from mfd_network_adapter.network_adapter_owner.exceptions import NetworkAdapterIncorrectData +from mfd_network_adapter.network_interface.windows import WindowsNetworkInterface + +from mfd_hyperv.attributes.vswitchattributes import VSwitchAttributes +from mfd_hyperv.exceptions import HyperVException +from mfd_hyperv.helpers import standardise_value + + +class VSwitch: + """VSwitch class. + + Hyper-V vSwitch has different names in the OS. + + interface_name - name of VSwitch in Hyper-V + name - name of adapter seen by OS + """ + + def __init__( + self, + interface_name: str, + host_adapter_names: List, + enable_iov: bool = False, + enable_teaming: bool = False, + connection: Optional["Connection"] = None, + host_adapters: Optional[List[NetworkInterface]] = None, + ): + """Class constructor. + + :param interface_name: name of vswitch seen by hyperv + :param host_adapter_names: names of interfaces that vswitch is created on (1 if normal vswitch, many if teaming) + :param enable_iov: is vswitch Sriov enabled + :param enable_teaming: is teaming enabled (in case of multiple ports) + :param connection: connection instance of MFD connect class. + :params host_adapters: adapters from config that vswitch is attached to. + """ + self.interface_name = interface_name + self.host_adapter_names = host_adapter_names + self.name = f"vEthernet ({interface_name})" + self.enable_iov = enable_iov + self.enable_teaming = enable_teaming + self.connection = connection + + self.attributes = None + self._interfaces = None + self.interfaces = host_adapters # list of interfaces seen from host that vswitch is created on + self.interface = None # vswitch seen as interface from host + self.owner = None + + if host_adapters: + self.interfaces_binding() + time.sleep(3) + + adapter_absent = True + while adapter_absent: + try: + self.interface = self.owner.get_interface(interface_name=self.name) + adapter_absent = False + except NetworkAdapterIncorrectData: + pass + + def __str__(self): + return f"{self.interface_name} ({[iface.name for iface in self.interfaces]})" + + @property + def interfaces(self) -> Optional[List[WindowsNetworkInterface]]: + """Interfaces property representing list of interfaces that vswitch is created on. + + :raises: HyperVException when this property is empty + """ + if not self._interfaces: + raise HyperVException( + "VSwitch has no access to interfaces it is created with. Create binding between objects" + ) + return self._interfaces + + @interfaces.setter + def interfaces(self, value: Optional[List[WindowsNetworkInterface]]) -> None: + """Interfaces property setter.""" + self._interfaces = value + + def interfaces_binding(self) -> None: + """Create bindings between vswitch and network interfaces objects.""" + for interface in self.interfaces: + interface.vswitch = self + self.owner = self.interfaces[0].owner + + def get_attributes(self) -> Dict[str, str]: + """Return vSwitch attributes in form of dictionary. + + :return: dictionary with vswitch attributes + """ + self.attributes = self.owner.hyperv.vswitch_manager.get_vswitch_attributes(self.interface_name) + return self.attributes + + def set_and_verify_attribute( + self, attribute: Union[VSwitchAttributes, str], value: Union[str, int, bool], sleep_duration: int = 1 + ) -> bool: + """Set specified vswitch attribute to specified value and check if results where applied in the OS. + + :param attribute: attribute to set + :param value: new value + :param sleep_duration: sleep_duration between setting value and reading it + :returns: whether the set value was also read after it was set + """ + # some attributes are responsible for 1 functionality but 2 different names are used for setting and getting + # keys are attributes used for setting + # values are attributes that are used for getting + mapping = {"enablerscoffload": "rscoffloadenabled", "enablesoftwarersc": "softwarerscenabled"} + + self.owner.hyperv.vswitch_manager.set_vswitch_attribute(self.interface_name, attribute, value) + time.sleep(sleep_duration) + read_value = self.owner.hyperv.vswitch_manager.get_vswitch_attributes(self.interface_name)[ + mapping.get(attribute, attribute) + ] + return standardise_value(value) == standardise_value(read_value) + + def remove(self) -> None: + """Remove vswitch identified by its 'interface_name'.""" + for vnic in self.owner.hyperv.vm_network_interface_manager.vm_interfaces: + if vnic._vswitch == self: + vnic.disconnect_from_vswitch() + + self.owner.hyperv.vswitch_manager.remove_vswitch(self.interface_name) + if self.interfaces: + for interface in self.interfaces: + interface.vswitch = None + + def rename(self, new_name: str) -> None: + """Rename vswitch with a specified name. + + :param new_name: new vSwitch name + """ + self.owner.hyperv.vswitch_manager.rename_vswitch(self.interface_name, new_name) + + self.interface_name = new_name + self.name = f"vEthernet ({new_name})" diff --git a/mfd_hyperv/vm_network_interface_manager.py b/mfd_hyperv/vm_network_interface_manager.py new file mode 100644 index 0000000..a016b9f --- /dev/null +++ b/mfd_hyperv/vm_network_interface_manager.py @@ -0,0 +1,420 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Module for Hyper-V VMNetworkInterfaceManager.""" +import logging +from typing import TYPE_CHECKING, Union, List, Dict, Optional + +from mfd_common_libs import os_supported, add_logging_level, log_levels +from mfd_connect.util.powershell_utils import parse_powershell_list +from mfd_typing import OSName + +from mfd_hyperv.attributes.vm_network_interface_attributes import VMNetworkInterfaceAttributes +from mfd_hyperv.exceptions import HyperVException, HyperVExecutionException +from mfd_hyperv.instances.vm import VM +from mfd_hyperv.instances.vm_network_interface import VMNetworkInterface +from mfd_hyperv.instances.vswitch import VSwitch + +if TYPE_CHECKING: + from mfd_connect import Connection + +logger = logging.getLogger(__name__) +add_logging_level(level_name="MODULE_DEBUG", level_value=log_levels.MODULE_DEBUG) + +UNTAGGED_VLAN = 0 + + +class VMNetworkInterfaceManager: + """Module for VMNetworkInterfaceManager. + + Wrapper for Powershell cmdlets for operations managing VM adapters from hypervisor host. + """ + + @os_supported(OSName.WINDOWS) + def __init__(self, connection: "Connection"): + """Class constructor. + + :param connection: connection instance of MFD connect class. + """ + self.connection = connection + self.vm_interfaces = [] + self.vm_adapter_name_counter = 1 + + self.all_vnics_attributes = {} + + def create_vm_network_interface( + self, + vm_name: str | None = None, + vswitch_name: str | None = None, + sriov: bool = False, + vmq: bool = True, + get_attributes: bool = False, + vm: VM | None = None, + vswitch: VSwitch | None = None, + ) -> VMNetworkInterface: + """Add network interface to VM or Host OS. + + :param vm_name: name of Virtual Machine this adapter belongs to (None for Host OS) + :param vswitch_name: name of vSwitch that this adapter is connected to + :param sriov: Decided whether adapter should use SRIOV + :param vmq: Decided whether adapter should use VMQ + :param get_attributes: retrieve VM interface attributes right after creating it + :param vm: Virtual machine that VM network interface will be connected to + :param vswitch: Virtual switch that VM network interface will be connected to + :raises: HyperVException when VM adapter cannot be added to specified VM or Host OS + """ + vnic_name = self._generate_name(vm_name if vm_name else "host") + logger.log( + level=log_levels.MODULE_DEBUG, + msg=( + f"Adding VMNetworkAdapter {vnic_name} to " + f"{'Host OS' if vm_name is None else f'VM {vm_name}'}. " + f"VMQ = {vmq}, SRIOV = {sriov}" + ), + ) + vswitch_info = f'-SwitchName "{vswitch_name}"' if vswitch_name else "" + cmd = "-ManagementOS" if vm_name is None else f'-VMName "{vm_name}"' + command = f'Add-VMNetworkAdapter {vswitch_info} {cmd} -Name "{vnic_name}"' + + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + + if result.return_code: + raise HyperVException( + f"Couldn't add VM adapter {vnic_name} to {'Host OS' if vm_name is None else f'VM {vm_name}'}" + ) + + vm_interface = VMNetworkInterface(vnic_name, vm_name, vswitch_name, sriov, vmq, self.connection, vm, vswitch) + + sriov_value = 100 if sriov else 0 + vmq_value = 100 if vmq else 0 + self.set_vm_interface_attribute(vnic_name, vm_name, VMNetworkInterfaceAttributes.IovWeight, sriov_value) + self.set_vm_interface_attribute(vnic_name, vm_name, VMNetworkInterfaceAttributes.VmqWeight, vmq_value) + if get_attributes: + vm_interface.get_attributes(True) + self.vm_interfaces.append(vm_interface) + + return vm_interface + + def remove_vm_interface( + self, + vm_interface_name: str, + vm_name: str, + ) -> None: + """Remove network interface from VM. + + :param vm_interface_name: VM adapter which will be removed + :param vm_name: VM that owns adapter + :raises: HyperVException when VM adapter cannot be removed + """ + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Remove VMNetworkAdapter {vm_interface_name} of VM {vm_name}") + + command = f'Remove-VMNetworkAdapter -VMName {vm_name} -Name "{vm_interface_name}"' + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException(f"Couldn't remove VM {vm_name} adapter {vm_interface_name}") + + vm_interface = [nic for nic in self.vm_interfaces if nic.interface_name == vm_interface_name] + if vm_interface: + self.vm_interfaces.remove(vm_interface[0]) + + def connect_vm_interface( + self, + vm_interface_name: str, + vm_name: str, + vswitch_name: str, + ) -> None: + """Connect vm adapter to virtual switch. + + :param vm_interface_name: str + :param vm_name: str + :param vswitch_name + :raises: HyperVException when VM adapter cannot be connected to specified vswitch + """ + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"Connecting VM {vm_name} adapter {vm_interface_name} to VMSwitch {vswitch_name}", + ) + + command = ( + f"Connect-VMNetworkAdapter" + f" -VMName {vm_name}" + f' -Name "{vm_interface_name}"' + f' -SwitchName "*{vswitch_name}*"' + ) + + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException( + f"Couldn't connect VM {vm_name} adapter {vm_interface_name} to VMSwitch {vswitch_name}" + ) + + def disconnect_vm_interface(self, vm_interface_name: str, vm_name: str) -> None: + """Disconnect VM Network Interface from vswitch. + + :param vm_interface_name: Virtual Machine Network Interface + :param vm_name: name of Virtual Machine + :raises: HyperVException when VM adapter cannot be disconnected from specified vswitch + """ + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Disconnecting VM {vm_name} adapter {vm_interface_name}") + + command = f"Disconnect-VMNetworkAdapter -VMName {vm_name} -Name {vm_interface_name}" + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException(f"Couldn't disconnect VM {vm_name} adapter {vm_interface_name}") + + def get_vm_interface_vlan(self, vm_name: str, interface_name: str) -> Dict[str, str]: + """Get VLAN settings for the traffic through a virtual network adapter. + + :param vm_name: name of VM + :param interface_name: name of VM network seen from hypervisor" + """ + command = f"Get-VMNetworkAdapterVlan -vmname {vm_name} -VMNetworkAdapterName {interface_name} | select * | fl" + + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException("Couldn't get VMNetworkAdapterVlan.") + + return parse_powershell_list(result.stdout)[0] + + def set_vm_interface_vlan( + self, + state: str, + vm_name: Optional[str] = None, + interface_name: Optional[str] = None, + vlan_type: Optional[str] = None, + vlan_id: Optional[Union[str, int]] = None, + management_os: bool = False, + ) -> None: + """Configure the VLAN settings for the traffic through a virtual network adapter. + + :param state: one of ["access", "trunk", "promiscuous", "isolated", "untagged"] + :param vm_name: name of VM + :param interface_name: name of VM network seen from hypervisor" + :param vlan_type one of ["vlanid", "nativevlanid", "primaryvlanid"] + :param vlan_id: VLAN Id + :param management_os whether to apply settings on virtual switch in the management OS + """ + assert state.lower() in ["access", "trunk", "promiscuous", "isolated", "untagged"] + + command = "Set-VMNetworkAdapterVlan " + if management_os: + command += "-ManagementOS " + if interface_name and vm_name: + command += f"-VMName {vm_name} -VMNetworkAdapterName {interface_name} " + + command += f"-{state} " + + if vlan_type and vlan_id: + assert vlan_type.lower() in ["vlanid", "nativevlanid", "primaryvlanid"] + command += f"-{vlan_type} {vlan_id}" + + logger.log( + level=log_levels.MODULE_DEBUG, + msg="Setting VM adapter VLAN", + ) + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException("Couldn't set VMNetworkAdapterVlan.") + + def get_vm_interface_rdma(self, vm_name: str, interface_name: str) -> Dict[str, str]: + """Get RDMA settings for VM network adapter. + + :param vm_name: name of VM + :param interface_name: name of VM network seen from hypervisor" + """ + command = f"Get-VMNetworkAdapterRDMA -vmname {vm_name} -VMNetworkAdapterName {interface_name} | select * | fl" + + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException("Couldn't get VMNetworkAdapterRDMA.") + + return parse_powershell_list(result.stdout)[0] + + def set_vm_interface_rdma(self, vm_name: str, interface_name: str, state: bool) -> None: + """Set RDMA on VM nic. + + :param vm_name: VM name + :param interface_name: name of VM nic seen by hypervisor + :param state: expected state of RDMA after operation succeeds + """ + rdma_weight = "100" if state else "0" + command = f'Set-VMNetworkAdapterRDMA -VMName "{vm_name}" -Name "{interface_name}" -RdmaWeight {rdma_weight}' + + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"Set RDMA state to {state} on vnic {interface_name} of VM {vm_name}", + ) + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException(f"Couldn't set RDMA state to {state} on vnic {interface_name} of VM {vm_name}") + + def set_vm_interface_attribute( + self, + vm_interface_name: str, + vm_name: Union[str, None], + attribute: Union[VMNetworkInterfaceAttributes, str], + value: Union[str, int], + ) -> None: + """Set attribute on VM (-VMName ) or Host (-ManagementOS case) adapter. + + :param vm_interface_name: Virtual Machine Network Interface + :param vm_name: name of Virtual Machine + :param attribute: Name changed attribute + :param value: new value of changed attribute + :raises: HyperVException when VM network adapter attributes cannot be set + """ + if isinstance(attribute, VMNetworkInterfaceAttributes): + attribute = attribute.value + + logger.log( + level=log_levels.MODULE_DEBUG, + msg=f"Setting adapter: {vm_interface_name} attribute: {attribute} to: {value}.", + ) + cmd = f"-VMName {vm_name}" if vm_name is not None else "-ManagementOS" + command = f'Set-VMNetworkAdapter -Name "{vm_interface_name}" {cmd} -{attribute} {value}' + + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException(f"Couldn't set VM: '{vm_name}' adapter attribute: '{attribute}' to '{value}'.") + + def clear_vm_interface_attributes_cache(self, vm_name: str = None) -> None: + """Clear cached VM nics attributes information of specified VM. + + :param vm_name: name of vm which vnics will have information about their attributes cleared + """ + if vm_name and vm_name in self.all_vnics_attributes: + self.all_vnics_attributes[vm_name] = {} + else: + self.all_vnics_attributes = {} + + def get_vm_interface_attributes(self, vm_name: str) -> Dict[str, str]: + """Get attributes of all VM network interface. + + :param vm_name: name of Virtual Machine name + :raises: HyperVException when vm network adapter attributes cannot be retrieved + """ + logger.log(level=log_levels.MODULE_DEBUG, msg="Getting VM adapter attributes") + + command = f"Get-VMNetworkAdapter -Name * -VMName {vm_name} | select * | fl" + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException(f"Couldn't get VM {vm_name} adapter attributes") + + self.all_vnics_attributes[vm_name] = parse_powershell_list(result.stdout.lower()) + return self.all_vnics_attributes[vm_name] + + def get_vm_interfaces(self, vm_name: str) -> List[Dict[str, str]]: + """Return dictionary of VM Network interfaces. + + :params vm_name: Name of VM + :raises: HyperVException when information about VM adapters cannot be retrieved + :return: list of dictionaries with information about each VM adapter + """ + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Get VM adapters of VM {vm_name}") + + command = f"Get-VMNetworkAdapter -VMName {vm_name} | select * | fl" + + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException(f"Couldn't get information about VM adapters of VM {vm_name}") + + return parse_powershell_list(result.stdout.lower()) + + def _generate_name(self, vm_name: str) -> str: + """Create unified vn adapter interface name. + + :param vm_name: name of Virtual Machine adapter belongs to + """ + vm_str = vm_name.split("_")[-1] + name = f"{vm_str}_vnic_{self.vm_adapter_name_counter:03}" + self.vm_adapter_name_counter += 1 + return name + + def get_adapters_vf_datapath_active(self) -> bool: + """Return Vfdatapathactive status of all VM adapters. + + raises: HyperVException if couldn't get VM nics VfDatapathActive + """ + command = ( + "get-vmnetworkadapter -VMName * | Where-Object IovWeight -EQ '100'" + " | select -ExpandProperty Vfdatapathactive" + ) + + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException("Couldn't get VM nics VfDatapathActive") + + results = [item for item in result.stdout.replace(" ", "").splitlines() if item] + return all(item == "True" for item in results) + + def get_vm_interface_attached_to_vswitch(self, vswitch_name: str) -> str: + """Get the VMNetworkAdapter name that is attached to the vswitch. + + :param vswitch_name: name of vswitch interface + :raises: HyperVExecutionException on any Powershell command execution error + :return: name of interfaces attached to the vswitch_name + """ + return self.connection.execute_powershell( + f"(Get-VMNetworkAdapter -ManagementOS | ? {{ $_.SwitchName -eq '{vswitch_name}'}}).Name", + custom_exception=HyperVExecutionException, + ).stdout.strip() + + def get_vlan_id_for_vswitch(self, vswitch_name: str) -> int: + """Get VLAN tagging set for Hyper-V vSwitch. Only access and untagged modes are supported at this point. + + :param vswitch_name: vswitch adapter object + :raises: HyperVExecutionException on any Powershell command execution error + :return: vlan number. 0 if untagged + """ + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Get VLAN ID for vswitch ({vswitch_name})...") + vmnetwork_adapter_name = self.get_vm_interface_attached_to_vswitch(vswitch_name=vswitch_name) + settings = self.connection.execute_powershell( + f'Get-VMNetworkAdapterVlan -ManagementOS -VMNetworkAdapterName "{vmnetwork_adapter_name}"', + custom_exception=HyperVExecutionException, + ).stdout + settings = parse_powershell_list(settings) + if not settings: + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Could not get VLAN settings for {vswitch_name}") + # UNTAGGED_VLAN is a safe guess in this situation - if it's impossible to get the VLAN setting, + # adapter probably doesn't support VLANs or sth is seriously wrong with the OS configuration + return UNTAGGED_VLAN + operation_mode = settings[0]["OperationMode"] + + if operation_mode == "Untagged": + return UNTAGGED_VLAN + elif operation_mode == "Access": + return int(settings[0]["AccessVlanId"]) + + logger.log(level=log_levels.MODULE_DEBUG, msg=f"Unsupported VLAN mode ({operation_mode}) detected") + return UNTAGGED_VLAN + + def get_host_os_interfaces(self) -> list[dict[str, str]]: + """Return dictionary of Host OS Network interfaces. + + :raises: HyperVException when information about Host OS adapters cannot be retrieved + :return: list of dictionaries with information about each Host OS adapter + """ + logger.log(level=log_levels.MODULE_DEBUG, msg="Get Host OS adapters") + + command = "Get-VMNetworkAdapter -ManagementOS | select * | fl" + + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException("Couldn't get information about Host OS adapters") + + return parse_powershell_list(result.stdout.lower()) + + def update_host_vnic_attributes(self, vnic_name: str) -> None: + """Update attributes of a Host OS virtual network interface. + + :param vnic_name: name of the Host OS virtual network interface + :raises: HyperVException when Host OS network adapter attributes cannot be retrieved + """ + logger.log(level=log_levels.MODULE_DEBUG, msg="Getting Host OS adapter attributes") + + command = f"Get-VMNetworkAdapter -ManagementOS -Name {vnic_name} | select * | fl" + result = self.connection.execute_powershell(command=command, expected_return_codes={}) + if result.return_code: + raise HyperVException(f"Couldn't get Host OS adapter attributes for vNIC {vnic_name}") + for vnic_interface in self.vm_interfaces: + if vnic_interface.interface_name == vnic_name: + vnic_interface.attributes = parse_powershell_list(result.stdout.lower())[0] diff --git a/mfd_hyperv/vswitch_manager.py b/mfd_hyperv/vswitch_manager.py new file mode 100644 index 0000000..ab33dd1 --- /dev/null +++ b/mfd_hyperv/vswitch_manager.py @@ -0,0 +1,258 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Main module.""" +import logging +import re +import time +from typing import TYPE_CHECKING, Union, Dict, List, Optional + +from mfd_common_libs import os_supported, add_logging_level, log_levels, TimeoutCounter +from mfd_common_libs.log_levels import MODULE_DEBUG +from mfd_connect.util.powershell_utils import parse_powershell_list +from mfd_network_adapter.network_interface.windows import WindowsNetworkInterface +from mfd_typing import OSName + +from mfd_hyperv.attributes.vswitchattributes import VSwitchAttributes +from mfd_hyperv.exceptions import HyperVExecutionException, HyperVException +from mfd_hyperv.helpers import standardise_value +from mfd_hyperv.instances.vswitch import VSwitch + +if TYPE_CHECKING: + from mfd_connect import Connection + +logger = logging.getLogger(__name__) +add_logging_level(level_name="MODULE_DEBUG", level_value=log_levels.MODULE_DEBUG) + + +class VSwitchManager: + """Module for VSwitch Manager.""" + + mng_vswitch_name = "managementvSwitch" + vswitch_name_prefix = "VSWITCH" + vswitch_name_counter = 1 + + @os_supported(OSName.WINDOWS) + def __init__(self, *, connection: "Connection"): + """Class constructor. + + :param connection: connection instance of MFD connect class. + """ + self.connection = connection + self.vswitches = [] + + def create_vswitch( + self, + interface_names: List[str], + vswitch_name: str = vswitch_name_prefix, + enable_iov: bool = False, + enable_teaming: bool = False, + mng: bool = False, + interfaces: Optional[List[WindowsNetworkInterface]] = None, + ) -> VSwitch: + """Create vSwitch. + + :param vswitch_name: Name of virtual switch + :param interface_names: list of names of network interface that vswitch will be created on + :param enable_iov: is vswitch Sriov enabled + :param enable_teaming: is teaming enabled (in case of multiple ports) + :param mng: whether this vswitch is mng or not + :param interfaces:interfaces objects that vswitch is connected to + :return: created vswitch + """ + interface_names = ", ".join([f"'{item}'" for item in interface_names]) + final_vswitch_name = vswitch_name if mng else self._generate_name(vswitch_name, enable_teaming) + + logger.log(level=MODULE_DEBUG, msg=f"Creating vSwitch {vswitch_name} on adapter {interface_names}") + cmd = ( + 'powershell.exe "New-VMSwitch' + f" -Name '{final_vswitch_name}' -NetAdapterName {interface_names} -AllowManagementOS $true" + f' -EnableIov ${enable_iov}"' + ) + if enable_teaming: + cmd = cmd[:-1] + cmd += ' -EnableEmbeddedTeaming $true"' + + self.connection.start_process(cmd, shell=True) + self.wait_vswitch_present(final_vswitch_name, timeout=120, interval=2) + + vs = VSwitch(final_vswitch_name, interface_names, enable_iov, enable_teaming, self.connection, interfaces) + + if not mng: + self.vswitches.append(vs) + return vs + + def _generate_name(self, vswitch_name: str, enable_teaming: bool) -> str: + """Create unified vswitch name. + + :param vswitch_name: name of Virtual Machine adapter belongs to + :param enable_teaming: if temaming is enabled + """ + name = f"{vswitch_name}_{self.vswitch_name_counter:02}{'_T' if enable_teaming else ''}" + self.vswitch_name_counter += 1 + return name + + def create_mng_vswitch(self) -> VSwitch: + """Create management vSwitch.""" + if self.is_vswitch_present(self.mng_vswitch_name): + return VSwitch( + interface_name=self.mng_vswitch_name, + host_adapter_names=[], + connection=self.connection, + ) + + cmd = ( + f"Get-NetIPAddress | Where-Object -Property IPAddress -EQ {self.connection._ip} | " + f"Where-Object -Property AddressFamily -EQ 'IPv4' | select -ExpandProperty InterfaceAlias" + ) + interface_name = self.connection.execute_powershell(cmd, expected_return_codes={0}).stdout.strip() + return self.create_vswitch([interface_name], self.mng_vswitch_name, mng=True) + + def remove_vswitch(self, interface_name: str) -> None: + """Remove vswitch identified by its 'interface_name'. + + :param interface_name: Virtual Switch interface name + """ + logger.log(level=MODULE_DEBUG, msg=f"Removing {interface_name}...") + self.connection.execute_powershell( + f"Remove-VMSwitch {interface_name} -Force", custom_exception=HyperVExecutionException + ) + logger.log(level=MODULE_DEBUG, msg=f"Successfully removed {interface_name}") + + # cleanup bindings + vswitch = [vs for vs in self.vswitches if vs.interface_name == interface_name] + if not vswitch: + return + + vswitch = vswitch[0] + self.vswitches.remove(vswitch) + + def get_vswitch_mapping(self) -> dict[str, str]: + """Get a list of Hyper-V vSwitches and the adapters they are mapped to. + + return: Dictionary where key are names of vswitches, values are Friendly names of an interfaces connect to + (NetAdapterInterfaceDescription field from powershell output) + """ + outcome = self.connection.execute_powershell("Get-VMSwitch | fl", expected_return_codes=None) + if outcome.return_code: + return {} + output = parse_powershell_list(outcome.stdout) + # SET vSwitch adapter, has NetAdapterInterfaceDescription = Teamed-Interface. + # But for mapping, there is a needed, that description have to be 1st pf member of the team, that is why, + # there is [0] element used. + for line in output: + if line["EmbeddedTeamingEnabled"] == "True": + line["NetAdapterInterfaceDescription"] = ( + line["NetAdapterInterfaceDescriptions"].replace("{", "").replace("}", "").split(",")[0] + ) + return dict((line["Name"], line["NetAdapterInterfaceDescription"]) for line in output) + + def get_vswitch_attributes(self, interface_name: str) -> Dict[str, str]: + """Return vSwitch attributes in form of dictionary. + + :param interface_name: Virtual Switch interface name + :raises: HyperVException when information about vswitch cannot be retrieved + :return: dictionary with vswitch attributes + """ + logger.log(level=MODULE_DEBUG, msg=f"Retrieving {interface_name} attributes...") + result = self.connection.execute_powershell( + f"Get-VMSwitch {interface_name} | select * | fl", expected_return_codes={} + ) + if result.return_code: + raise HyperVException(f"Couldn't get information about vSwitch {interface_name}") + + return parse_powershell_list(result.stdout.lower())[0] + + def set_vswitch_attribute( + self, interface_name: str, attribute: Union[VSwitchAttributes, str], value: Union[str, int, bool] + ) -> None: + """Set attribute on VSwitch. + + :param interface_name: Virtual Switch interface_name + :param attribute: Attribute to be set on vSwitch + :param value: Value of set attribute + :return: value of set attribute + """ + if isinstance(attribute, VSwitchAttributes): + attribute = attribute.value + + value = standardise_value(value) + logger.log(level=MODULE_DEBUG, msg=f"Setting new value {value} of {attribute} on {interface_name}") + + command = f"Set-VMSwitch -Name {interface_name} -{attribute} {value}" + self.connection.execute_powershell(command, custom_exception=HyperVExecutionException) + + def remove_tested_vswitches(self) -> None: + """Remove all tested vswitches.""" + logger.log(level=MODULE_DEBUG, msg=f"Removing all tested (non-{self.mng_vswitch_name}) vSwitches...") + + self.connection.execute_powershell( + "Get-VMSwitch | Where-Object {$_.Name -ne " + f'"{self.mng_vswitch_name}"' + "} | Remove-VMSwitch -force -Confirm:$false", + custom_exception=HyperVExecutionException, + ) + + logger.log(level=MODULE_DEBUG, msg="Successfully removed all tested vSwitches") + + # cleanup bindings + for vswitch in self.vswitches: + if not vswitch._interfaces: + continue + for interface in vswitch.interfaces: + interface.vswitch = None + self.vswitches.clear() + + def is_vswitch_present(self, interface_name: str) -> bool: + """Check if given virtual switch is present. + + :param interface_name: Virtual Switch interface_name + :return: whether vswitch is present or not + """ + logger.log(level=MODULE_DEBUG, msg=f"Checking if {interface_name} exists...") + out = self.connection.execute_powershell( + "Get-VMSwitch | select -expandproperty Name", custom_exception=HyperVExecutionException + ) + + result = re.findall(rf"\b{interface_name}\b", out.stdout) + return interface_name in result + + def wait_vswitch_present(self, vswitch_name: str, timeout: int = 60, interval: int = 10) -> None: + """Wait for timeout duration for vswitch to appear present. + + :param vswitch_name: Name of vSwitch + :param interval: sleep duration between retries + :param timeout: maximum time of waiting for vswitch to appear + :raises: HyperVException when specified vswitch is not present among other vswitches + :return: whether vswitch is present or not + """ + timeout_reached = TimeoutCounter(timeout) + while not timeout_reached: + try: + if self.is_vswitch_present(vswitch_name): + logger.log(level=MODULE_DEBUG, msg=f"Successfully created vSwitch {vswitch_name}") + return + except (EOFError, OSError): + logger.log(level=MODULE_DEBUG, msg=f"Waiting for vSwitch '{vswitch_name}' object.") + time.sleep(interval) + else: + raise HyperVException(f"Timeout expired. Cannot find vswitch {vswitch_name}") + + def rename_vswitch(self, interface_name: str, new_name: str) -> None: + """Rename given vSwitch. + + :param interface_name: Virtual Switch interface_name + :param new_name: new vSwitch interface_name + """ + logger.log(level=MODULE_DEBUG, msg=f"Renaming vSwitch {interface_name} to {new_name}...") + + self.connection.execute_powershell( + f'Rename-VMSwitch "{interface_name}" -NewName "{new_name}"', + custom_exception=HyperVExecutionException, + ) + + out = self.get_vswitch_attributes(interface_name=new_name) + + if out["name"] == new_name: + logger.log(level=MODULE_DEBUG, msg=f"Successfully renamed vSwitch {interface_name} to {new_name}") + else: + raise HyperVException(f"Could not rename vSwitch {interface_name} to {new_name}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..606296f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = [ + "setuptools>=80.4.0", + "wheel" +] +build-backend = "setuptools.build_meta" + +[tool.setuptools.dynamic] +dependencies = { file = ["requirements.txt"] } + +[project] +name = "mfd-hyperv" +description = "Module for handling functionalities of HyperV hypervisor." +requires-python = ">=3.10, <3.14" +version = "2.0.0" +dynamic = ["dependencies"] +license-files = ["LICENSE.md", "AUTHORS.md"] +readme = {file = "README.md", content-type = "text/markdown"} + +[project.urls] +Homepage = "https://github.com/intel/mfd" +Repository = "https://github.com/intel/mfd-hyperv" +Issues = "https://github.com/intel/mfd-hyperv/issues" +Changelog = "https://github.com/intel/mfd-hyperv/blob/main/CHANGELOG.md" + +[tool.setuptools.packages.find] +exclude = ["examples", "tests*", "sphinx-doc"] + +[tool.black] +line-length = 119 +exclude = ''' +( + /( + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + | examples + )/ + | setup.py +) +''' + +[tool.coverage.run] +source_pkgs = ["mfd_hyperv"] + +[tool.coverage.report] +exclude_also = ["if TYPE_CHECKING:"] \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..93a6fad --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,12 @@ +-r requirements-test.txt + +pydocstyle ~= 6.3.0 +flake8 +flake8-annotations +flake8-builtins +flake8-docstrings +black ~= 25.1.0 +flake8-black ~= 0.3.6 +click ~= 8.2.1 + +mfd-code-quality >= 1.2.0, < 2 \ No newline at end of file diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..3012976 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,3 @@ +-r requirements.txt +sphinx +sphinx_rtd_theme_github_versions \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..5d460f0 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,6 @@ +-r requirements.txt + +pytest ~= 8.4 +pytest-mock ~= 3.14 + +coverage ~= 7.3.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ef695bf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +mfd-common-libs >= 1.11.0, < 2 +mfd-typing >= 1.23.0, < 2 +mfd_connect >= 7.12.0, < 8 +mfd-ping >= 1.15.0, < 2 +mfd_network_adapter >= 14.0.0, < 15 \ No newline at end of file diff --git a/sphinx-doc/Makefile b/sphinx-doc/Makefile new file mode 100644 index 0000000..2c8e0e8 --- /dev/null +++ b/sphinx-doc/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = MFD-Hyperv +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/sphinx-doc/README.md b/sphinx-doc/README.md new file mode 100644 index 0000000..e694442 --- /dev/null +++ b/sphinx-doc/README.md @@ -0,0 +1,13 @@ +# MFD-HYPERV SPHINX DOCUMENTATION + +## HOW TO GENERATE DOCS +### 1. Download or use system embedded Python in version at least 3.7 +### 2. Create venv +- Create Python venv from MFD-Hyperv requirements for Sphinx (`/requirements-docs.txt`) +- Link how to do this: `https://python.land/virtual-environments/virtualenv` +### 3. In Activated venv go to MFD-Hyperv directory `/sphinx-doc` +### 4. Run command: +```shell +$ python generate_docs.py +``` +### 5. Open `/sphinx-doc/build/html/index.html` in Web browser to read documentation \ No newline at end of file diff --git a/sphinx-doc/conf.py b/sphinx-doc/conf.py new file mode 100644 index 0000000..1e207a0 --- /dev/null +++ b/sphinx-doc/conf.py @@ -0,0 +1,166 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT + +# MFD-Hyperv documentation build configuration file, created by +# sphinx-quickstart on Fri Nov 24 14:52:29 2017. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +"""Configure file for sphinx docs.""" + + +import os +import sys + +sys.path.append(os.path.abspath("..")) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.doctest", + "sphinx.ext.viewcode", + "sphinx.ext.autosummary", + "sphinx.ext.inheritance_diagram", +] + +autodoc_default_flags = ["members", "undoc-members", "private-members", "inherited-members", "show-inheritance"] +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = ".rst" + +# The master toctree document. +master_doc = "index" + +# General information about the project. +project = "MFD-Hyperv" +project_copyright = """Copyright (C) 2025 Intel Corporation +SPDX-License-Identifier: MIT""" +copyright = project_copyright # noqa +author = "Intel Corporation" + +# The full version, including alpha/beta/rc tags. +release = "" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = "en" + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +todo_include_todos = False + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme_github_versions" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +html_theme_options = { + "logo_only": False, + "display_version": True, + "prev_next_buttons_location": "bottom", + "style_external_links": False, + "vcs_pageview_mode": "", + # Toc options + "collapse_navigation": True, + "sticky_navigation": True, + "navigation_depth": 4, + "includehidden": True, + "titles_only": False, +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = "mfd-hyperv-doc" + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, "mfd-hyperv.tex", "MFD-Hyperv Documentation", "author", "manual"), +] + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [(master_doc, "mfd-hyperv", "MFD-Hyperv Documentation", [author], 1)] + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + master_doc, + "MFD-Hyperv", + "MFD-Hyperv Documentation", + author, + "MFD-Hyperv", + "One line description of project.", + "Miscellaneous", + ), +] diff --git a/sphinx-doc/generate_docs.py b/sphinx-doc/generate_docs.py new file mode 100644 index 0000000..f00e062 --- /dev/null +++ b/sphinx-doc/generate_docs.py @@ -0,0 +1,18 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Generate sphinx docs.""" + +import os +import shutil +import logging + +from sphinx.ext import apidoc +from sphinx.cmd import build + + +apidoc.main(["-e", "-o", "mfd_hyperv", os.path.join("..", "mfd_hyperv")]) + +build.main(["-b", "html", ".", "build/html"]) + +logging.info("Cleaning folders from build process...") +shutil.rmtree("mfd_hyperv") diff --git a/sphinx-doc/genindex.rst b/sphinx-doc/genindex.rst new file mode 100644 index 0000000..a50680d --- /dev/null +++ b/sphinx-doc/genindex.rst @@ -0,0 +1,4 @@ +.. This file is a placeholder and will be replaced + +Index +##### \ No newline at end of file diff --git a/sphinx-doc/index.rst b/sphinx-doc/index.rst new file mode 100644 index 0000000..ec958e3 --- /dev/null +++ b/sphinx-doc/index.rst @@ -0,0 +1,21 @@ +Welcome to MFD-Hyperv's documentation! +====================================== + +.. toctree:: + :caption: Home + + Documentation Home + + +.. toctree:: + :caption: Main Documentation + :maxdepth: 4 + + MFD-Hyperv Documentation + + +.. toctree:: + :caption: Appendix + + Python Module Index + Index \ No newline at end of file diff --git a/sphinx-doc/py-modindex.rst b/sphinx-doc/py-modindex.rst new file mode 100644 index 0000000..4d1ecb4 --- /dev/null +++ b/sphinx-doc/py-modindex.rst @@ -0,0 +1,4 @@ +.. This file is a placeholder and will be replaced + +Python Module Index +##### \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8ef2fad --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT diff --git a/tests/system/.gitkeep b/tests/system/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..8ef2fad --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT diff --git a/tests/unit/test_mfd_hyperv/__init__.py b/tests/unit/test_mfd_hyperv/__init__.py new file mode 100644 index 0000000..8ef2fad --- /dev/null +++ b/tests/unit/test_mfd_hyperv/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT diff --git a/tests/unit/test_mfd_hyperv/const.py b/tests/unit/test_mfd_hyperv/const.py new file mode 100644 index 0000000..e41f717 --- /dev/null +++ b/tests/unit/test_mfd_hyperv/const.py @@ -0,0 +1,322 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +from textwrap import dedent + +out_list_queue = dedent( + """ + ITEM LIST +=========== + + + QOS QUEUE: 2 + Friendly name : SQ2 + Enforce intra-host limit: TRUE + Transmit Limit: 10000 + Transmit Reservation: 0 + Receive Limit: 0 + Transmit Queue Depth: 200 packets + Receive Queue Depth: 50 packets + Transmit Burst Size: 50 ms + Receive Burst Size: 50 ms + Reservation UnderUtilized watermark: 85% + Reservation OverUtilized watermark: 95% + Reservation Headroom: 10% + Reservation Rampup time: 500 ms + Reservation Min rate: 10 Mbps + + Current Transmit Info: + Rate: 10000 + Throttled Packets: 0 + Dropped Packets: 0 + Current Receive Info: + Rate: DISABLED + Throttled Packets: 0 + Dropped Packets: 0 + + + + QOS QUEUE: 1 + Friendly name : SQ1 + Enforce intra-host limit: TRUE + Transmit Limit: 10000 + Transmit Reservation: 0 + Receive Limit: 0 + Transmit Queue Depth: 200 packets + Receive Queue Depth: 50 packets + Transmit Burst Size: 50 ms + Receive Burst Size: 50 ms + Reservation UnderUtilized watermark: 85% + Reservation OverUtilized watermark: 95% + Reservation Headroom: 10% + Reservation Rampup time: 500 ms + Reservation Min rate: 10 Mbps + + Current Transmit Info: + Rate: 10000 + Throttled Packets: 0 + Dropped Packets: 0 + Current Receive Info: + Rate: DISABLED + Throttled Packets: 0 + Dropped Packets: 0 + + +Command list-queue succeeded!""" +) + +out_get_all_info = dedent( + """ + ITEM LIST +=========== + + + QOS QUEUE: 2 + Friendly name : SQ2 + Enforce intra-host limit: TRUE + Transmit Limit: 10000 + Transmit Reservation: 0 + Receive Limit: 0 + Transmit Queue Depth: 200 packets + Receive Queue Depth: 50 packets + Transmit Burst Size: 50 ms + Receive Burst Size: 50 ms + Reservation UnderUtilized watermark: 85% + Reservation OverUtilized watermark: 95% + Reservation Headroom: 10% + Reservation Rampup time: 500 ms + Reservation Min rate: 10 Mbps + + Current Transmit Info: + Rate: 10000 + Throttled Packets: 0 + Dropped Packets: 0 + Current Receive Info: + Rate: DISABLED + Throttled Packets: 0 + Dropped Packets: 0 + + + Max queue stats since last query: + Queue Type: Max cap + Direction: Out + Elapsed time since last query: 242218580 ms + Bytes received: 0 + Packets received: 0 + Bytes allowed: 0 + Packets allowed: 0 + Bytes dropped: 0 + Packets dropped: 0 + Bytes delayed due to non-empty queue: 0 + Packets delayed due to non-empty queue: 0 + Bytes delayed due to insufficient tokens: 0 + Packets delayed due to insufficient tokens: 0 + Bytes resumed: 0 + Packets resumed: 0 + + Packet events: + Column 1: Time stamp (100-ns units) + Column 2: Prerefresh token count + Column 3: Tokens refreshed + Column 4: Queue rate (Mbps) + Column 5: Queue pkt length + Column 6: Proc number + Column 7: Bytes received + Column 8: Packets received + Column 9: PortId + Column 10: Queue action + + 0: 0: 0: 0: 0: 0: 0: 0: 0: Unknown + 0: 0: 0: 0: 0: 0: 0: 0: 0: Unknown + 0: 0: 0: 0: 0: 0: 0: 0: 0: Unknown + + Resume events: + Column 1: Time stamp (100-ns units) + Column 2: Elapsed uSecs since last token refreshed + Column 3: Proc number + Column 4: PreRefresh token count + Column 5: Tokens refreshed + Column 6: Queue byte length + Column 7: Queue packet length + Column 8: Bytes resumed + Column 9: Packets resumed + + 0: 0: 0: 0: 0: 0: 0: 0: 0 + 0: 0: 0: 0: 0: 0: 0: 0: 0 + 0: 0: 0: 0: 0: 0: 0: 0: 0 + 0: 0: 0: 0: 0: 0: 0: 0: 0 + + QOS QUEUE: 1 + Friendly name : SQ1 + Enforce intra-host limit: TRUE + Transmit Limit: 10000 + Transmit Reservation: 0 + Receive Limit: 0 + Transmit Queue Depth: 200 packets + Receive Queue Depth: 50 packets + Transmit Burst Size: 50 ms + Receive Burst Size: 50 ms + Reservation UnderUtilized watermark: 85% + Reservation OverUtilized watermark: 95% + Reservation Headroom: 10% + Reservation Rampup time: 500 ms + Reservation Min rate: 10 Mbps + + Current Transmit Info: + Rate: 10000 + Throttled Packets: 0 + Dropped Packets: 0 + Current Receive Info: + Rate: DISABLED + Throttled Packets: 0 + Dropped Packets: 0 + + + Max queue stats since last query: + Queue Type: Max cap + Direction: Out + Elapsed time since last query: 242218580 ms + Bytes received: 0 + Packets received: 0 + Bytes allowed: 0 + Packets allowed: 0 + Bytes dropped: 0 + Packets dropped: 0 + Bytes delayed due to non-empty queue: 0 + Packets delayed due to non-empty queue: 0 + Bytes delayed due to insufficient tokens: 0 + Packets delayed due to insufficient tokens: 0 + Bytes resumed: 0 + Packets resumed: 0 + + Packet events: + Column 1: Time stamp (100-ns units) + Column 2: Prerefresh token count + Column 3: Tokens refreshed + Column 4: Queue rate (Mbps) + Column 5: Queue pkt length + Column 6: Proc number + Column 7: Bytes received + Column 8: Packets received + Column 9: PortId + Column 10: Queue action + + 0: 0: 0: 0: 0: 0: 0: 0: 0: Unknown + 0: 0: 0: 0: 0: 0: 0: 0: 0: Unknown + 0: 0: 0: 0: 0: 0: 0: 0: 0: Unknown + 0: 0: 0: 0: 0: 0: 0: 0: 0: Unknown + 0: 0: 0: 0: 0: 0: 0: 0: 0: Unknown + + Resume events: + Column 1: Time stamp (100-ns units) + Column 2: Elapsed uSecs since last token refreshed + Column 3: Proc number + Column 4: PreRefresh token count + Column 5: Tokens refreshed + Column 6: Queue byte length + Column 7: Queue packet length + Column 8: Bytes resumed + Column 9: Packets resumed + + 0: 0: 0: 0: 0: 0: 0: 0: 0 + 0: 0: 0: 0: 0: 0: 0: 0: 0 + 0: 0: 0: 0: 0: 0: 0: 0: 0 + 0: 0: 0: 0: 0: 0: 0: 0: 0 + 0: 0: 0: 0: 0: 0: 0: 0: 0 + 0: 0: 0: 0: 0: 0: 0: 0: 0 + +Command get-queue-info succeeded!""" +) + +out_queue_offload = dedent( + """ + ITEM LIST +=========== + + + QOS QUEUE: 2 + Friendly name : SQ2 + Enforce intra-host limit: TRUE + Transmit Limit: 10000 + Transmit Reservation: 0 + Receive Limit: 0 + Transmit Queue Depth: 200 packets + Receive Queue Depth: 50 packets + Transmit Burst Size: 50 ms + Receive Burst Size: 50 ms + Reservation UnderUtilized watermark: 85% + Reservation OverUtilized watermark: 95% + Reservation Headroom: 10% + Reservation Rampup time: 500 ms + Reservation Min rate: 10 Mbps + + Current Transmit Info: + Rate: 10000 + Throttled Packets: 0 + Dropped Packets: 0 + Current Receive Info: + Rate: DISABLED + Throttled Packets: 0 + Dropped Packets: 0 + + + Max queue stats since last query: + Queue Type: Max cap + Direction: Out + Elapsed time since last query: 242218580 ms + Bytes received: 0 + Packets received: 0 + Bytes allowed: 0 + Packets allowed: 0 + Bytes dropped: 0 + Packets dropped: 0 + Bytes delayed due to non-empty queue: 0 + Packets delayed due to non-empty queue: 0 + Bytes delayed due to insufficient tokens: 0 + Packets delayed due to insufficient tokens: 0 + Bytes resumed: 0 + Packets resumed: 0 + + QOS QUEUE: 1 + Friendly name : SQ2 + Enforce intra-host limit: TRUE + Transmit Limit: 10000 + Transmit Reservation: 0 + Receive Limit: 0 + Transmit Queue Depth: 200 packets + Receive Queue Depth: 50 packets + Transmit Burst Size: 50 ms + Receive Burst Size: 50 ms + Reservation UnderUtilized watermark: 85% + Reservation OverUtilized watermark: 95% + Reservation Headroom: 10% + Reservation Rampup time: 500 ms + Reservation Min rate: 10 Mbps + + Current Transmit Info: + Rate: 10000 + Throttled Packets: 0 + Dropped Packets: 0 + Current Receive Info: + Rate: DISABLED + Throttled Packets: 0 + Dropped Packets: 0 + + + Max queue stats since last query: + Queue Type: Max cap + Direction: Out + Elapsed time since last query: 242218580 ms + Bytes received: 0 + Packets received: 0 + Bytes allowed: 0 + Packets allowed: 0 + Bytes dropped: 0 + Packets dropped: 0 + Bytes delayed due to non-empty queue: 0 + Packets delayed due to non-empty queue: 0 + Bytes delayed due to insufficient tokens: 0 + Packets delayed due to insufficient tokens: 0 + Bytes resumed: 0 + Packets resumed: 0 + """ +) diff --git a/tests/unit/test_mfd_hyperv/test_hw_qos.py b/tests/unit/test_mfd_hyperv/test_hw_qos.py new file mode 100644 index 0000000..36004b0 --- /dev/null +++ b/tests/unit/test_mfd_hyperv/test_hw_qos.py @@ -0,0 +1,342 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Tests for `hw_qos` package.""" +from textwrap import dedent + +import pytest + +from mfd_connect import LocalConnection +from mfd_connect.base import ConnectionCompletedProcess +from mfd_typing import OSName + +from mfd_hyperv.hw_qos import HWQoS +from mfd_hyperv.exceptions import HyperVExecutionException, HyperVException +from tests.unit.test_mfd_hyperv.const import out_list_queue, out_queue_offload, out_get_all_info + + +class TestMfdHypervHWQos: + @pytest.fixture() + def hyperv_qos(self, mocker): + conn = mocker.create_autospec(LocalConnection) + conn.get_os_name.return_value = OSName.WINDOWS + + hyperv = HWQoS(connection=conn) + mocker.stopall() + return hyperv + + def test_create_scheduler_queue(self, hyperv_qos): + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="stderr" + ) + hyperv_qos.create_scheduler_queue( + vswitch_name="sw0", limit=True, tx_max="500", tx_reserve="0", rx_max="0", sq_id="5", sq_name="SQ1" + ) + hyperv_qos._connection.execute_powershell.assert_called_once_with( + 'vfpctrl /switch sw0 /add-queue "5 SQ1 true 500 0 0"', + custom_exception=HyperVExecutionException, + ) + + def test_update_scheduler_queue(self, hyperv_qos): + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="stderr" + ) + hyperv_qos.update_scheduler_queue( + vswitch_name="sw0", limit=True, tx_max="500", tx_reserve="0", rx_max="0", sq_id="5" + ) + hyperv_qos._connection.execute_powershell.assert_called_once_with( + 'vfpctrl /switch sw0 /set-queue-config "true 500 0 0" /queue "5"', + custom_exception=HyperVExecutionException, + ) + + def test_delete_scheduler_queue(self, hyperv_qos): + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="stderr" + ) + hyperv_qos.delete_scheduler_queue(vswitch_name="sw0", sq_id="5") + hyperv_qos._connection.execute_powershell.assert_called_once_with( + 'vfpctrl /switch sw0 /remove-queue /queue "5"', custom_exception=HyperVExecutionException + ) + + def test_get_qos_config(self, hyperv_qos): + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="stderr" + ) + hyperv_qos.get_qos_config(vswitch_name="sw0") + hyperv_qos._connection.execute_powershell.assert_called_once_with( + "vfpctrl /switch sw0 /get-qos-config", custom_exception=HyperVExecutionException + ) + + def test_get_qos_config_parse_output(self, hyperv_qos): + command_output = """ + ITEM LIST + =========== + + SWITCH QOS CONFIG + Enable Hardware Caps: FALSE + Enable Hardware Reservations: FALSE + Enable Software Reservations: TRUE + Flags: 0x00 + Command get-qos-config succeeded! + """ + expected_result = {"hw_caps": False, "hw_reserv": False, "sw_reserv": True, "flags": "0x00"} + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=command_output, stderr="stderr" + ) + assert hyperv_qos.get_qos_config(vswitch_name="sw0") == expected_result + + def test_set_qos_config(self, hyperv_qos): + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="stderr" + ) + hyperv_qos.set_qos_config(vswitch_name="sw0", hw_caps=False, hw_reserv=True, sw_reserv=True, flags="0") + hyperv_qos._connection.execute_powershell.assert_called_once_with( + 'vfpctrl /switch sw0 /set-qos-config "false true true 0"', + custom_exception=HyperVExecutionException, + ) + + def test_disassociate_scheduler_queues_with_vport(self, hyperv_qos): + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="stderr" + ) + hyperv_qos.disassociate_scheduler_queues_with_vport( + vswitch_name="sw0", vport="AF4F56A0-802D-4629-88D4-7ECBDB019AE3" + ) + hyperv_qos._connection.execute_powershell.assert_called_once_with( + "vfpctrl /switch sw0 /port AF4F56A0-802D-4629-88D4-7ECBDB019AE3 /clear-port-queue", + custom_exception=HyperVExecutionException, + ) + + def test_list_scheduler_queues_with_vport(self, hyperv_qos): + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="stderr" + ) + hyperv_qos.list_scheduler_queues_with_vport(vswitch_name="sw0", vport="AF4F56A0-802D-4629-88D4-7ECBDB019AE3") + hyperv_qos._connection.execute_powershell.assert_called_once_with( + "vfpctrl /switch sw0 /port AF4F56A0-802D-4629-88D4-7ECBDB019AE3 /get-port-queue", + custom_exception=HyperVExecutionException, + ) + + def test_list_scheduler_queues_with_vport_parse_output(self, hyperv_qos): + command_output = """ + ITEM LIST + =========== + + + QOS QUEUE: 2 + Friendly name : SQ1 + Enforce intra-host limit: TRUE + Transmit Limit: 1000 + Transmit Reservation: 0 + Receive Limit: 0 + Transmit Queue Depth: 200 packets + Receive Queue Depth: 50 packets + Transmit Burst Size: 50 ms + Receive Burst Size: 50 ms + Reservation UnderUtilized watermark: 85% + Reservation OverUtilized watermark: 95% + Reservation Headroom: 10% + Reservation Rampup time: 500 ms + Reservation Min rate: 10 Mbps + + Current Transmit Info: + Rate: 1000 + Throttled Packets: 0 + Dropped Packets: 0 + Current Receive Info: + Rate: DISABLED + Throttled Packets: 0 + Dropped Packets: 0 + + QOS QUEUE: 4 + Friendly name : SQ1 + Enforce intra-host limit: TRUE + Transmit Limit: 1000 + Transmit Reservation: 0 + Receive Limit: 0 + Transmit Queue Depth: 200 packets + Receive Queue Depth: 50 packets + Transmit Burst Size: 50 ms + Receive Burst Size: 50 ms + Reservation UnderUtilized watermark: 85% + Reservation OverUtilized watermark: 95% + Reservation Headroom: 10% + Reservation Rampup time: 500 ms + Reservation Min rate: 10 Mbps + + Current Transmit Info: + Rate: 1000 + Throttled Packets: 0 + Dropped Packets: 0 + Current Receive Info: + Rate: DISABLED + Throttled Packets: 0 + Dropped Packets: 0 + + + Port: AF4F56A0-802D-4629-88D4-7ECBDB019AE3 + Friendly Name: vSwitch000_External + ID: 1 +Command get-port-queue succeeded! + """ + expected_result = ["2", "4"] + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=command_output, stderr="stderr" + ) + assert ( + hyperv_qos.list_scheduler_queues_with_vport( + vswitch_name="sw0", vport="AF4F56A0-802D-4629-88D4-7ECBDB019AE3" + ) + == expected_result + ) + + def test_associate_scheduler_queues_with_vport(self, hyperv_qos, mocker): + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="stderr" + ) + hyperv_qos.associate_scheduler_queues_with_vport( + vswitch_name="sw0", vport="AF4F56A0-802D-4629-88D4-7ECBDB019AE3", sq_id="5", lid=1, lname="layer1" + ) + expected_cmds = [ + "vfpctrl /switch sw0 /port AF4F56A0-802D-4629-88D4-7ECBDB019AE3 /enable-port", + "vfpctrl /switch sw0 /port AF4F56A0-802D-4629-88D4-7ECBDB019AE3 /unblock-port", + "vfpctrl /switch sw0 /port AF4F56A0-802D-4629-88D4-7ECBDB019AE3 " "/add-layer '1 layer1 stateless 100 1'", + "vfpctrl /switch sw0 /port AF4F56A0-802D-4629-88D4-7ECBDB019AE3 /set-port-queue 5", + ] + calls = [mocker.call(command=call, custom_exception=HyperVExecutionException) for call in expected_cmds] + hyperv_qos._connection.execute_powershell.assert_has_calls(calls) + + def test_get_vmswitch_port_name_no_match(self, hyperv_qos): + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="stderr" + ) + with pytest.raises( + HyperVException, + match="Couldn't find VM Switch port name for Switch Friendly name: " + "vSwitch00 and VM name: vm00-2019 in output: ", + ): + hyperv_qos.get_vmswitch_port_name("vSwitch00", "vm00-2019") + + def test_get_vmswitch_port_name(self, hyperv_qos): + output = dedent( + """\ + Port name : 924950C2-4D3F-47E2-A7BA-C0E322C51C66 + Port Friendly name : Dynamic Ethernet Switch Port + Switch name : 5D81E4BB-3056-4BA3-A7A5-469AEAFB366D + Switch Friendly name : vSwitch00 + PortId : 3 + VMQ Weight : 100 + VMQ Usage : 1 + SR-IOV Weight : 0 + SR-IOV Usage : 0 + Port type: : Synthetic + Port is Initialized. + MAC Learning is Disabled. + NIC name : FFAAQQ66-AA1234-7890-F342-7894AD45ER12--33E0CC89-3DB0-4A5A-7845-123456789ABC + NIC Friendly name : Network Adapter + MTU : 1500 + MAC address : AA-BB-CC-DD-EE-FF + VM name : vm00-2019 + VM ID : 11223344-1122-4455-6655-ABCDEF123456 + """ + ) + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + assert hyperv_qos.get_vmswitch_port_name("vSwitch00", "vm00-2019") == "924950C2-4D3F-47E2-A7BA-C0E322C51C66" + + def test_get_vmswitch_port_name_multiple_vm(self, hyperv_qos): + output = dedent( + """\ + Port name : 88888888-4D3F-47E2-A7BA-000000000000 + Port Friendly name : Dynamic Ethernet Switch Port + Switch name : 5D81E4BB-3056-4BA3-A7A5-469AEAFB366D + Switch Friendly name : vSwitch01 + PortId : 3 + VMQ Weight : 100 + VMQ Usage : 1 + SR-IOV Weight : 0 + SR-IOV Usage : 0 + Port type: : Synthetic + Port is Initialized. + MAC Learning is Disabled. + NIC name : FFAAQQ66-AA1234-7890-F342-7894AD45ER12--33E0CC89-3DB0-4A5A-7845-123456789ABC + NIC Friendly name : Network Adapter + MTU : 1500 + MAC address : AA-BB-CC-DD-EE-FF + VM name : vm01-2019 + VM ID : 11223344-1122-4455-6655-ABCDEF123456 + Port name : 924950C2-4D3F-47E2-A7BA-C0E322C51C66 + Port Friendly name : Dynamic Ethernet Switch Port + Switch name : 5D81E4BB-3056-4BA3-A7A5-469AEAFB366D + Switch Friendly name : vSwitch00 + PortId : 3 + VMQ Weight : 100 + VMQ Usage : 1 + SR-IOV Weight : 0 + SR-IOV Usage : 0 + Port type: : Synthetic + Port is Initialized. + MAC Learning is Disabled. + NIC name : FFAAQQ66-AA1234-7890-F342-7894AD45ER12--33E0CC89-3DB0-4A5A-7845-123456789ABC + NIC Friendly name : Network Adapter + MTU : 1500 + MAC address : AA-BB-CC-DD-EE-FF + VM name : vm00-2019 + VM ID : 11223344-1122-4455-6655-ABCDEF123456 + """ + ) + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + assert hyperv_qos.get_vmswitch_port_name("vSwitch00", "vm00-2019") == "924950C2-4D3F-47E2-A7BA-C0E322C51C66" + + def test_is_scheduler_queues_created_true(self, mocker, hyperv_qos): + hyperv_qos.list_queue = mocker.Mock(return_value=out_list_queue) + hyperv_qos.get_queue_all_info = mocker.Mock(return_value=out_get_all_info) + hyperv_qos.get_queue_offload_info = mocker.Mock(return_value=out_queue_offload) + out = hyperv_qos.is_scheduler_queues_created( + vswitch_name="sample_vswitch", sq_id=2, sq_name="SQ2", tx_max="10000" + ) + assert out + + def test_is_scheduler_queues_created_false(self, mocker, hyperv_qos): + hyperv_qos.list_queue = mocker.Mock(return_value="output") + hyperv_qos.get_queue_all_info = mocker.Mock(return_value="out_get_all_info") + hyperv_qos.get_queue_offload_info = mocker.Mock(return_value="offload_result") + out = hyperv_qos.is_scheduler_queues_created( + vswitch_name="sample_vswitch", sq_id=2, sq_name="SQ2", tx_max="10000" + ) + assert not out + + def test_list_queue(self, hyperv_qos): + expected_output = "list queue output" + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=expected_output, stderr="stderr" + ) + result = hyperv_qos.list_queue(vswitch_name="sw0") + assert result == expected_output + hyperv_qos._connection.execute_powershell.assert_called_once_with( + "vfpctrl /switch sw0 /list-queue", custom_exception=HyperVExecutionException + ) + + def test_get_queue_all_info(self, hyperv_qos): + expected_output = "get all queue info output" + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=expected_output, stderr="stderr" + ) + result = hyperv_qos.get_queue_all_info(vswitch_name="sw0") + assert result == expected_output + hyperv_qos._connection.execute_powershell.assert_called_once_with( + 'vfpctrl /switch sw0 /get-queue-info "all"', custom_exception=HyperVExecutionException + ) + + def test_get_queue_offload_info(self, hyperv_qos): + expected_output = "get queue offload info output" + hyperv_qos._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=expected_output, stderr="stderr" + ) + result = hyperv_qos.get_queue_offload_info(vswitch_name="sw0", sq_id=2) + assert result == expected_output + hyperv_qos._connection.execute_powershell.assert_called_once_with( + 'vfpctrl /switch sw0 /get-queue-info "offload" /queue "2"', custom_exception=HyperVExecutionException + ) diff --git a/tests/unit/test_mfd_hyperv/test_hypervisor.py b/tests/unit/test_mfd_hyperv/test_hypervisor.py new file mode 100644 index 0000000..9a7cfbf --- /dev/null +++ b/tests/unit/test_mfd_hyperv/test_hypervisor.py @@ -0,0 +1,822 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Tests for `mfd_hyperv` hypervisor submodule.""" + +from pathlib import Path + +import pytest +from mfd_connect import LocalConnection +from mfd_connect.base import ConnectionCompletedProcess +from mfd_ping import PingResult +from mfd_typing import OSName, MACAddress +from netaddr import IPAddress + +from mfd_hyperv.attributes.vm_params import VMParams +from mfd_hyperv.exceptions import HyperVException, HyperVExecutionException +from mfd_hyperv.hypervisor import HypervHypervisor + + +class TestHypervisor: + @pytest.fixture() + def hypervisor(self, mocker): + conn = mocker.create_autospec(LocalConnection) + conn.get_os_name.return_value = OSName.WINDOWS + + hypervisor = HypervHypervisor(connection=conn) + mocker.stopall() + return hypervisor + + @pytest.fixture() + def hypervisor_with_2_vms(self, mocker, hypervisor): + vm_params = VMParams( + name="vm_name", + cpu_count=4, + hw_threads_per_core=0, + memory=4096, + generation=2, + vm_dir_path="path", + diff_disk_path="diff_disk_path", + mng_interface_name="mng", + mng_mac_address=MACAddress("00:00:00:00:00:00"), + mng_ip="1.1.1.1", + vswitch_name="vswitch", + ) + + output = """ + VMName : Base_W19_VM001 + IPAddresses : {10.91.218.16, fe80::6994:9bd4:d0aa:ff4d} + MacAddress : 525A005BDA10 + """ + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.start_vm", return_value=None) + mocker.patch("mfd_hyperv.hypervisor.RPyCConnection", autospec=True) + mocker.patch("mfd_hyperv.instances.vm.NetworkAdapterOwner", autospec=True) + + for i in range(2): + vm_params.name = f"vm_name_{i}" + hypervisor.create_vm(vm_params) + + return hypervisor + + def test_is_hyperv_enabled(self, hypervisor): + out_positive = """ + FeatureName : Microsoft-Hyper-V + DisplayName : Hyper-V + Description : Hyper-V + RestartRequired : Possible + State : Enabled + """ + + out_negative = """ + FeatureName : Microsoft-Hyper-V + DisplayName : Hyper-V + Description : Hyper-V + RestartRequired : Possible + State : Disabled + """ + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=out_positive, stderr="stderr" + ) + assert hypervisor.is_hyperv_enabled() + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=out_negative, stderr="stderr" + ) + assert not hypervisor.is_hyperv_enabled() + + def test_create_vm(self, mocker, hypervisor): + vm_params = VMParams( + name="vm_name", + cpu_count=4, + hw_threads_per_core=0, + memory=4096, + generation=2, + vm_dir_path="path", + diff_disk_path="diff_disk_path", + mng_interface_name="mng", + mng_mac_address=MACAddress("00:00:00:00:00:00"), + mng_ip="1.1.1.1", + vswitch_name="vswitch", + ) + output = """ + VMName : Base_W19_VM001 + IPAddresses : {10.91.218.16, fe80::6994:9bd4:d0aa:ff4d} + MacAddress : 525A005BDA10 + """ + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.start_vm", return_value=None) + mocker.patch("mfd_hyperv.hypervisor.RPyCConnection", autospec=True) + mocker.patch("mfd_hyperv.hypervisor.VM", autospec=True) + + hypervisor.create_vm(vm_params, dynamic_mng_ip=True) + assert len(hypervisor.vms) == 1 + + def test_remove_vm_all(self, hypervisor_with_2_vms): + hypervisor_with_2_vms._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="stdout", stderr="stderr" + ) + + hypervisor_with_2_vms.remove_vm() + assert len(hypervisor_with_2_vms.vms) == 0 + + def test_remove_vm_single(self, hypervisor_with_2_vms): + hypervisor_with_2_vms._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="stdout", stderr="stderr" + ) + + hypervisor_with_2_vms.remove_vm("vm_name_0") + assert len(hypervisor_with_2_vms.vms) == 1 + assert hypervisor_with_2_vms.vms[0].name == "vm_name_1" + + def test_start_vm(self, hypervisor): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="stdout", stderr="stderr" + ) + hypervisor.start_vm() + + hypervisor._connection.execute_powershell.assert_called_once_with( + "Start-VM *", + expected_return_codes={}, + ) + + hypervisor.start_vm("vm") + + hypervisor._connection.execute_powershell.assert_called_with( + "Start-VM vm", + expected_return_codes={}, + ) + + def test_stop_vm(self, hypervisor): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="stdout", stderr="stderr" + ) + hypervisor.stop_vm() + + hypervisor._connection.execute_powershell.assert_called_once_with( + "Stop-VM *", + expected_return_codes={}, + ) + + hypervisor.stop_vm("vm") + + hypervisor._connection.execute_powershell.assert_called_with( + "Stop-VM vm", + expected_return_codes={}, + ) + + def test_vm_connectivity_test(self, hypervisor, mocker): + mocker.patch("mfd_hyperv.hypervisor.time.sleep") + mocker.patch("mfd_hyperv.hypervisor.TimeoutCounter", return_value=True) + mocker.patch("mfd_ping.windows.WindowsPing.stop", return_value=PingResult(4, 0)) + assert hypervisor._vm_connectivity_test(mocker.create_autospec(IPAddress)) + + mocker.patch("mfd_ping.windows.WindowsPing.stop", return_value=PingResult(0, 4)) + assert not hypervisor._vm_connectivity_test(mocker.create_autospec(IPAddress)) + + def test_wait_vm_functional(self, hypervisor, mocker): + mocker.patch("mfd_hyperv.hypervisor.TimeoutCounter", return_value=False) + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._vm_connectivity_test", return_value=True) + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.get_vm_state", return_value="Running") + + hypervisor.wait_vm_functional(mocker.Mock(), mocker.Mock()) + + def test_wait_vm_stopped(self, hypervisor, mocker): + mocker.patch("mfd_hyperv.hypervisor.TimeoutCounter", return_value=False) + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.get_vm_state", return_value="Off") + + hypervisor.wait_vm_stopped(mocker.Mock()) + + def test_get_vm_state(self, hypervisor, mocker): + out_positive = """ + FeatureName : Microsoft-Hyper-V + DisplayName : Hyper-V + Description : Hyper-V + RestartRequired : Possible + State : Enabled + """ + + out_negative = """ + FeatureName : Microsoft-Hyper-V + DisplayName : Hyper-V + Description : Hyper-V + RestartRequired : Possible + State : Disabled + """ + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=out_positive, stderr="stderr" + ) + assert hypervisor.get_vm_state(mocker.Mock()) == "Enabled" + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=out_negative, stderr="stderr" + ) + assert hypervisor.get_vm_state(mocker.Mock()) == "Disabled" + + def test_restart_vm(self, hypervisor): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="stdout", stderr="stderr" + ) + hypervisor.restart_vm() + + hypervisor._connection.execute_powershell.assert_called_once_with( + "Restart-VM * -force -confirm:$false", + expected_return_codes={}, + ) + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="stdout", stderr="stderr" + ) + hypervisor.restart_vm("name") + + hypervisor._connection.execute_powershell.assert_called_with( + "Restart-VM name -force -confirm:$false", + expected_return_codes={}, + ) + + def test_clear_vm_locations(self, hypervisor, mocker): + output = { + "D:": 124321432, + "E:": 435435436, + } + + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._get_disks_free_space", return_value=output) + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="stdout", stderr="stderr" + ) + + hypervisor.clear_vm_locations() + + calls = [ + mocker.call("Remove-Item -Recurse -Force E:\\VMs\\*"), + mocker.call("Remove-Item -Recurse -Force D:\\VMs\\*"), + ] + + hypervisor._connection.execute_powershell.assert_has_calls(calls, any_order=True) + + def test_get_vm_attributes(self, hypervisor): + output = """ + Name : Base_R92_VM001 + State : Running + CpuUsage : 0 + MemoryAssigned : 4294967296 + MemoryDemand : 2705326080 + MemoryStatus : + Uptime : 00:03:47.9220000 + Status : Operating normally + ReplicationState : Disabled + Generation : 2 + """ + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + expected_obj = { + "Name": "Base_R92_VM001", + "State": "Running", + "CpuUsage": "0", + "MemoryAssigned": "4294967296", + "MemoryDemand": "2705326080", + "MemoryStatus": "", + "Uptime": "00:03:47.9220000", + "Status": "Operating normally", + "ReplicationState": "Disabled", + "Generation": "2", + } + + assert hypervisor.get_vm_attributes("test") == expected_obj + hypervisor._connection.execute_powershell.assert_called_once_with( + "Get-VM test | select * | fl", + expected_return_codes={}, + ) + + def test_get_vm_processor_attributes(self, hypervisor): + output = """ + ResourcePoolName : Primordial + Count : 2 + CompatibilityForMigrationEnabled : False + CompatibilityForMigrationMode : MinimumFeatureSet + CompatibilityForOlderOperatingSystemsEnabled : False + HwThreadCountPerCore : 0 + ExposeVirtualizationExtensions : False + EnablePerfmonPmu : False + EnablePerfmonLbr : False + EnablePerfmonPebs : False + EnablePerfmonIpt : False + EnableLegacyApicMode : False + ApicMode : Default + AllowACountMCount : True + CpuBrandString : + PerfCpuFreqCapMhz : 0 + Maximum : 100 + Reserve : 0 + RelativeWeight : 100 + MaximumCountPerNumaNode : 36 + MaximumCountPerNumaSocket : 1 + EnableHostResourceProtection : False + OperationalStatus : {Ok, HostResourceProtectionDisabled} + StatusDescription : {OK, Host resource protection is disabled.} + Name : Processor + Id : Microsoft:4711AB94-4AED-40B8-B3A0-9388049571AB\b637f346-6a0e-4dec-af52-b + d70cb80a21d\0 + VMId : 4711ab94-4aed-40b8-b3a0-9388049571ab + VMName : Base_R92_VM001 + VMSnapshotId : 00000000-0000-0000-0000-000000000000 + VMSnapshotName : + CimSession : CimSession: . + ComputerName : AMVAL-216-025 + IsDeleted : False + VMCheckpointId : 00000000-0000-0000-0000-000000000000 + VMCheckpointName : + """ # noqa: E501 + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + assert len(hypervisor.get_vm_processor_attributes("name").keys()) == 35 + + hypervisor._connection.execute_powershell.assert_called_once_with( + "Get-VMProcessor -VMName name | select * | fl", + expected_return_codes={}, + ) + + def test_set_vm_processor_attribute(self, hypervisor, mocker): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + hypervisor.set_vm_processor_attribute("name", mocker.Mock(), mocker.Mock()) + + hypervisor._connection.execute_powershell.assert_called() + + def test_get_disks_free_space(self, hypervisor, mocker): + output = """ + Caption DriveType FreeSpace Size + ------- --------- --------- ---- + C: 3 37575798784 64317550592 + D: 3 110013030400 254060523520 + Z: 4 1874351206400 7433549180928 + """ + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + expected_obj = { + "D:\\": {"free": "110013030400", "total": "254060523520"}, + "C:\\": {"free": "37575798784", "total": "64317550592"}, + } + + assert hypervisor._get_disks_free_space() == expected_obj + + def test_get_disks_free_space_failing(self, hypervisor): + output = """ + Caption DriveType FreeSpace Size + ------- --------- --------- ---- + Z: 4 1874351206400 7433549180928 + """ + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + with pytest.raises(HyperVException, match="Expected partition was not found."): + hypervisor._get_disks_free_space() + + def test_get_disk_paths_with_enough_space(self, hypervisor, mocker): + disks = {"emu": {"free": "110013030400", "total": "254060523520"}} + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._get_disks_free_space", return_value=disks) + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + hypervisor._connection.path = Path + + assert hypervisor.get_disk_paths_with_enough_space(1234) == str(Path(r"emu/VMs")) + + def test_get_disk_paths_with_enough_space_failing(self, hypervisor, mocker): + disks = {"D:\\": {"free": "110013030400", "total": "254060523520"}} + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._get_disks_free_space", return_value=disks) + + hypervisor._connection.execute_command.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + with pytest.raises(HyperVException, match="No disk that has enough space"): + hypervisor.get_disk_paths_with_enough_space(210013030400) + + def test_copy_vm_image_no_zip(self, hypervisor, mocker): + hypervisor._connection.execute_command.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + mocker.patch("time.sleep") + hypervisor._connection.path.return_value = Path("D:\\dst\\img.vhdx") + mocker.patch("mfd_connect.util.rpc_copy_utils._check_paths") + mocker.patch("mfd_connect.util.rpc_copy_utils.copy") + + assert hypervisor.copy_vm_image("img.vhdx", "D:\\dst", r"C:\\src") == Path("D:\\dst\\img.vhdx") + + def test_copy_vm_image_zip(self, hypervisor, mocker): + hypervisor._connection.execute_command.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + mocker.patch("time.sleep") + hypervisor._connection.path.return_value = Path("D:\\dst\\img.vhdx") + mocker.patch("pathlib.Path.exists", return_value=True) + + assert hypervisor.copy_vm_image("img.vhdx", "D:\\dst", r"C:\\src") == Path("D:\\dst\\img.vhdx") + + def test_copy_vm_image_zip_windows_16(self, hypervisor, mocker): + hypervisor._connection.execute_command.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + mocker.patch("time.sleep") + hypervisor._connection.path.return_value = Path("D:\\dst\\img.vhdx") + mocker.patch("pathlib.Path.exists", return_value=True) + hypervisor._connection.get_system_info().os_name = "Windows 2016" + + assert hypervisor.copy_vm_image("img.vhdx", "D:\\dst", r"C:\\src") == Path("D:\\dst\\img.vhdx") + + def test_get_file_metadata(self, hypervisor, mocker): + outputs = [ + """ + Directory: D:\VM-Template + + + Name : Base_R88.vhdx + Length : 19990052864 + CreationTime : 7/21/2023 3:49:16 PM + LastWriteTime : 7/7/2023 7:44:33 AM + LastAccessTime : 7/21/2023 3:52:19 PM + Mode : -a---- + LinkType : + Target : {} + VersionInfo : File: D:\VM-Template\Base_R88.vhdx + InternalName: + OriginalFilename: + FileVersion: + FileDescription: + Product: + ProductVersion: + Debug: False + Patched: False + PreRelease: False + PrivateBuild: False + SpecialBuild: False + Language: + """, # noqa: E501, W291, W605, W293 + """ + Directory: fdsfdssfsf + + Name : Base_W19.vhdx + if ($_ -is [System.IO.DirectoryInfo]) { return '' } + if ($_.Attributes -band [System.IO.FileAttributes]::Offline) + { + return '({0})' -f $_.Length + } + return $_.Length : 19990052864 + CreationTime : 7/6/2023 9:41:18 AM + LastWriteTime : 7/7/2023 7:44:33 AM + LastAccessTime : 8/1/2023 10:19:44 PM + Mode : -a---- + LinkType : + Target : + VersionInfo : File: gfdgdgggf + InternalName: + OriginalFilename: + FileVersion: + FileDescription: + Product: + ProductVersion: + Debug: False + Patched: False + PreRelease: False + PrivateBuild: False + SpecialBuild: False + Language: + """, # noqa: E501, W291, W605, W293 + """ + Directory: fdsfdsf + + Name : Base_W19.vhdx + if ($_ -is [System.IO.DirectoryInfo]) { return '' } + if ($_.Attributes -band [System.IO.FileAttributes]::Offline) + { + return '({0})' -f $_.Length + } + return $_.Length + : + 19990052864 + CreationTime : 7/6/2023 9:41:18 AM + LastWriteTime + : + 7/7/2023 7:44:33 AM + LastAccessTime : 8/1/2023 10:19:44 PM + Mode : -a---- + LinkType : + Target : + VersionInfo : File: \\src\img.vhdx + """, # noqa: E501, W291, W605, W293 + ] + expected_value = {"lwt": "7/7/2023 7:44:33 AM", "length": "19990052864"} + + for output in outputs: + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + assert hypervisor._get_file_metadata(mocker.Mock()) == expected_value + + def test_get_file_metadata_fail(self, hypervisor, mocker): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="stderr" + ) + + with pytest.raises(HyperVException): + hypervisor._get_file_metadata(mocker.Mock()) + + def test_is_same_metadata(self, hypervisor): + file1_metadata = {"lwt": "7/7/2023 7:44:33 AM", "length": "19990052864"} + + file2_metadatas = [ + # the same + {"lwt": "7/7/2023 7:44:33 AM", "length": "19990052864"}, + # 12 hour later + {"lwt": "7/7/2023 7:44:33 PM", "length": "19990052864"}, + # 300 seconds later + {"lwt": "7/7/2023 7:49:33 AM", "length": "19990052864"}, + # 300 seconds earlier + {"lwt": "7/7/2023 7:39:33 AM", "length": "19990052864"}, + # 301 seconds later + {"lwt": "7/7/2023 7:49:34 AM", "length": "19990052864"}, + # 301 seconds earlier + {"lwt": "7/7/2023 7:39:32 AM", "length": "19990052864"}, + # different size + {"lwt": "7/7/2023 7:44:33 AM", "length": "1"}, + ] + + expected_results = [True, False, True, True, False, False, False] + metadata_pairs = map(lambda file2_metadata: (file1_metadata, file2_metadata), file2_metadatas) + + for pair, expected_result in zip(metadata_pairs, expected_results): + f1_metadata, f2_metadata = pair + assert hypervisor._is_same_metadata(f1_metadata, f2_metadata) == expected_result + + def test_is_latest_image(self, hypervisor, mocker): + mocker.patch("pathlib.Path.exists", return_value=False) + + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._get_file_metadata") + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._is_same_metadata", return_value=True) + assert hypervisor.is_latest_image(mocker.Mock(), r"C:\\src") + + def test_is_latest_image_non_existent(self, hypervisor, mocker): + hypervisor._connection.path.return_value = Path("D:\\dst\\img.vhdx") + mocker.patch("pathlib.Path.exists", return_value=False) + + assert hypervisor.is_latest_image(mocker.Mock(), r"C:\\src") + + def test_get_vm_template_image_present_latest(self, hypervisor, mocker): + disks = {"emu": {"free": "110013030400", "total": "254060523520"}} + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._get_disks_free_space", return_value=disks) + hypervisor._connection.path.return_value = Path("dst\\img.vhdx") + mocker.patch("pathlib.Path.exists", return_value=False) + + output = r""" + emu\VM-Template\Base_R86.vhdx + emu\VM-Template\Base_R92.vhdx + emu\VM-Template\Base_W19.vhdx + emu\VM-Template\Base_W22.vhdx + """ + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + hypervisor._connection.path = Path + + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.is_latest_image", return_value=True) + mocker.patch("mfd_connect.util.rpc_copy_utils._check_paths") + + assert hypervisor.get_vm_template("Base_R86", r"C:\\src") == str(Path(r"emu/VM-Template/Base_R86.vhdx")) + + def test_get_vm_template_image_present_not_latest(self, hypervisor, mocker): + disks = {"emu": {"free": "110013030400", "total": "254060523520"}} + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._get_disks_free_space", return_value=disks) + hypervisor._connection.path.return_value = Path("dst\\img.vhdx") + mocker.patch("pathlib.Path.exists", return_value=False) + + output = r""" + emu\VM-Template\Base_R86.vhdx + emu\VM-Template\Base_R92.vhdx + emu\VM-Template\Base_W19.vhdx + emu\VM-Template\Base_W22.vhdx + """ + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + hypervisor._connection.path = Path + + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.is_latest_image", return_value=False) + mocker.patch("mfd_connect.util.rpc_copy_utils._check_paths") + + mocker.patch( + "mfd_hyperv.hypervisor.HypervHypervisor.copy_vm_image", + return_value=str(Path(r"emu/VM-Template/Base_R86.vhdx")), + ) + + assert hypervisor.get_vm_template("Base_R86", r"C:\\src") == str(Path(r"emu/VM-Template/Base_R86.vhdx")) + + def test_get_vm_template_image_not_present_need_copy(self, hypervisor, mocker): + disks = {"D:\\": {"free": "110013030400", "total": "254060523520"}} + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._get_disks_free_space", return_value=disks) + hypervisor._connection.path.return_value = Path("D:\\dst\\img.vhdx") + mocker.patch("pathlib.Path.exists", return_value=False) + + output = """ + D:\\VM-Template\\Base_R86.vhdx + D:\\VM-Template\\Base_R92.vhdx + D:\\VM-Template\\Base_W19.vhdx + D:\\VM-Template\\Base_W22.vhdx + """ + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + hypervisor._connection.path = Path + + mocker.patch( + "mfd_hyperv.hypervisor.HypervHypervisor.copy_vm_image", return_value="D:\\VM-Template\\Base_R99.vhdx" + ) + + assert hypervisor.get_vm_template("Base_R99", r"C:\\src") == "D:\\VM-Template\\Base_R99.vhdx" + + def test_create_differencing_disk(self, hypervisor, mocker): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + hypervisor._connection.path.return_value = Path("D:\\dst\\img.vhdx") + mocker.patch("pathlib.Path.exists", return_value=True) + + hypervisor.create_differencing_disk(mocker.Mock(), "C:\\", "img,vhdx") + + def test_create_differencing_disk_failed(self, hypervisor, mocker): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + hypervisor._connection.path.return_value = Path("D:\\dst\\img.vhdx") + mocker.patch("pathlib.Path.exists", return_value=False) + + with pytest.raises(HyperVException, match="Command execution succeed but disk doesn't exist "): + hypervisor.create_differencing_disk(mocker.Mock(), "C:\\", "img,vhdx") + + def test_remove_differencing_disk(self, hypervisor): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + hypervisor.remove_differencing_disk("tst") + + hypervisor._connection.execute_powershell.assert_called_once_with( + "Remove-Item tst", custom_exception=HyperVExecutionException + ) + + def test_get_hyperv_vm_ips(self, hypervisor, mocker): + ip_data = """ + 1.1.1.1 + 1.1.1.1 + + [hv] + 1.2.1.2 + 1.2.1.3 + 1.3.1.2 + """ + hypervisor._connection.path.return_value = Path("D:\\") + mocker.patch("pathlib.Path.read_text", return_value=ip_data) + hypervisor._connection._ip = "1.2.1.1" + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._get_mng_mask", return_value=16) + + expected_items = [IPAddress("1.2.1.2"), IPAddress("1.2.1.3")] + assert hypervisor.get_hyperv_vm_ips(r"C:\\src\file.txt") == expected_items + + def test_get_mng_mask(self, hypervisor): + output = """ + Ethernet adapter vEthernet (VSWITCH_02): + + Connection-specific DNS Suffix . : + Link-local IPv6 Address . . . . . : fdsfsfs + IPv4 Address. . . . . . . . . . . : 1.112.1.1 + Subnet Mask . . . . . . . . . . . : 255.0.0.0 + Default Gateway . . . . . . . . . : + + Ethernet adapter vEthernet (managementvSwitch): + + Connection-specific DNS Suffix . : www.com + Link-local IPv6 Address . . . . . : fdsfsfs + IPv4 Address. . . . . . . . . . . : 1.2.1.1 + Subnet Mask . . . . . . . . . . . : 255.255.0.0 + Default Gateway . . . . . . . . . : fdsf + """ + + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + hypervisor._connection._ip = "1.2.1.1" + + assert hypervisor._get_mng_mask() == 16 + + def test_get_free_ips(self, hypervisor, mocker): + mocker.patch("mfd_hyperv.hypervisor.TimeoutCounter", return_value=False) + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor._vm_connectivity_test", return_value=False) + + expected_items = [IPAddress("1.2.1.2"), IPAddress("1.2.1.3")] + result = hypervisor.get_free_ips(expected_items, 2) + assert all([item in result for item in expected_items]) + + def test_format_mac(self, hypervisor): + data = [("1.1.1.1", "FF:FF:FF", "ff:ff:ff:01:01:01"), ("1.255.255.255", "FF:FF:FF", "ff:ff:ff:ff:ff:ff")] + + for ip, prefix, result in data: + assert hypervisor.format_mac(ip, prefix) == result + + def test_wait_vm_mng_ips(self, hypervisor, mocker): + mocker.patch("mfd_hyperv.hypervisor.time.sleep") + mocker.patch("mfd_hyperv.hypervisor.TimeoutCounter", return_value=False) + + output_positive = """ + VMName : Base_W19_VM001 + IPAddresses : {10.91.218.16, fe80::6994:9bd4:d0aa:ff4d} + MacAddress : 525A005BDA10 + """ + + output_negative = """ + VMName : Base_W19_VM001 + IPAddresses : {fe80::6994:9bd4:d0aa:ff4d} + MacAddress : 525A005BDA10 + """ + + hypervisor._connection.execute_powershell.side_effect = [ + ConnectionCompletedProcess(return_code=0, args="command", stdout=output_negative, stderr="stderr"), + ConnectionCompletedProcess(return_code=0, args="command", stdout=output_positive, stderr="stderr"), + ] + + hypervisor._wait_vm_mng_ips("Base_R92_ps_VM001") + + assert hypervisor._connection.execute_powershell.call_count == 2 + + def test_wait_vm_mng_ips_failed(self, hypervisor, mocker): + mocker.patch("mfd_hyperv.hypervisor.TimeoutCounter", return_value=True) + + with pytest.raises(HyperVException, match="Problem with setting IP on mng adapter on one of VMs"): + hypervisor._wait_vm_mng_ips("Base_R92_ps_VM001") + + def test_remove_folder_contents(self, hypervisor, mocker): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + dir_path = mocker.Mock() + hypervisor._remove_folder_contents(dir_path) + + hypervisor._connection.execute_powershell.assert_called_once_with( + "get-childitem -Recurse | remove-item -recurse -confirm:$false", + cwd=dir_path, + ) + + def test_is_folder_empty(self, hypervisor, mocker): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + path = mocker.Mock() + hypervisor._is_folder_empty(path) + + hypervisor._connection.execute_powershell.assert_called_once_with( + "Get-ChildItem | select -ExpandProperty fullname", cwd=path, expected_return_codes={} + ) + + def testget_file_size(self, hypervisor, mocker): + hypervisor._connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="213", stderr="stderr" + ) + path = mocker.Mock() + assert hypervisor.get_file_size(path) == 213 + + hypervisor._connection.execute_powershell.assert_called_once_with( + f"(Get-Item -Path {path}).Length", custom_exception=HyperVExecutionException + ) diff --git a/tests/unit/test_mfd_hyperv/test_vm.py b/tests/unit/test_mfd_hyperv/test_vm.py new file mode 100644 index 0000000..c482314 --- /dev/null +++ b/tests/unit/test_mfd_hyperv/test_vm.py @@ -0,0 +1,156 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Tests for `mfd_hyperv` vm.""" + +import pytest +from mfd_connect import LocalConnection +from mfd_typing import MACAddress, OSName +from mfd_typing.network_interface import InterfaceType + +from mfd_hyperv import HyperV +from mfd_hyperv.attributes.vm_params import VMParams +from mfd_hyperv.instances.vm import VM + + +class TestVM: + @pytest.fixture() + def vm(self, mocker): + vm_params = VMParams( + name="vm_name", + cpu_count=4, + hw_threads_per_core=0, + memory=4096, + generation=2, + vm_dir_path="path", + diff_disk_path="diff_disk_path", + mng_interface_name="mng", + mng_mac_address=MACAddress("00:00:00:00:00:00"), + mng_ip="1.1.1.1", + vswitch_name="vswitch", + ) + + conn = mocker.create_autospec(LocalConnection) + conn.get_os_name.return_value = OSName.WINDOWS + vm = VM(connection=conn, vm_params=vm_params, owner=mocker.Mock(), hyperv=HyperV(connection=conn)) + mocker.stopall() + return vm + + def test_object(self, vm): + vm_params = VMParams( + name="vm_name", + cpu_count=4, + hw_threads_per_core=0, + memory=4096, + generation=2, + vm_dir_path="path", + diff_disk_path="diff_disk_path", + mng_interface_name="mng", + mng_mac_address=MACAddress("00:00:00:00:00:00"), + mng_ip="1.1.1.1", + vswitch_name="vswitch", + ) + + vm_dict = vm.__dict__ + + del vm_dict["owner"] + del vm_dict["guest"] + del vm_dict["_hyperv"] + del vm_dict["attributes"] + del vm_dict["connection"] + del vm_dict["connection_timeout"] + + assert vm_dict == vm_params.__dict__ + + def test_get_attributes(self, vm, mocker): + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.get_vm_attributes", return_value="x") + assert vm.get_attributes() == "x" + + def test_start(self, vm, mocker): + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.start_vm", return_value="x") + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.wait_vm_functional", return_value="x") + mocker.patch("mfd_hyperv.instances.vm.RPyCConnection") + + vm.start() + + def test_match_interfaces(self, vm, mocker): + from_host_vm_interfaces = [ + {"name": "x", "macaddress": "00:00:00:00:00:01"}, + {"name": "y", "macaddress": "00:00:00:00:00:02"}, + ] + vm.hyperv.vm_network_interface_manager.all_vnics_attributes = {} + mocker.patch("mfd_hyperv.instances.vm.VM.get_vm_interfaces", return_value=from_host_vm_interfaces) + + iface1 = mocker.Mock() + iface1.mac_address = MACAddress("00:00:00:00:00:01") + iface1.interface_type = InterfaceType.VMNIC + iface2 = mocker.Mock() + iface2.mac_address = MACAddress("00:00:00:00:00:02") + iface2.interface_type = InterfaceType.VF + iface3 = mocker.Mock() + iface3.mac_address = MACAddress("00:00:00:00:00:01") + iface3.interface_type = InterfaceType.VF + iface4 = mocker.Mock() + iface4.mac_address = MACAddress("00:00:00:00:00:02") + iface4.interface_type = InterfaceType.VMNIC + from_vm_interfaces = [iface1, iface2, iface3, iface4] + vm.guest = mocker.Mock() + vm.guest.get_interfaces.return_value = from_vm_interfaces + + vm_iface1 = mocker.Mock() + vm_iface1.interface_name = "x" + vm_iface1.vm = vm + vm_iface2 = mocker.Mock() + vm_iface2.interface_name = "y" + vm_iface2.vm = vm + + created_vm_interfaces = [vm_iface1, vm_iface2] + vm.hyperv.vm_network_interface_manager.vm_interfaces = created_vm_interfaces + + result = vm.match_interfaces() + + assert result[0].interface.vf == iface3 + assert result[1].interface.vf == iface2 + + def test_stop(self, vm, mocker): + mocker.patch("mfd_hyperv.instances.vm.RPyCConnection.shutdown_platform", return_value="x") + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.wait_vm_stopped", return_value="x") + sleeper = mocker.patch("mfd_hyperv.instances.vm.sleep", return_value=None) + + vm.stop(300) + vm.hyperv.hypervisor.wait_vm_stopped.assert_called_once_with("vm_name", 300) + vm.connection.shutdown_platform.assert_called_once() + sleeper.assert_called_once() + + def test_stop_eoferror(self, vm, mocker): + mocker.patch("mfd_hyperv.instances.vm.RPyCConnection.shutdown_platform", return_value="x") + mocker.patch("mfd_hyperv.hypervisor.HypervHypervisor.wait_vm_stopped", return_value="x") + sleeper = mocker.patch("mfd_hyperv.instances.vm.sleep", return_value=None) + + vm.connection.shutdown_platform.side_effect = EOFError + vm.stop(timeout=300) + vm.connection.shutdown_platform.assert_called_once() + vm.hyperv.hypervisor.wait_vm_stopped.assert_not_called() + sleeper.assert_not_called() + + def test_reboot(self, vm, mocker): + mocker.patch("mfd_hyperv.instances.vm.RPyCConnection.restart_platform", return_value="x") + sleeper = mocker.patch("mfd_hyperv.instances.vm.sleep", return_value="x") + + vm.wait_functional = mocker.Mock() + + vm.reboot(300) + vm.connection.restart_platform.assert_called_once() + sleeper.assert_called_once() + vm.wait_functional.assert_called_once() + + def test_reboot_eoferror(self, vm, mocker): + mocker.patch("mfd_hyperv.instances.vm.RPyCConnection.restart_platform", return_value="x") + sleeper = mocker.patch("mfd_hyperv.instances.vm.sleep", return_value="x") + + vm.wait_functional = mocker.Mock() + vm.connection.restart_platform.side_effect = EOFError + + vm.reboot(300) + vm.connection.restart_platform.assert_called_once() + sleeper.assert_not_called() + vm.wait_functional.assert_called_once() diff --git a/tests/unit/test_mfd_hyperv/test_vm_network_interface.py b/tests/unit/test_mfd_hyperv/test_vm_network_interface.py new file mode 100644 index 0000000..c025044 --- /dev/null +++ b/tests/unit/test_mfd_hyperv/test_vm_network_interface.py @@ -0,0 +1,91 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Tests for `mfd_hyperv` vm network interface.""" +from textwrap import dedent + +import pytest +from mfd_connect import LocalConnection +from mfd_connect.util.powershell_utils import parse_powershell_list +from mfd_typing import OSName + +from mfd_hyperv.instances.vm_network_interface import VMNetworkInterface + + +class TestVNetworkInterface: + @pytest.fixture() + def vmnic(self, mocker): + conn = mocker.create_autospec(LocalConnection) + conn.get_os_name.return_value = OSName.WINDOWS + vmnic = VMNetworkInterface( + connection=conn, + interface_name="ifname", + vm_name="vmname", + vswitch_name="vswitch_name", + sriov=True, + vmq=True, + vm=mocker.Mock(), + vswitch=mocker.Mock(), + ) + mocker.stopall() + return vmnic + + def test_set_and_verify_attribute(self, vmnic, mocker): + mocker.patch("mfd_hyperv.vm_network_interface_manager.VMNetworkInterfaceManager.set_vm_interface_attribute") + mocker.patch("time.sleep") + + attrs = [{"test": "val", "name": "ifname"}] + vmnic.vm.hyperv.vm_network_interface_manager.get_vm_interface_attributes.return_value = attrs + + vmnic.set_and_verify_attribute("test", "val") + + def test_connect_to_vswitch(self, vmnic, mocker): + vswitch = mocker.Mock() + vmnic.connect_to_vswitch(vswitch) + + assert vmnic.vswitch == vswitch + + def test_get_vlan_id(self, vmnic): + output = dedent( + """ + + OperationMode : Access + AccessVlanId : 21 + NativeVlanId : 0 + AllowedVlanIdList : {} + AllowedVlanIdListString : + PrivateVlanMode : 0 + PrimaryVlanId : 0 + SecondaryVlanId : 0 + SecondaryVlanIdList : + SecondaryVlanIdListString : + ParentAdapter : VMNetworkAdapter (Name = 'VM001_vnic_002', VMName = 'Base_W19_VM001') [VMId = '649efb9b-effe-463b-937d-df9a2fcb68a7'] + IsTemplate : False + CimSession : CimSession: . + ComputerName : AMVAL-216-025 + IsDeleted : False + + """ # noqa: E501 + ) + vmnic.vm.hyperv.vm_network_interface_manager.get_vm_interface_vlan.return_value = parse_powershell_list( + output + )[0] + assert vmnic.get_vlan_id() == "21" + + def test_get_rdma_status(self, vmnic): + output = dedent( + """ + + RdmaWeight : 100 + ParentAdapter : VMNetworkAdapter (Name = 'VM001_vnic_002', VMName = 'Base_W19_VM001') [VMId = '649efb9b-effe-463b-937d-df9a2fcb68a7'] + IsTemplate : False + CimSession : CimSession: . + ComputerName : AMVAL-216-025 + IsDeleted : False + + """ # noqa: E501 + ) + + vmnic.vm.hyperv.vm_network_interface_manager.get_vm_interface_rdma.return_value = parse_powershell_list( + output + )[0] + assert vmnic.get_rdma_status() diff --git a/tests/unit/test_mfd_hyperv/test_vm_network_interface_manager.py b/tests/unit/test_mfd_hyperv/test_vm_network_interface_manager.py new file mode 100644 index 0000000..d8bca2d --- /dev/null +++ b/tests/unit/test_mfd_hyperv/test_vm_network_interface_manager.py @@ -0,0 +1,263 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Tests for `mfd_hyperv` vm network interface manager submodule.""" +from textwrap import dedent + +import pytest +from mfd_common_libs import log_levels +from mfd_connect import LocalConnection +from mfd_connect.base import ConnectionCompletedProcess +from mfd_typing import OSName + +from mfd_hyperv.exceptions import HyperVExecutionException +from mfd_hyperv.vm_network_interface_manager import VMNetworkInterfaceManager, UNTAGGED_VLAN + + +class TestVMNetworkInterfaceManager: + @pytest.fixture() + def vmni_manager(self, mocker): + conn = mocker.create_autospec(LocalConnection) + conn.get_os_name.return_value = OSName.WINDOWS + + vmni_manager = VMNetworkInterfaceManager(connection=conn) + return vmni_manager + + def test_create_vm_network_interface(self, vmni_manager, mocker): + mocker.patch( + "mfd_hyperv.vm_network_interface_manager.VMNetworkInterfaceManager._generate_name", return_value="x" + ) + vmni_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + mocker.patch("mfd_hyperv.vm_network_interface_manager.VMNetworkInterfaceManager.set_vm_interface_attribute") + mocker.patch("mfd_hyperv.instances.vm_network_interface.VMNetworkInterface.get_attributes") + + vmni_manager.create_vm_network_interface("vm_name", "vs_name", True, True) + + vmni_manager.connection.execute_powershell.assert_called_with( + command='Add-VMNetworkAdapter -SwitchName "vs_name" -VMName "vm_name" -Name "x"', expected_return_codes={} + ) + + assert len(vmni_manager.vm_interfaces) == 1 + + def test_remove_vm_interface(self, vmni_manager, mocker): + vmni_manager.vm_interfaces = [ + mocker.Mock(interface_name="x"), + mocker.Mock(interface_name="y"), + ] + + vmni_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + vmni_manager.remove_vm_interface("x", "vm_name") + + vmni_manager.connection.execute_powershell.assert_called_with( + command='Remove-VMNetworkAdapter -VMName vm_name -Name "x"', expected_return_codes={} + ) + + assert len(vmni_manager.vm_interfaces) == 1 + assert vmni_manager.vm_interfaces[0].interface_name == "y" + + def test_connect_vm_interface(self, vmni_manager): + vmni_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + vmni_manager.connect_vm_interface("iname", "vm_name", "vswitch_name") + + cmd = 'Connect-VMNetworkAdapter -VMName vm_name -Name "iname" -SwitchName "*vswitch_name*"' + vmni_manager.connection.execute_powershell.assert_called_with(cmd, expected_return_codes={}) + + def test_disconnect_vm_interface(self, vmni_manager): + vmni_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + vmni_manager.disconnect_vm_interface("iname", "vm_name") + + vmni_manager.connection.execute_powershell.assert_called_with( + command="Disconnect-VMNetworkAdapter -VMName vm_name -Name iname", expected_return_codes={} + ) + + def test_set_vm_interface_attribute(self, vmni_manager): + vmni_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="output", stderr="stderr" + ) + + vmni_manager.set_vm_interface_attribute("iname", "vm_name", "attr", "val") + + vmni_manager.connection.execute_powershell.assert_called_with( + command='Set-VMNetworkAdapter -Name "iname" -VMName vm_name -attr val', expected_return_codes={} + ) + + def test_get_vm_interface_attributes(self, vmni_manager): + out = """ + Name : vm001_vnic_001 + Status : {ok} + IPAddresses : {169.254.168.197, fe80::fd4a:a46a:2c05:90b} + """ + vmni_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=out, stderr="stderr" + ) + + res = vmni_manager.get_vm_interface_attributes("vm_name") + + vmni_manager.connection.execute_powershell.assert_called_with( + command="Get-VMNetworkAdapter -Name * -VMName vm_name | select * | fl", expected_return_codes={} + ) + + assert res[0]["status"] == "{ok}" + assert res[0]["name"] == "vm001_vnic_001" + assert res[0]["ipaddresses"] == "{169.254.168.197, fe80::fd4a:a46a:2c05:90b}" + + def test_get_vm_interfaces(self, vmni_manager): + out = """ + Name : mng + """ + vmni_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=out, stderr="stderr" + ) + + res = vmni_manager.get_vm_interfaces("vm_name") + + vmni_manager.connection.execute_powershell.assert_called_with( + command="Get-VMNetworkAdapter -VMName vm_name | select * | fl", expected_return_codes={} + ) + + assert len(res) == 1 + assert res[0]["name"] == "mng" + + def test_get_vlan_id_for_vswitch_access_mode(self, vmni_manager): + output1 = dedent( + """ + VMNetworkAdapter1 + """ + ) + output2 = dedent( + """ + Name : VMNetworkAdapter1 + OperationMode : Access + AccessVlanId : 10 + """ + ) + vmni_manager.connection.execute_powershell.side_effect = [ + ConnectionCompletedProcess(return_code=0, args="command", stdout=output1, stderr="stderr"), + ConnectionCompletedProcess(return_code=0, args="command", stdout=output2, stderr="stderr"), + ] + vlan_id = vmni_manager.get_vlan_id_for_vswitch("managementvSwitch") + assert vlan_id == 10 + + def test_get_host_os_interfaces(self, vmni_manager): + output = dedent( + """ + Name : HostAdapter1 + Status : {ok} + IPAddresses : {192.168.1.1, fe80::1} + """ + ) + vmni_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + res = vmni_manager.get_host_os_interfaces() + + vmni_manager.connection.execute_powershell.assert_called_with( + command="Get-VMNetworkAdapter -ManagementOS | select * | fl", expected_return_codes={} + ) + + assert len(res) == 1 + assert res[0]["name"] == "hostadapter1" + assert res[0]["status"] == "{ok}" + assert res[0]["ipaddresses"] == "{192.168.1.1, fe80::1}" + + def test_update_host_vnic_attributes(self, vmni_manager, mocker): + output = dedent( + """ + Name : HostAdapter1 + Status : {ok} + IPAddresses : {192.168.1.1, fe80::1} + """ + ) + vmni_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + vmni_manager.vm_interfaces = [mocker.Mock(interface_name="HostAdapter1", attributes=None)] + + vmni_manager.update_host_vnic_attributes("HostAdapter1") + + vmni_manager.connection.execute_powershell.assert_called_with( + command="Get-VMNetworkAdapter -ManagementOS -Name HostAdapter1 | select * | fl", expected_return_codes={} + ) + + assert vmni_manager.vm_interfaces[0].attributes["name"] == "hostadapter1" + + def test_get_vm_interface_attached_to_vswitch(self, vmni_manager): + output = "VMNetworkAdapter1" + vmni_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=output, stderr="stderr" + ) + + res = vmni_manager.get_vm_interface_attached_to_vswitch("managementvSwitch") + + vmni_manager.connection.execute_powershell.assert_called_with( + "(Get-VMNetworkAdapter -ManagementOS | ? { $_.SwitchName -eq 'managementvSwitch'}).Name", + custom_exception=HyperVExecutionException, + ) + + assert res == "VMNetworkAdapter1" + + def test_get_vm_interface_attached_to_vswitch_error(self, vmni_manager): + vmni_manager.connection.execute_powershell.side_effect = HyperVExecutionException( + returncode=1, cmd="", output="", stderr="Error message" + ) + with pytest.raises(HyperVExecutionException): + vmni_manager.get_vm_interface_attached_to_vswitch("managementvSwitch") + + def test_get_vlan_id_for_vswitch_auto_mode(self, vmni_manager, caplog): + caplog.set_level(log_levels.MODULE_DEBUG) + output1 = dedent( + """ + VMNetworkAdapter1 + """ + ) + output2 = dedent( + """ + Name : VMNetworkAdapter1 + Id : 1 + InterfaceDescription : "Virtual Ethernet Adapter for VM Network Adapter 1" + MacAddress : 00-11-22-33-44-55-66-77 + VlanId : 10 + OperationMode : Auto + """ + ) + vmni_manager.connection.execute_powershell.side_effect = [ + ConnectionCompletedProcess(return_code=0, args="command", stdout=output1, stderr="stderr"), + ConnectionCompletedProcess(return_code=0, args="command", stdout=output2, stderr="stderr"), + ] + vlan_id = vmni_manager.get_vlan_id_for_vswitch("managementvSwitch") + assert UNTAGGED_VLAN == vlan_id + assert "Unsupported VLAN mode (Auto) detected" in caplog.messages[1] + + def test_get_vlan_id_for_vswitch_untagged_mode(self, vmni_manager): + output1 = dedent( + """ + VMNetworkAdapter1 + """ + ) + output2 = dedent( + """ + Name : VMNetworkAdapter1 + OperationMode : Untagged + """ + ) + vmni_manager.connection.execute_powershell.side_effect = [ + ConnectionCompletedProcess(return_code=0, args="command", stdout=output1, stderr="stderr"), + ConnectionCompletedProcess(return_code=0, args="command", stdout=output2, stderr="stderr"), + ] + + vlan_id = vmni_manager.get_vlan_id_for_vswitch("managementvSwitch") + + assert vlan_id == 0 diff --git a/tests/unit/test_mfd_hyperv/test_vswitch.py b/tests/unit/test_mfd_hyperv/test_vswitch.py new file mode 100644 index 0000000..cda904b --- /dev/null +++ b/tests/unit/test_mfd_hyperv/test_vswitch.py @@ -0,0 +1,44 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Tests for `mfd_hyperv` vswitch.""" + +import pytest +from mfd_connect import LocalConnection +from mfd_typing import OSName + +from mfd_hyperv.instances.vswitch import VSwitch + + +class TestVNetworkInterface: + @pytest.fixture() + def vswitch(self, mocker): + conn = mocker.create_autospec(LocalConnection) + conn.get_os_name.return_value = OSName.WINDOWS + vswitch = VSwitch( + connection=conn, + interface_name="ifname", + host_adapter_names=["host_adapter_name"], + enable_iov=True, + enable_teaming=False, + host_adapters=[mocker.Mock()], + ) + mocker.stopall() + return vswitch + + def test_set_and_verify_attribute(self, vswitch, mocker): + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.set_vswitch_attribute") + mocker.patch("time.sleep") + + attrs = {"rscoffloadenabled": "10"} + vswitch.owner.hyperv.vswitch_manager.get_vswitch_attributes.return_value = attrs + + vswitch.set_and_verify_attribute("enablerscoffload", "10") + + def test_rename(self, vswitch, mocker): + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.rename_vswitch") + + new_name = "new_test_name" + vswitch.rename(new_name=new_name) + + assert vswitch.name == f"vEthernet ({new_name})" + assert vswitch.interface_name == new_name diff --git a/tests/unit/test_mfd_hyperv/test_vswitch_manager.py b/tests/unit/test_mfd_hyperv/test_vswitch_manager.py new file mode 100644 index 0000000..042ba34 --- /dev/null +++ b/tests/unit/test_mfd_hyperv/test_vswitch_manager.py @@ -0,0 +1,362 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: MIT +"""Tests for `mfd_hyperv` vswitch manager submodule.""" +from textwrap import dedent + +import pytest +from mfd_connect import LocalConnection +from mfd_connect.base import ConnectionCompletedProcess +from mfd_typing import OSName + +from mfd_hyperv.exceptions import HyperVExecutionException, HyperVException +from mfd_hyperv.instances.vswitch import VSwitch +from mfd_hyperv.vswitch_manager import VSwitchManager + + +class TestVswitchManager: + @pytest.fixture() + def vswitch_manager(self, mocker): + conn = mocker.create_autospec(LocalConnection) + conn.get_os_name.return_value = OSName.WINDOWS + + vswitch_manager = VSwitchManager(connection=conn) + mocker.stopall() + return vswitch_manager + + @pytest.fixture() + def vswitch_manager_2_vswitches(self, mocker): + conn = mocker.create_autospec(LocalConnection) + conn.get_os_name.return_value = OSName.WINDOWS + + vswitch_manager = VSwitchManager(connection=conn) + mocker.stopall() + + mocked1 = mocker.Mock() + mocked2 = mocker.Mock() + + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.wait_vswitch_present", return_value=None) + mocker.patch("mfd_hyperv.vswitch_manager.time.sleep") + + vswitch_manager.create_vswitch(["interface1"], "vs_name1", False, False, False, [mocked1]) + vswitch_manager.create_vswitch(["interface2"], "vs_name2", True, False, False, [mocked2]) + + return vswitch_manager + + def test_create_vswitch_teaming_sriov(self, vswitch_manager, mocker): + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager._generate_name", return_value="final_name") + cmd = ( + "powershell.exe \"New-VMSwitch -Name 'final_name' -NetAdapterName " + "'interface1', 'interface2' -AllowManagementOS $true -EnableIov $True " + '-EnableEmbeddedTeaming $true"' + ) + + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.wait_vswitch_present", return_value=None) + mocker.patch("mfd_hyperv.vswitch_manager.time.sleep") + mocked = mocker.Mock() + + vs = vswitch_manager.create_vswitch(["interface1", "interface2"], "vs_name", True, True, False, [mocked]) + tested = VSwitch("final_name", "'interface1', 'interface2'", True, True, vswitch_manager.connection, [mocked]) + + vswitch_manager.connection.start_process.assert_called_once_with(cmd, shell=True) + + assert len(vswitch_manager.vswitches) == 1 + assert vs.__dict__ == tested.__dict__ + + def test_create_vswitch_sriov(self, vswitch_manager, mocker): + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager._generate_name", return_value="final_name") + cmd = ( + "powershell.exe \"New-VMSwitch -Name 'final_name' -NetAdapterName " + "'interface' -AllowManagementOS $true -EnableIov $True\"" + ) + + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.wait_vswitch_present", return_value=None) + mocker.patch("mfd_hyperv.vswitch_manager.time.sleep") + mocked = mocker.Mock() + + vs = vswitch_manager.create_vswitch(["interface"], "vs_name", True, False, False, [mocked]) + tested = VSwitch("final_name", "'interface'", True, False, vswitch_manager.connection, [mocked]) + + vswitch_manager.connection.start_process.assert_called_once_with(cmd, shell=True) + + assert len(vswitch_manager.vswitches) == 1 + assert vs.__dict__ == tested.__dict__ + + def test_create_vswitch(self, vswitch_manager, mocker): + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager._generate_name", return_value="final_name") + cmd = ( + "powershell.exe \"New-VMSwitch -Name 'final_name' -NetAdapterName " + "'interface' -AllowManagementOS $true -EnableIov $False\"" + ) + + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.wait_vswitch_present", return_value=None) + mocker.patch("mfd_hyperv.vswitch_manager.time.sleep") + mocked = mocker.Mock() + + vs = vswitch_manager.create_vswitch(["interface"], "vs_name", False, False, False, [mocked]) + tested = VSwitch("final_name", "'interface'", False, False, vswitch_manager.connection, [mocked]) + + vswitch_manager.connection.start_process.assert_called_once_with(cmd, shell=True) + + assert len(vswitch_manager.vswitches) == 1 + assert vs.__dict__ == tested.__dict__ + + def test_create_vswitch_mng(self, vswitch_manager, mocker): + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager._generate_name", return_value="final_name") + cmd = ( + "powershell.exe \"New-VMSwitch -Name 'vs_name' -NetAdapterName 'interface' " + '-AllowManagementOS $true -EnableIov $False"' + ) + + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.wait_vswitch_present", return_value=None) + mocker.patch("mfd_hyperv.vswitch_manager.time.sleep") + mocked = mocker.Mock() + + vs = vswitch_manager.create_vswitch(["interface"], "vs_name", False, False, True, [mocked]) + tested = VSwitch("vs_name", "'interface'", False, False, vswitch_manager.connection, [mocked]) + + vswitch_manager.connection.start_process.assert_called_once_with(cmd, shell=True) + + assert len(vswitch_manager.vswitches) == 0 + assert vs.__dict__ == tested.__dict__ + + def test_remove_vswitch(self, vswitch_manager_2_vswitches, mocker): + assert len(vswitch_manager_2_vswitches.vswitches) == 2 + + vswitch_manager_2_vswitches.remove_vswitch("vs_name2_02") + + vswitch_manager_2_vswitches.connection.execute_powershell.assert_called_once_with( + "Remove-VMSwitch vs_name2_02 -Force", custom_exception=HyperVExecutionException + ) + + assert len(vswitch_manager_2_vswitches.vswitches) == 1 + + def test_generate_name(self, vswitch_manager): + assert vswitch_manager._generate_name("aaa", True) == "aaa_01_T" + assert vswitch_manager._generate_name("aaa", False) == "aaa_02" + + def test_generate_name_more(self, vswitch_manager_2_vswitches): + assert vswitch_manager_2_vswitches._generate_name("aaa", True) == "aaa_03_T" + assert vswitch_manager_2_vswitches._generate_name("aaa", False) == "aaa_04" + + def test_create_mng_vswitch_present(self, vswitch_manager, mocker): + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.is_vswitch_present", return_value=True) + vs = vswitch_manager.create_mng_vswitch() + + expected_vs = VSwitch( + interface_name="managementvSwitch", host_adapter_names=[], connection=vswitch_manager.connection + ) + + assert vs.__dict__ == expected_vs.__dict__ + + def test_create_mng_vswitch(self, vswitch_manager, mocker): + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.is_vswitch_present", return_value=False) + + vswitch_manager.connection._ip = "ip_addr" + vswitch_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="iname", stderr="stderr" + ) + + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.wait_vswitch_present", return_value=None) + mocker.patch("mfd_hyperv.vswitch_manager.time.sleep") + + vs = vswitch_manager.create_mng_vswitch() + + cmd = ( + "Get-NetIPAddress | Where-Object -Property IPAddress -EQ ip_addr | " + "Where-Object -Property AddressFamily -EQ 'IPv4' | select -ExpandProperty InterfaceAlias" + ) + vswitch_manager.connection.execute_powershell.assert_called_with(cmd, expected_return_codes={0}) + + expected_vs = VSwitch("managementvSwitch", "'iname'", False, False, vswitch_manager.connection) + + assert vs.__dict__ == expected_vs.__dict__ + + def test_get_vswitch_attributes(self, vswitch_manager, mocker): + out = """ + Name : managementvSwitch + Id : 98094e00-0df1-485b-875c-9a00d9af2f75 + Notes : + Extensions : {Microsoft Windows Filtering Platform, Microsoft NDIS Capture} + BandwidthReservationMode : Absolute + PacketDirectEnabled : False + EmbeddedTeamingEnabled : False + AllowNetLbfoTeams : False + IovEnabled : False + SwitchType : External + AllowManagementOS : True + NetAdapterInterfaceDescription : Intel(R) Ethernet Controller X550 + NetAdapterInterfaceDescriptions : {Intel(R) Ethernet Controller X550} + NetAdapterInterfaceGuid : {889a6b6b-9b94-48cc-929a-ed4b7ec5d3de} + IovSupport : True + IovSupportReasons : + AvailableIPSecSA : 2048 + NumberIPSecSAAllocated : 0 + AvailableVMQueues : 31 + NumberVmqAllocated : 1 + IovQueuePairCount : 127 + IovQueuePairsInUse : 8 + IovVirtualFunctionCount : 0 + IovVirtualFunctionsInUse : 0 + PacketDirectInUse : False + DefaultQueueVrssEnabledRequested : True + DefaultQueueVrssEnabled : True + DefaultQueueVmmqEnabledRequested : True + DefaultQueueVmmqEnabled : True + DefaultQueueVrssMaxQueuePairsRequested : 16 + DefaultQueueVrssMaxQueuePairs : 4 + DefaultQueueVrssMinQueuePairsRequested : 1 + DefaultQueueVrssMinQueuePairs : 1 + DefaultQueueVrssQueueSchedulingModeRequested : StaticVrss + DefaultQueueVrssQueueSchedulingMode : StaticVrss + DefaultQueueVrssExcludePrimaryProcessorRequested : False + DefaultQueueVrssExcludePrimaryProcessor : False + SoftwareRscEnabled : True + RscOffloadEnabled : False + BandwidthPercentage : 10 + DefaultFlowMinimumBandwidthAbsolute : 1000000000 + DefaultFlowMinimumBandwidthWeight : 0 + CimSession : CimSession: . + ComputerName : AMVAL-216-025 + IsDeleted : False + DefaultQueueVmmqQueuePairs : 4 + DefaultQueueVmmqQueuePairsRequested : 16 + """ # noqa: E501 + + vswitch_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=out, stderr="stderr" + ) + + result = vswitch_manager.get_vswitch_attributes("managementvSwitch") + + vswitch_manager.connection.execute_powershell.assert_called_with( + "Get-VMSwitch managementvSwitch | select * | fl", expected_return_codes={} + ) + + assert result["defaultqueuevmmqqueuepairsrequested"] == "16" + assert result["iovsupportreasons"] == "" + assert result["extensions"] == "{microsoft windows filtering platform, microsoft ndis capture}" + + def test_set_vswitch_attribute(self, vswitch_manager): + vswitch_manager.set_vswitch_attribute("iname", "key", "value") + + vswitch_manager.connection.execute_powershell.assert_called_with( + "Set-VMSwitch -Name iname -key value", custom_exception=HyperVExecutionException + ) + + def test_remove_tested_vswitches(self, vswitch_manager_2_vswitches): + vswitch_manager_2_vswitches.remove_tested_vswitches() + + cmd = ( + "Get-VMSwitch | Where-Object {$_.Name -ne " + '"managementvSwitch"' + "} | Remove-VMSwitch -force -Confirm:$false" + ) + vswitch_manager_2_vswitches.connection.execute_powershell.assert_called_with( + cmd, custom_exception=HyperVExecutionException + ) + + assert len(vswitch_manager_2_vswitches.vswitches) == 0 + + def test_is_vswitch_present(self, vswitch_manager): + out = """ + managementvSwitch + """ + + vswitch_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=out, stderr="stderr" + ) + + assert vswitch_manager.is_vswitch_present("managementvSwitch") + + assert not vswitch_manager.is_vswitch_present("aasdfd") + + def test_wait_vswitch_present(self, vswitch_manager, mocker): + mocker.patch("mfd_hyperv.vswitch_manager.TimeoutCounter", return_value=False) + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.is_vswitch_present", return_value=True) + + vswitch_manager.wait_vswitch_present("managementvSwitch") + + def test_wait_vswitch_present_failed(self, vswitch_manager, mocker): + mocker.patch("mfd_hyperv.vswitch_manager.TimeoutCounter", return_value=True) + + with pytest.raises(HyperVException, match="Timeout expired. Cannot find vswitch managementvSwitch"): + vswitch_manager.wait_vswitch_present("managementvSwitch") + + def test_rename_vswitch(self, vswitch_manager, mocker): + attrs = {"name": "vswitch_new_name"} + mocker.patch("mfd_hyperv.vswitch_manager.VSwitchManager.get_vswitch_attributes", return_value=attrs) + vswitch_manager.rename_vswitch("vs_01", "vswitch_new_name") + + cmd = 'Rename-VMSwitch "vs_01" -NewName "vswitch_new_name"' + vswitch_manager.connection.execute_powershell.assert_called_with( + cmd, custom_exception=HyperVExecutionException + ) + + def test_get_vswitch_mapping_one_vswitch_found(self, vswitch_manager): + out = dedent( + """ + Name : managementvSwitch + Id : bbaaa964-2ff4-4904-b4da-121a937c6041 + Notes : + Extensions : {Microsoft Windows Filtering Platform, Microsoft NDIS Capture} + BandwidthReservationMode : Absolute + PacketDirectEnabled : False + EmbeddedTeamingEnabled : False + AllowNetLbfoTeams : False + IovEnabled : False + SwitchType : External + AllowManagementOS : True + NetAdapterInterfaceDescription : Intel(R) Ethernet Controller X550 + NetAdapterInterfaceDescriptions : {Intel(R) Ethernet Controller X550} + NetAdapterInterfaceGuid : {bd4d0811-a156-4cb3-9381-a899064196df} + IovSupport : True + IovSupportReasons : + AvailableIPSecSA : 2048 + NumberIPSecSAAllocated : 0 + AvailableVMQueues : 31 + NumberVmqAllocated : 1 + IovQueuePairCount : 127 + IovQueuePairsInUse : 8 + IovVirtualFunctionCount : 0 + IovVirtualFunctionsInUse : 0 + PacketDirectInUse : False + DefaultQueueVrssEnabledRequested : True + DefaultQueueVrssEnabled : True + DefaultQueueVmmqEnabledRequested : True + DefaultQueueVmmqEnabled : True + DefaultQueueVrssMaxQueuePairsRequested : 16 + DefaultQueueVrssMaxQueuePairs : 4 + DefaultQueueVrssMinQueuePairsRequested : 1 + DefaultQueueVrssMinQueuePairs : 1 + DefaultQueueVrssQueueSchedulingModeRequested : StaticVrss + DefaultQueueVrssQueueSchedulingMode : StaticVrss + DefaultQueueVrssExcludePrimaryProcessorRequested : False + DefaultQueueVrssExcludePrimaryProcessor : False + SoftwareRscEnabled : True + RscOffloadEnabled : False + BandwidthPercentage : 10 + DefaultFlowMinimumBandwidthAbsolute : 1000000000 + DefaultFlowMinimumBandwidthWeight : 0 + CimSession : CimSession: . + ComputerName : JASON-55-013 + IsDeleted : False + DefaultQueueVmmqQueuePairs : 4 + DefaultQueueVmmqQueuePairsRequested : 16 + """ # noqa: E501, W605, W291 + ) + vswitch_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout=out, stderr="" + ) + assert {"managementvSwitch": "Intel(R) Ethernet Controller X550"} == vswitch_manager.get_vswitch_mapping() + + def test_get_vswitch_mapping_no_vswitches(self, vswitch_manager): + vswitch_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=0, args="command", stdout="", stderr="" + ) + assert {} == vswitch_manager.get_vswitch_mapping() + + def test_get_vswitch_mapping_error(self, vswitch_manager): + vswitch_manager.connection.execute_powershell.return_value = ConnectionCompletedProcess( + return_code=1, args="command", stdout="", stderr="" + ) + assert {} == vswitch_manager.get_vswitch_mapping()