Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fixinventory plugin for nutanix #1

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

4 changes: 2 additions & 2 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions plugins/nutanix/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include README.md
52 changes: 52 additions & 0 deletions plugins/nutanix/Makefile
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions plugins/nutanix/README.md
Original file line number Diff line number Diff line change
@@ -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.
114 changes: 114 additions & 0 deletions plugins/nutanix/fix_plugin_nutanix/__init__.py
Original file line number Diff line number Diff line change
@@ -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
118 changes: 118 additions & 0 deletions plugins/nutanix/fix_plugin_nutanix/collector.py
Original file line number Diff line number Diff line change
@@ -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)
37 changes: 37 additions & 0 deletions plugins/nutanix/fix_plugin_nutanix/config.py
Original file line number Diff line number Diff line change
@@ -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'}]."
},
)
Loading