Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
vm_id,vm_name,firmware,num_cpu,memory_mb,nested_virtualization,dynamic_memory_enabled,secure_boot,os_type,guest_id,disks,nics,hostname,parent_vapp,host,power_state
4cb7e5e1-d3c4-4b0a-8742-f2b1b3d9a1c2,web-server-01,efi,4,8192,False,True,False,linux,ubuntu64Guest,150GB,Network adapter 1|00:50:56:aa:bb:cc|VM Network|10.0.0.10,web-server-01,,esxi-host-01.example.com,poweredOn
9fa23b11-7e2a-4c10-a3f8-0d1e2f3c4a5b,db-server-01,bios,8,16384,False,False,False,windows,windows9Server64Guest,300GB,Network adapter 1|00:50:56:aa:cc:dd|VM Network|10.0.0.20,db-server-01,db-vapp,esxi-host-02.example.com,poweredOn
58 changes: 58 additions & 0 deletions coriolis/api-refs/source/endpoint.inc
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,64 @@ Response
.. literalinclude:: ../api_samples/endpoint/endpoint-instance-show-resp.json
:language: javascript

Get Endpoint VM Inventory
=========================

.. rest_method:: GET /endpoints/{endpoint_id}/inventory

Exports the full VM inventory of an endpoint in CSV format.

**Preconditions**

The endpoint must exist and the provider must support inventory export.

Normal response codes: 200

Error response codes: unauthorized(401), forbidden(403),
itemNotFound(404)

Request
-------

.. rest_parameters:: parameters.yaml

- endpoint_id : endpoint_id_path
- env : inventory_source_env

Response
--------

The response body is a UTF-8 encoded CSV file with various columns returned.
Each provider may return more specific fields (i.e. parentVapp when using VMWare).
The example below shows the values returned by the VMWare provider.

- **vm_id** – Unique identifier (UUID) of the VM.
- **vm_name** – Display name of the VM.
- **guest_id** – Raw VMware guest OS identifier (e.g. ``ubuntu64Guest``).
- **firmware** – Firmware type (``bios`` or ``efi``).
- **num_cpu** – Number of vCPUs.
- **memory_mb** – Memory in megabytes.
- **nested_virtualization** – Whether nested virtualisation is enabled.
- **dynamic_memory_enabled** – Whether dynamic memory is enabled.
- **secure_boot** – Whether EFI Secure Boot is enabled.
- **os_type** – Coriolis-normalised OS type (e.g. ``linux``, ``windows``).
- **disks** – Total provisioned disk size for the VM (e.g. ``150GB``).
- **nics** – Semicolon-separated list of NIC entries, each formatted as
``label|mac[|network[|ip1,ip2,...]]``.
- **hostname** – Guest hostname as reported by VMware Tools (empty if tools
are not running).
- **parent_vapp** – Name of the parent vApp, if any.
- **host** – ESXi host the VM is currently running on.
- **power_state** – Current power state (e.g. ``poweredOn``, ``poweredOff``).

The response is returned with ``Content-Type: text/csv`` and a
``Content-Disposition: attachment`` header suggesting a filename of the form
``vm_inventory_{endpoint_id}.csv``.

**Example VM Inventory CSV Response**

.. literalinclude:: ../api_samples/endpoint/endpoint-inventory-resp.csv

Get Endpoint Destination Options
================================

Expand Down
7 changes: 7 additions & 0 deletions coriolis/api-refs/source/parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ instance_refresh:
required: false
type: boolean
default: false
inventory_source_env:
description: |
Optional base64-encoded JSON object containing source environment options
for the inventory export When omitted, the provider's defaults are used.
in: query
required: false
type: string
show_deleted:
description: |
Whether to include deleted resources in the response.
Expand Down
39 changes: 39 additions & 0 deletions coriolis/api/v1/endpoint_inventory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Copyright 2026 Cloudbase Solutions Srl
# All Rights Reserved.

from coriolis.api import wsgi as api_wsgi
from coriolis.endpoint_resources import api
from coriolis.policies import endpoints as endpoint_policies
from coriolis import utils

from oslo_log import log as logging

LOG = logging.getLogger(__name__)


class EndpointInventoryController(api_wsgi.Controller):
"""Returns a VM inventory CSV for endpoints that support it."""

def __init__(self):
self._endpoint_resources_api = api.API()
super(EndpointInventoryController, self).__init__()

def index(self, req, endpoint_id):
context = req.environ['coriolis.context']
context.can("%s:export_inventory" % (
endpoint_policies.ENDPOINTS_POLICY_PREFIX))

env = req.GET.get("env")
if env is not None:
env = utils.decode_base64_param(env, is_json=True)
else:
env = {}

csv_content = self._endpoint_resources_api.get_endpoint_inventory_csv(
context, endpoint_id, env)

