diff --git a/Makefile b/Makefile index bc626d8d2b..82a9201370 100644 --- a/Makefile +++ b/Makefile @@ -25,3 +25,12 @@ setup: clean requirements: python3 tools/requirements.py + +DOCKER_IMAGE := somecr.io/someengineering +docker-build: + docker build -t ghcr.io/someengineering/fixinventorybase:$(IMAGE_TAG) . -f Dockerfile.fixinventorybase + docker build -t $(DOCKER_IMAGE)/fixcore:$(IMAGE_TAG) . -f Dockerfile.fixcore + docker build -t $(DOCKER_IMAGE)/fixworker:$(IMAGE_TAG) . -f Dockerfile.fixworker + docker build -t $(DOCKER_IMAGE)/fixmetrics:$(IMAGE_TAG) . -f Dockerfile.fixmetrics + docker build -t $(DOCKER_IMAGE)/fixshell:$(IMAGE_TAG) . -f Dockerfile.fixshell + diff --git a/docker-compose.yaml b/docker-compose.yaml index a7d29b6b94..bea0bb9a06 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,6 @@ services: graphdb-upgrade: - image: arangodb:3.10.1 + image: arangodb:3.8.9 container_name: graphdb-upgrade environment: - ARANGO_ROOT_PASSWORD= @@ -11,7 +11,7 @@ services: command: - --database.auto-upgrade graphdb: - image: arangodb:3.10.1 + image: arangodb:3.8.9 depends_on: graphdb-upgrade: condition: service_completed_successfully diff --git a/plugins/nutanix/MANIFEST.in b/plugins/nutanix/MANIFEST.in new file mode 100644 index 0000000000..bb3ec5f0d4 --- /dev/null +++ b/plugins/nutanix/MANIFEST.in @@ -0,0 +1 @@ +include README.md diff --git a/plugins/nutanix/Makefile b/plugins/nutanix/Makefile new file mode 100644 index 0000000000..83c19145d1 --- /dev/null +++ b/plugins/nutanix/Makefile @@ -0,0 +1,52 @@ +.PHONY: clean clean-test clean-pyc clean-build docs help test +.DEFAULT_GOAL := help +.SILENT: clean clean-build clean-pyc clean-test + +clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts + +clean-build: ## remove build artifacts + rm -fr build/ + rm -fr out/ + rm -fr gen/ + rm -fr dist/ + rm -fr .eggs/ + rm -fr .hypothesis/ + rm -fr .mypy_cache/ + find . -name '*.egg-info' -exec rm -fr {} + + find . -name '*.egg' -exec rm -fr {} + + +clean-pyc: ## remove Python file artifacts + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + find . -name '__pycache__' -exec rm -fr {} + + +clean-test: ## remove test and coverage artifacts + rm -fr .tox/ + rm -f .coverage + rm -fr htmlcov/ + rm -fr .pytest_cache + +lint: ## static code analysis + black --line-length 120 --check fix_plugin_nutanix test + flake8 fix_plugin_nutanix + mypy --python-version 3.11 --strict --install-types fix_plugin_nutanix test + +test: ## run tests quickly with the default Python + pytest + +test-all: ## run tests on every Python version with tox + tox + +coverage: ## check code coverage quickly with the default Python + coverage run --source fix_plugin_aws -m pytest + coverage combine + coverage report -m + coverage html + $(BROWSER) htmlcov/index.html + +list-outdated: + pip list --outdated + +install-latest: + pip list --outdated --format=freeze | grep -v '^\-e' | cut -d = -f 1 | xargs -n1 pip install -U diff --git a/plugins/nutanix/README.md b/plugins/nutanix/README.md new file mode 100644 index 0000000000..41168c27ed --- /dev/null +++ b/plugins/nutanix/README.md @@ -0,0 +1,32 @@ +# fix-plugin-aws +An AWS collector plugin for Fix. + +## Usage +For details on how to edit configuration, please see [the documentation](https://inventory.fix.security/docs/getting-started/configuring-fix). + +When the collector is enabled (`fixworker.collector = [aws]`) it will automatically collect any accounts the AWS boto3 SDK can authenticate for. +By default it will check for environment variables like `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` or `AWS_SESSION_TOKEN`. + +If Fix should assume an IAM role that role can be given via `fixworker.aws.role = SomeRoleName`. + +The collector will scrape resources in all regions unless regions are specified using e.g. `fixworker.aws.region = [us-east-1, us-west-2]`. + + +## Scraping multiple accounts +If the given credentials are allowed to assume the specified role in other accounts of your AWS organisation, Fix +can collect multiple accounts at the same time. To do so provide the account IDs to the `fixworker.aws.account` configuration. + +## Scraping the entire organisation +Instead of giving a list of account IDs manually you could also specify `fixworker.aws.scrape_org`, which will make Fix try to get the list of all accounts using the [ListAccounts](https://docs.aws.amazon.com/organizations/latest/APIReference/API_ListAccounts.html) API. + +If certain accounts are to be excluded from that list they can be specified using the `fixworker.aws.scrape_exclude_account` config option. + +## Miscellaneous Options +When collecting multiple accounts Fix by default will collect the accounts it finds in the org as well as the one it is currently authenticated as. +If you do not want it to scrape the account that was used to get the list of all org accounts (e.g. your root account) you can specify `fixworker.aws.dont_scrape_current`. + +If instead of using the current credentials you would like Fix to assume the specified role (`fixworker.aws.role`) even for the current account you can specify the options +`fixworker.aws.assume_current` and `fixworker.aws.dont_scrape_current`. This would make it so that Fix does not scrape the current account using default credentials but instead assume the specified IAM role even for the current account. + +## License +See [LICENSE](../../LICENSE) for details. diff --git a/plugins/nutanix/fix_plugin_nutanix/__init__.py b/plugins/nutanix/fix_plugin_nutanix/__init__.py new file mode 100644 index 0000000000..fe804fd50a --- /dev/null +++ b/plugins/nutanix/fix_plugin_nutanix/__init__.py @@ -0,0 +1,114 @@ +import fixlib.logger + +from ntnx_clustermgmt_py_client import Configuration as ClusterConfiguration +from ntnx_clustermgmt_py_client import ApiClient as ClusterClient + +from ntnx_vmm_py_client import Configuration as VMMConfiguration +from ntnx_vmm_py_client import ApiClient as VMMClient + +from attrs import define, field +from typing import List, Optional, cast +from fixlib.baseplugin import BaseCollectorPlugin +from fixlib.graph import Graph +from fixlib.args import ArgumentParser +from fixlib.config import Config + +from fix_plugin_nutanix.collector import PrismCentralCollector +from fix_plugin_nutanix.resources import PrismCentralAccount +from fix_plugin_nutanix.config import PrismCentalCredentials, PrismCentralColletorConfig + +log = fixlib.logger.getLogger("fix." + __name__) + + +class NutanixCollectorPlugin(BaseCollectorPlugin): + """Nutanix Collector Plugin""" + + cloud = "nutanix" + + def collect(self) -> None: + """This method is being called by fix whenever the collector runs + + It is responsible for querying the cloud APIs for remote resources and adding + them to the plugin graph. + The graph root (self.graph.root) must always be followed by one or more + accounts. An account must always be followed by a region. + A region can contain arbitrary resources. + """ + log.debug("plugin: collecting nutanix resources") + + pcConfigs = cast(List[PrismCentalCredentials], Config.nutanix.credentials) + for pc in pcConfigs: + pcAccount = PrismCentralAccount( + id=pc.name.replace(" ", "_"), + name=pc.name, + endpoint=pc.endpoint, + username=pc.username, + password=pc.password, + port=pc.port, + insecure=pc.insecure, + ) + pc_graph = self.collect_pc(pcAccount) + if pc_graph: + self.send_account_graph(pc_graph) + + def collect_pc(self, prismCentral: PrismCentralAccount) -> Graph: + log.info(f"Collecting data from Nutanix Prism Central {prismCentral.name}") + vmmClient = vmm_client(prismCentral) + clusterClient = cluster_client(prismCentral) + prismCentralCollector = PrismCentralCollector( + prismCentral, vmmClient, clusterClient + ) + return prismCentralCollector.collect() + + @staticmethod + def add_config(config: Config) -> None: + """Add any plugin config to the global config store. + + Method called by the PluginLoader upon plugin initialization. + Can be used to introduce plugin config arguments to the global config store. + """ + config.add_config(PrismCentralColletorConfig) + + @staticmethod + def add_args(arg_parser: ArgumentParser) -> None: + """Example of how to use the ArgumentParser + + Can be accessed via ArgumentParser.args.example_arg + Note: almost all plugin config should be done via add_config() + so it can be changed centrally and at runtime. + """ + pass + + +def cluster_client(pc: PrismCentralAccount) -> ClusterClient: + cluster_config = ClusterConfiguration() + cluster_config.host = pc.endpoint + cluster_config.port = pc.port + cluster_config.verify_ssl = not pc.insecure + cluster_config.username = pc.username + cluster_config.password = pc.password + cluster_client = ClusterClient(configuration=cluster_config) + cluster_client.add_default_header( + header_name="Accept-Encoding", header_value="gzip, deflate, br" + ) + cluster_client.add_default_header( + header_name="Content-Type", header_value="application/json" + ) + return cluster_client + + +def vmm_client(pc: PrismCentralAccount) -> VMMClient: + vmm_config = VMMConfiguration() + vmm_config.host = pc.endpoint + vmm_config.port = pc.port + vmm_config.verify_ssl = not pc.insecure + vmm_config.username = pc.username + vmm_config.password = pc.password + vmm_client = VMMClient(configuration=vmm_config) + vmm_client.add_default_header( + header_name="Accept-Encoding", header_value="gzip, deflate, br" + ) + vmm_client.add_default_header( + header_name="Content-Type", header_value="application/json" + ) + return vmm_client diff --git a/plugins/nutanix/fix_plugin_nutanix/collector.py b/plugins/nutanix/fix_plugin_nutanix/collector.py new file mode 100644 index 0000000000..bf457cfa3f --- /dev/null +++ b/plugins/nutanix/fix_plugin_nutanix/collector.py @@ -0,0 +1,118 @@ +import logging + +import ntnx_clustermgmt_py_client +from ntnx_clustermgmt_py_client import ApiClient as ClusterClient + +from typing import Tuple, List, Dict, Callable + +import ntnx_vmm_py_client +from ntnx_vmm_py_client import ApiClient as VMMClient +from fix_plugin_nutanix.resources import ( + PrismCentralAccount, + PrismElement, + ViraualMachine, +) +from fixlib.graph import Graph + +log = logging.getLogger("fix." + __name__) + + +class PrismCentralCollector: + """ + Collects data from single Nutanix Prism Central instance + """ + + def __init__( + self, + prismCentral: PrismCentralAccount, + vmmClient: VMMClient, + clusterClient: ClusterClient, + ) -> None: + self.vmmClient = vmmClient + self.clusterClient = clusterClient + self.prismCentral = prismCentral + # pe_collectors collectors that are always collected + self.mandatoryCollectors: List[Tuple[str, Callable[..., None]]] = [ + ("prism_elements", self.collect_prism_element) + ] + # Global collectors are resources that are specified across all PE + self.globalCollectors: List[Tuple[str, Callable[..., None]]] = [ + # ("images", self.collect_images) + ] + # Prism Element collectors are resources that are specific to PE + self.prismElementCollectors: List[Tuple[str, Callable[..., None]]] = [ + ("virtual_machines", self.collect_virtual_machines) + ] + self.allCollectors = dict(self.mandatoryCollectors) + self.allCollectors.update(dict(self.globalCollectors)) + self.allCollectors.update(dict(self.prismElementCollectors)) + self.collecter_set = set(self.allCollectors.keys()) + self.graph = Graph(root=self.prismCentral) + + def collect(self) -> Graph: + """ + Runs resource collectors across all PE + Resource collectors add their resources to the local `self.graph` graph + """ + log.info(f"Collecting data from Nutanix Prism Central: {self.prismCentral.name}") + collectors = set(self.collecter_set) + for collectorName, collector in self.mandatoryCollectors: + if collectorName in collectors: + log.info(f"Running collector: {collectorName} in {self.prismCentral.name}") + collector() + + prismElements = [pe for pe in self.graph.nodes if isinstance(pe, PrismElement)] + for collectorName, collector in self.prismElementCollectors: + for pe in prismElements: + if collectorName in collectors: + log.info(f"Running collector: {collectorName} in {pe.name}") + collector(pe) + return self.graph + + def collect_prism_element(self) -> None: + log.info("Collecting data from all Prism Elements") + clusterApi = ntnx_clustermgmt_py_client.api.ClusterApi(self.clusterClient) + clusters = clusterApi.get_clusters() + log.info(f"Found PEs: {clusters.metadata.total_available_results}") + for cluster in clusters.data: + log.debug(f"Processing PE. uuid: {cluster['extId']}" f", name: {cluster['name']}") + pe = PrismElement( + id=cluster["extId"], + name=cluster["name"], + tags={"cluster_uuid": cluster["extId"]}, + ) + self.graph.add_resource(self.prismCentral, pe) + + def collect_images(self) -> None: + """ + Collects data from all images + """ + log.info("Collecting data from all images") + + def collect_virtual_machines(self, pe: PrismElement) -> None: + log.info(f"Collecting data from all virtual machines in {pe.name}") + # Get all virtual machines in the PE + vmm_instance = ntnx_vmm_py_client.api.VmApi(self.vmmClient) + filter = f"contains(cluster/extId, '{pe.id}')" + response = vmm_instance.list_vms(_filter=filter) + log.info( + f"{pe.name}: Found virtual machines: {response.metadata.total_available_results}" + ) + if response.metadata.total_available_results == 0: + return + for vm in response.data: + log.debug( + f"VM: {vm.name}" + f", uuid: {vm.ext_id}" + f", power_state: {vm.power_state}" + f", create_time: {vm.create_time}" + ) + vm = ViraualMachine( + id=vm.ext_id, + name=vm.name, + tags={"power_state": vm.power_state}, + power_state=vm.power_state, + ctime=vm.create_time, + mtime=vm.update_time, + ) + self.graph.add_resource(pe, vm) diff --git a/plugins/nutanix/fix_plugin_nutanix/config.py b/plugins/nutanix/fix_plugin_nutanix/config.py new file mode 100644 index 0000000000..0f1a748ce8 --- /dev/null +++ b/plugins/nutanix/fix_plugin_nutanix/config.py @@ -0,0 +1,37 @@ +from attrs import define, field +from typing import List, ClassVar, Optional + + +@define +class PrismCentalCredentials: + kind: ClassVar[str] = "prism_central_credentials" + name: str = field( + metadata={"description": "A NickName/Identifier for Prism Central"} + ) + endpoint: str = field( + metadata={ + "description": "Nutanix Prism Central endpoint, without scheme or port." + } + ) + username: str = field(metadata={"description": "Nutanix Prism Central username."}) + password: str = field(metadata={"description": "Nutanix Prism Central password."}) + insecure: Optional[bool] = field( + default=False, + metadata={"description": "Whether to ignore SSL certificate errors."}, + ) + port: Optional[int] = field( + default=9440, + metadata={"description": "Nutanix Prism Central port."}, + ) + + +@define +class PrismCentralColletorConfig: + kind: ClassVar[str] = "nutanix" + credentials: List[PrismCentalCredentials] = field( + factory=list, + metadata={ + "description": "Nutanix Prism Central credentials for the resources to be collected." + "Expected format: [{ 'name': 'my_pc', 'endpoint': 'mypc.example.com', 'insecure': 'true', 'port': '9440', 'username':'my_pc_username', 'password':'my_pc_password'}]." + }, + ) diff --git a/plugins/nutanix/fix_plugin_nutanix/resources.py b/plugins/nutanix/fix_plugin_nutanix/resources.py new file mode 100644 index 0000000000..ed69cebaf6 --- /dev/null +++ b/plugins/nutanix/fix_plugin_nutanix/resources.py @@ -0,0 +1,72 @@ +import logging +from typing import ClassVar, Dict, List, Optional, Tuple, Any +from attrs import define +from fixlib.baseresources import BaseAccount, BaseResource, ModelReference +from fixlib.graph import Graph + +log = logging.getLogger("fix." + __name__) + + +@define(eq=False, slots=False) +class PrismCentralAccount(BaseAccount): + """PrismCentral Account""" + + kind: ClassVar[str] = "prismcentral_account" + kind_display: ClassVar[str] = "Prism Central" + kind_description: ClassVar[str] = ( + "Prism Central is a multi-cluster management solution that enables you to manage multiple Nutanix clusters " + ) + reference_kinds: ClassVar[ModelReference] = { + "successors": { + "default": [ + "prism_element", + ], + "delete": [], + } + } + endpoint: str + username: str + password: str + port: Optional[int] = 9440 + insecure: Optional[bool] = False + + +@define(eq=False, slots=False) +class PrismElement(BaseResource): + """Prism Element""" + + kind: ClassVar[str] = "prism_element" + kind_display: ClassVar[str] = "Prism Element" + kind_description: ClassVar[str] = ( + "A Nutanix Prism Element is like a region in public clouds" + ) + reference_kinds: ClassVar[ModelReference] = { + "successors": { + "default": [ + "virtual_machine", + ], + "delete": [], + } + } + + def delete(self, graph: Graph) -> bool: + """PEs can usually not be deleted so we return NotImplemented""" + return NotImplemented + + +@define(eq=False, slots=False) +class ViraualMachine(BaseResource): + kind: ClassVar[str] = "virtual_machine" + kind_display: ClassVar[str] = "Virtual Machine" + kind_description: ClassVar[str] = "A virtual machine in Nutanix" + reference_kinds: ClassVar[ModelReference] = { + "predecessors": { + "default": ["prism_element"], + "delete": [], + } + } + power_state: str + + def delete(self, graph: Graph) -> bool: + log.info(f"delete virtual machine {self.id}: {self.name}") + return NotImplemented diff --git a/plugins/nutanix/pyproject.toml b/plugins/nutanix/pyproject.toml new file mode 100644 index 0000000000..15bafbcb66 --- /dev/null +++ b/plugins/nutanix/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "fixinventory-plugin-nutanix" +version = "0.1.0" +authors = [{name="supershal"}] +description = "Runs collector plugins and sends the result to fixcore." +license = { text="AGPLv3" } +requires-python = ">=3.11" +classifiers = [ + # Current project status + "Development Status :: 5 - Production/Stable", + # Audience + "Intended Audience :: System Administrators", + "Intended Audience :: Information Technology", + # License information + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", + # Supported python versions + "Programming Language :: Python :: 3.11", + # Supported OS's + "Operating System :: POSIX :: Linux", + "Operating System :: Unix", + # Extra metadata + "Environment :: Console", + "Natural Language :: English", + "Topic :: Security", + "Topic :: Utilities", +] +readme = {file="README.md", content-type="text/markdown"} + +dependencies = [ + "fixinventorylib==4.1.0", + "fixinventorydata", + "retrying", +] + +[project.entry-points."fix.plugins"] +nutanix = "fix_plugin_nutanix:NutanixCollectorPlugin" + +[project.urls] +Documentation = "https://inventory.fix.security" +Source = "https://github.com/someengineering/fix/tree/main/plugins/nutanix" + +[build-system] +requires = ["setuptools>=67.8.0", "wheel>=0.40.0", "build>=0.10.0"] +build-backend = "setuptools.build_meta" diff --git a/plugins/nutanix/setup.cfg b/plugins/nutanix/setup.cfg new file mode 100644 index 0000000000..a8ae35ab70 --- /dev/null +++ b/plugins/nutanix/setup.cfg @@ -0,0 +1,10 @@ +[options] +packages = find: +include_package_data = True +zip_safe = False + +[aliases] +test=pytest + +[mypy] +ignore_missing_imports = True diff --git a/plugins/nutanix/test/test_collector.py b/plugins/nutanix/test/test_collector.py new file mode 100644 index 0000000000..9ccd762c04 --- /dev/null +++ b/plugins/nutanix/test/test_collector.py @@ -0,0 +1,48 @@ +import logging +import os +from typing import Dict, Any, List, cast + +from fixlib.baseresources import GraphRoot, Cloud + +from fixlib.graph import Graph +from fixlib.graph import sanitize +from fix_plugin_nutanix.collector import PrismCentralCollector +from fix_plugin_nutanix.resources import PrismCentralAccount +import fix_plugin_nutanix + + +log = logging.getLogger("fix." + __name__) + + +def prepare_graph() -> Graph: + sherlockDevAccount = PrismCentralAccount( + id="sherlock_dev", + name="Sherlock Dev", + endpoint=os.getenv("NUTANIX_ENDPOINT"), + username=os.getenv("NUTANIX_USER"), + password=os.getenv("NUTANIX_PASSWORD"), + tags={"url": os.getenv("NUTANIX_ENDPOINT")}, + ) + vmmClient = fix_plugin_nutanix.vmm_client(sherlockDevAccount) + clusterClient = fix_plugin_nutanix.cluster_client(sherlockDevAccount) + plugin_instance = PrismCentralCollector( + sherlockDevAccount, vmmClient, clusterClient + ) + pcGraph = plugin_instance.collect() + cloud = Cloud(id="nutanix_test") + cloud_graph = Graph(root=cloud) + cloud_graph.merge(pcGraph) + # create root and add cloud graph. + graph = Graph(root=GraphRoot(id="root", tags={})) + graph.merge(cloud_graph) + sanitize(graph) + for node in graph.nodes: + log.info(f"Node ID: {node.id}" + f"Node name: {node.name}") + for node_from, node_to, edge in graph.edges: + log.info(f"Edge from: {node_from.name} to {node_to.name} with type {edge}") + + return graph + + +def test_collect_nutanix() -> None: + graph = prepare_graph() diff --git a/plugins/nutanix/test/test_config.py b/plugins/nutanix/test/test_config.py new file mode 100644 index 0000000000..b215d05b86 --- /dev/null +++ b/plugins/nutanix/test/test_config.py @@ -0,0 +1,10 @@ +from numpy import where +from fixlib.config import Config +from fix_plugin_nutanix import NutanixCollectorPlugin + + +def test_config(): + config = Config("dummy", "dummy") + NutanixCollectorPlugin.add_config(config) + Config.init_default_config() + assert Config.nutanix.credentials == [] diff --git a/plugins/nutanix/tox.ini b/plugins/nutanix/tox.ini new file mode 100644 index 0000000000..9ad8c01657 --- /dev/null +++ b/plugins/nutanix/tox.ini @@ -0,0 +1,41 @@ +[tox] +#env_list = syntax, tests, black, mypy +env_list = syntax, tests + +[flake8] +max-line-length=120 +exclude = .git,.tox,__pycache__,.idea,.pytest_cache +ignore=F403, F405, E722, N806, N813, E266, W503, E203, F811, E501 + +[pytest] +testpaths = test +asyncio_mode=auto +log_cli = true +log_cli_level = INFO + + +[testenv] +usedevelop = true +deps = + -r../../requirements-all.txt + --editable=file:///{toxinidir}/../../fixlib + --editable=file:///{toxinidir}/../nutanix +# until this is fixed: https://github.com/pypa/setuptools/issues/3518 +setenv = + SETUPTOOLS_ENABLE_FEATURES = legacy-editable +passenv = + NUTANIX_USER + NUTANIX_PASSWORD + NUTANIX_ENDPOINT + +[testenv:syntax] +commands = flake8 + +[testenv:tests] +commands= pytest + +[testenv:black] +commands = black --line-length 120 --check --diff --target-version py39 . + +[testenv:mypy] +commands= python -m mypy --install-types --non-interactive --python-version 3.11 --strict fix_plugin_nutanix test diff --git a/requirements-all.txt b/requirements-all.txt index d47262cb86..6d1b31d591 100644 --- a/requirements-all.txt +++ b/requirements-all.txt @@ -27,7 +27,7 @@ build==1.2.1 cachetools==5.3.3 cattrs==23.2.3 cerberus==1.3.5 -certifi==2024.7.4 +certifi==2020.4.5.1 cffi==1.16.0 chardet==5.2.0 charset-normalizer==3.3.2 @@ -190,3 +190,6 @@ wrapt==1.16.0 yarl==1.9.4 zc-lockfile==3.0.post1 zipp==3.19.2 +ntnx_vmm_py_client==4.0.3a1 +ntnx_prism_py_client==4.0.3a2 +ntnx_clustermgmt_py_client==4.0.2a2 \ No newline at end of file diff --git a/requirements-extra.txt b/requirements-extra.txt index 614beeb6a6..cd81dc29b0 100644 --- a/requirements-extra.txt +++ b/requirements-extra.txt @@ -24,7 +24,7 @@ brotli==1.1.0 cachetools==5.3.3 cattrs==23.2.3 cerberus==1.3.5 -certifi==2024.7.4 +certifi==2020.4.5.1 cffi==1.16.0 charset-normalizer==3.3.2 cheroot==10.0.1 @@ -147,3 +147,6 @@ wrapt==1.16.0 yarl==1.9.4 zc-lockfile==3.0.post1 zipp==3.19.2 +ntnx_vmm_py_client==4.0.3a1 +ntnx_prism_py_client==4.0.3a2 +ntnx_clustermgmt_py_client==4.0.2a2 \ No newline at end of file