return api_wsgi.ResponseObject(csv_content)


def create_resource():
return api_wsgi.Resource(EndpointInventoryController())
6 changes: 6 additions & 0 deletions coriolis/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from coriolis.api.v1 import endpoint_destination_minion_pool_options
from coriolis.api.v1 import endpoint_destination_options
from coriolis.api.v1 import endpoint_instances
from coriolis.api.v1 import endpoint_inventory
from coriolis.api.v1 import endpoint_networks
from coriolis.api.v1 import endpoint_source_minion_pool_options
from coriolis.api.v1 import endpoint_source_options
Expand Down Expand Up @@ -109,6 +110,11 @@ def _setup_routes(self, mapper, ext_mgr):
mapper.resource('instance', 'endpoints/{endpoint_id}/instances',
controller=self.resources['endpoint_instances'])

self.resources['endpoint_inventory'] = \
endpoint_inventory.create_resource()
mapper.resource('inventory', 'endpoints/{endpoint_id}/inventory',
controller=self.resources['endpoint_inventory'])

self.resources['endpoint_networks'] = \
endpoint_networks.create_resource()
mapper.resource('network', 'endpoints/{endpoint_id}/networks',
Expand Down
12 changes: 11 additions & 1 deletion coriolis/api/wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@

SUPPORTED_CONTENT_TYPES = (
'application/json',
'text/csv',
)

_MEDIA_TYPE_MAP = {
'application/json': 'json',
'text/csv': 'csv',
}


Expand Down Expand Up @@ -426,6 +428,13 @@ def default(self, data):
return jsonutils.dumps(data)


class CSVSerializer(DictSerializer):
"""Serializer for CSV responses. Expects pre-formatted CSV string."""

def default(self, data):
return str(data)


def serializers(**serializers):
"""Attaches serializers to a method.

Expand Down Expand Up @@ -693,7 +702,8 @@ def __init__(self, controller, action_peek=None, **deserializers):
default_deserializers.update(deserializers)

self.default_deserializers = default_deserializers
self.default_serializers = dict(json=JSONDictSerializer)
self.default_serializers = dict(json=JSONDictSerializer,
csv=CSVSerializer)

self.action_peek = dict(json=action_peek_json)
self.action_peek.update(action_peek or {})
Expand Down
7 changes: 7 additions & 0 deletions coriolis/conductor/rpc/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ def get_endpoint_storage(self, ctxt, endpoint_id, env):
endpoint_id=endpoint_id,
env=env)

def get_endpoint_inventory_csv(
self, ctxt, endpoint_id, source_environment):
return self._call(
ctxt, 'get_endpoint_inventory_csv',
endpoint_id=endpoint_id,
source_environment=source_environment)

def validate_endpoint_connection(self, ctxt, endpoint_id):
return self._call(
ctxt, 'validate_endpoint_connection',
Expand Down
15 changes: 15 additions & 0 deletions coriolis/conductor/rpc/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,21 @@ def get_endpoint_storage(self, ctxt, endpoint_id, env):
return worker_rpc.get_endpoint_storage(
ctxt, endpoint.type, endpoint.connection_info, env)

def get_endpoint_inventory_csv(
self, ctxt, endpoint_id, source_environment):
endpoint = self.get_endpoint(ctxt, endpoint_id)

worker_rpc = self._get_worker_service_rpc_for_specs(
ctxt, enabled=True,
region_sets=[[reg.id for reg in endpoint.mapped_regions]],
provider_requirements={
endpoint.type: [
constants.PROVIDER_TYPE_ENDPOINT_INVENTORY_EXPORT]})

return worker_rpc.get_endpoint_inventory_csv(
ctxt, endpoint.type, endpoint.connection_info,
source_environment)

def validate_endpoint_connection(self, ctxt, endpoint_id):
endpoint = self.get_endpoint(ctxt, endpoint_id)

Expand Down
1 change: 1 addition & 0 deletions coriolis/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@
PROVIDER_TYPE_DESTINATION_TRANSFER_UPDATE = 262144
PROVIDER_TYPE_SOURCE_MINION_POOL = 524288
PROVIDER_TYPE_DESTINATION_MINION_POOL = 1048576
PROVIDER_TYPE_ENDPOINT_INVENTORY_EXPORT = 2097152
# NOTE(dvincze): These are deprecated, we should remove them,
# and de-increment the rest
PROVIDER_TYPE_VALIDATE_MIGRATION_EXPORT = 2048
Expand Down
5 changes: 5 additions & 0 deletions coriolis/endpoint_resources/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ def get_endpoint_networks(self, ctxt, endpoint_id, env):
def get_endpoint_storage(self, ctxt, endpoint_id, env):
return self._rpc_client.get_endpoint_storage(
ctxt, endpoint_id, env)

def get_endpoint_inventory_csv(
self, ctxt, endpoint_id, source_environment):
return self._rpc_client.get_endpoint_inventory_csv(
ctxt, endpoint_id, source_environment)
11 changes: 11 additions & 0 deletions coriolis/policies/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,17 @@ def get_endpoints_policy_label(rule_label):
}
]
),
policy.DocumentedRuleDefault(
get_endpoints_policy_label('export_inventory'),
ENDPOINTS_POLICY_DEFAULT_RULE,
"Export VM inventory as CSV for a supported endpoint",
[
{
"path": "/endpoints/{endpoint_id}/inventory",
"method": "GET"
}
]
),
]


Expand Down
21 changes: 21 additions & 0 deletions coriolis/providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,27 @@ def healthcheck_minion(
pass


class BaseEndpointInventoryExportProvider(
object, with_metaclass(abc.ABCMeta)):
"""Capability class for providers that support VM inventory CSV export.

Providers that implement this class will be offered in the UI as
supporting the inventory export action. Providers that do not implement
this class cleanly do not support it — no additional error handling is
required at the provider level.
"""

@abc.abstractmethod
def export_instance_inventory(
self, ctxt, connection_info, source_environment):
"""Export the full VM inventory as a CSV-formatted string.

Returns a standards-compliant CSV string with a header row and one
row per VM, sorted deterministically by VM ID.
"""
raise NotImplementedError()


class BaseSourceMinionPoolProvider(_BaseMinionPoolProvider):

pass
Expand Down
4 changes: 3 additions & 1 deletion coriolis/providers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
constants.PROVIDER_TYPE_SOURCE_MINION_POOL: (
base.BaseSourceMinionPoolProvider),
constants.PROVIDER_TYPE_DESTINATION_MINION_POOL: (
base.BaseDestinationMinionPoolProvider)
base.BaseDestinationMinionPoolProvider),
constants.PROVIDER_TYPE_ENDPOINT_INVENTORY_EXPORT: (
base.BaseEndpointInventoryExportProvider),
}


Expand Down
67 changes: 67 additions & 0 deletions coriolis/tests/api/v1/test_endpoint_inventory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright 2026 Cloudbase Solutions Srl
# All Rights Reserved.

from unittest import mock

from coriolis.api.v1 import endpoint_inventory as endpoint
from coriolis.api import wsgi as api_wsgi
from coriolis.endpoint_resources import api
from coriolis.tests import test_base
from coriolis import utils


class EndpointInventoryControllerTestCase(test_base.CoriolisBaseTestCase):
"""Test suite for the Coriolis Endpoint Inventory v1 API"""

def setUp(self):
super(EndpointInventoryControllerTestCase, self).setUp()
self.endpoint_api = endpoint.EndpointInventoryController()

@mock.patch.object(utils, 'decode_base64_param')
@mock.patch.object(api.API, 'get_endpoint_inventory_csv')
def test_index(
self,
mock_get_endpoint_inventory_csv,
mock_decode_base64_param,
):
mock_req = mock.Mock()
mock_context = mock.Mock()
endpoint_id = mock.sentinel.endpoint_id
mock_req.environ = {'coriolis.context': mock_context}
env = mock.sentinel.env
mock_req.GET = {'env': env}
mock_get_endpoint_inventory_csv.return_value = 'vm_id,vm_name\n'

response = self.endpoint_api.index(mock_req, endpoint_id)

mock_context.can.assert_called_once_with(
'migration:endpoints:export_inventory')
mock_decode_base64_param.assert_called_once_with(env, is_json=True)
mock_get_endpoint_inventory_csv.assert_called_once_with(
mock_context, endpoint_id,
mock_decode_base64_param.return_value)
self.assertIsInstance(response, api_wsgi.ResponseObject)
self.assertEqual(response.code, 200)
self.assertEqual(response.obj, 'vm_id,vm_name\n')

@mock.patch.object(utils, 'decode_base64_param')
@mock.patch.object(api.API, 'get_endpoint_inventory_csv')
def test_index_no_env(
self,
mock_get_endpoint_inventory_csv,
mock_decode_base64_param,
):
mock_req = mock.Mock()
mock_context = mock.Mock()
endpoint_id = mock.sentinel.endpoint_id
mock_req.environ = {'coriolis.context': mock_context}
mock_req.GET = {}
mock_get_endpoint_inventory_csv.return_value = 'vm_id,vm_name\n'

response = self.endpoint_api.index(mock_req, endpoint_id)

mock_decode_base64_param.assert_not_called()
mock_get_endpoint_inventory_csv.assert_called_once_with(
mock_context, endpoint_id, {})
self.assertIsInstance(response, api_wsgi.ResponseObject)
self.assertEqual(response.code, 200)
Loading
Loading