diff --git a/docs/ref/modules/all.rst b/docs/ref/modules/all.rst index 0aea788b..6a49f21f 100644 --- a/docs/ref/modules/all.rst +++ b/docs/ref/modules/all.rst @@ -11,6 +11,7 @@ Execution Modules saltext.vmware.modules.cluster saltext.vmware.modules.cluster_drs saltext.vmware.modules.cluster_ha + saltext.vmware.modules.content_library saltext.vmware.modules.datacenter saltext.vmware.modules.datastore saltext.vmware.modules.dvportgroup @@ -32,6 +33,7 @@ Execution Modules saltext.vmware.modules.nsxt_uplink_profiles saltext.vmware.modules.ssl_adapter saltext.vmware.modules.storage_policies + saltext.vmware.modules.subscribed_library saltext.vmware.modules.tag saltext.vmware.modules.vm saltext.vmware.modules.vmc_dhcp_profiles diff --git a/docs/ref/modules/saltext.vmware.modules.content_library.rst b/docs/ref/modules/saltext.vmware.modules.content_library.rst new file mode 100644 index 00000000..f51fa219 --- /dev/null +++ b/docs/ref/modules/saltext.vmware.modules.content_library.rst @@ -0,0 +1,6 @@ + +saltext.vmware.modules.content_library +====================================== + +.. automodule:: saltext.vmware.modules.content_library + :members: diff --git a/docs/ref/modules/saltext.vmware.modules.subscribed_library.rst b/docs/ref/modules/saltext.vmware.modules.subscribed_library.rst new file mode 100644 index 00000000..6b38e2c9 --- /dev/null +++ b/docs/ref/modules/saltext.vmware.modules.subscribed_library.rst @@ -0,0 +1,6 @@ + +saltext.vmware.modules.subscribed_library +========================================= + +.. automodule:: saltext.vmware.modules.subscribed_library + :members: diff --git a/docs/ref/states/all.rst b/docs/ref/states/all.rst index c6ee2f5f..79244b0d 100644 --- a/docs/ref/states/all.rst +++ b/docs/ref/states/all.rst @@ -8,6 +8,7 @@ State Modules .. autosummary:: :toctree: + saltext.vmware.states.content_library saltext.vmware.states.datacenter saltext.vmware.states.datastore saltext.vmware.states.esxi diff --git a/docs/ref/states/saltext.vmware.states.content_library.rst b/docs/ref/states/saltext.vmware.states.content_library.rst new file mode 100644 index 00000000..aa6195a6 --- /dev/null +++ b/docs/ref/states/saltext.vmware.states.content_library.rst @@ -0,0 +1,6 @@ + +saltext.vmware.states.content_library +===================================== + +.. automodule:: saltext.vmware.states.content_library + :members: diff --git a/src/saltext/vmware/modules/content_library.py b/src/saltext/vmware/modules/content_library.py new file mode 100644 index 00000000..02e03166 --- /dev/null +++ b/src/saltext/vmware/modules/content_library.py @@ -0,0 +1,151 @@ +# Copyright 2021-2023 VMware, Inc. +# SPDX-License: Apache-2.0 +import logging + +import saltext.vmware.utils.connect as connect + +log = logging.getLogger(__name__) + +__virtualname__ = "vsphere_content_library" + + +def __virtual__(): + return __virtualname__ + + +def list(): + """ + Lists IDs for all the content libraries on a given vCenter. + """ + response = connect.request( + "/api/content/local-library", "GET", opts=__opts__, pillar=__pillar__ + ) + return response["response"].json() + + +def list_detailed(): + """ + Lists all the content libraries on a given vCenter with all their details. + """ + result = {} + library_ids = list() + for library_id in library_ids: + response = get(library_id) + name = response["name"] + result[name] = response + return result + + +def get(id): + """ + Returns info on given content library. + + id + (string) Content Library ID. + """ + url = f"/api/content/local-library/{id}" + response = connect.request(url, "GET", opts=__opts__, pillar=__pillar__) + return response["response"].json() + + +def create(library): + """ + Creates a new content library. + + library + Dictionary having values for library, as following: + + name + Name of the content library. + + description + (optional) Description for the content library being created. + + datastore + Datastore ID where library will store its contents. + + published + (optional) Whether the content library should be published or not. + + authentication + (optional) The authentication method when the content library is published. + """ + + publish_info = { + "published": library["published"], + "authentication_method": library["authentication"], + } + storage_backings = [{"datastore_id": library["datastore"], "type": "DATASTORE"}] + + data = { + "name": library["name"], + "publish_info": publish_info, + "storage_backings": storage_backings, + "type": "LOCAL", + } + if "description" in library: + data["description"] = library["description"] + + response = connect.request( + "/api/content/local-library", "POST", body=data, opts=__opts__, pillar=__pillar__ + ) + return response["response"].json() + + +def update(id, library): + """ + Updates content library with given id. + + id + (string) Content library ID . + + library + Dictionary having values for library, as following: + + name + (optional) Name of the content library. + + description + (optional) Description for the content library being updated. + + published + (optional) Whether the content library should be published or not. + + authentication + (optional) The authentication method when the content library is published. + + datastore + (optional) Datastore ID where library will store its contents. + """ + + publish_info = {} + if "published" in library: + publish_info["published"] = library["published"] + if "authentication" in library: + publish_info["authentication_method"] = library["authentication"] + + data = {} + if "name" in library: + data["name"] = library["name"] + if "description" in library: + publish_info["description"] = library["description"] + if publish_info: + data["publish_info"] = publish_info + if "datastore" in library: + data["storage_backings"] = [{"datastore_id": library["datastore"], "type": "DATASTORE"}] + + url = f"/api/content/local-library/{id}" + response = connect.request(url, "PATCH", body=data, opts=__opts__, pillar=__pillar__) + return response["response"].json() + + +def delete(id): + """ + Delete content library having corresponding id. + + id + (string) Content library ID to delete. + """ + url = f"/api/content/local-library/{id}" + response = connect.request(url, "DELETE", opts=__opts__, pillar=__pillar__) + return response["response"].json() diff --git a/src/saltext/vmware/modules/subscribed_library.py b/src/saltext/vmware/modules/subscribed_library.py new file mode 100644 index 00000000..3b124ed9 --- /dev/null +++ b/src/saltext/vmware/modules/subscribed_library.py @@ -0,0 +1,127 @@ +# Copyright 2021-2023 VMware, Inc. +# SPDX-License: Apache-2.0 +import logging + +import saltext.vmware.utils.connect as connect + +log = logging.getLogger(__name__) + +__virtualname__ = "vsphere_subscribed_library" + + +def __virtual__(): + return __virtualname__ + + +def list(): + """ + Lists IDs for all the subscribed libraries on a given vCenter. + """ + response = connect.request( + "/api/content/subscribed-library", "GET", opts=__opts__, pillar=__pillar__ + ) + response = response["response"].json() + return response["value"] + + +def get(id): + """ + Returns info on given subscribed library. + + id + (string) Subscribed Library ID. + """ + url = f"/api/content/subscribed-library/{id}" + response = connect.request(url, "GET", opts=__opts__, pillar=__pillar__) + return response["response"].json() + + +def create(name, description, published, authentication, datastore): + """ + Creates a new subscribed library. + + name + Name of the subscribed library. + + datastore + Datastore ID where library will store its contents. + + description + (optional) Description for the subscribed library being created. + + published + (optional) Whether the subscribed library should be published or not. + + authentication + (optional) The authentication method for the subscribed library being published. + """ + + publish_info = {"published": published, "authentication_method": authentication} + storage_backings = {"datastore_id": datastore, "type": "DATASTORE"} + + data = { + "name": name, + "publish_info": publish_info, + "storage_backings": storage_backings, + "type": "SUBSCRIBED", + } + if description is not None: + data["description"] = description + + response = connect.request( + "/api/content/subscribed-library", "POST", body=data, opts=__opts__, pillar=__pillar__ + ) + return response["response"].json() + + +def update(id, name, description, published, authentication, datastore): + """ + Updates subscribed library with given id. + + id + (string) Subscribed library ID . + + name + (optional) Name of the subscribed library. + + description + (optional) Description for the subscribed library being updated. + + published + (optional) Whether the subscribed library should be published or not. + + authentication + (optional) The authentication method for the subscribed library being published. + + datastore + (optional) Datastore ID where library will store its contents. + """ + + publish_info = {"published": published, "authentication_method": authentication} + storage_backings = {"datastore_id": datastore, "type": "DATASTORE"} + + data = {} + if name is not None: + data["name"] = name + if description is not None: + data["name"] = description + if published is not None: + data["publish_info"] = publish_info + if datastore is not None: + data["storage_backings"] = storage_backings + + url = f"/api/content/subscribed-library/{id}" + response = connect.request(url, "PATCH", body=data, opts=__opts__, pillar=__pillar__) + return response["response"].json() + + +def delete(id): + """ + Delete subscribed library having corresponding id. + + id + (string) Subscribed library ID to delete. + """ + url = f"/api/content/subscribed-library/{id}" + response = connect.request(url, "DELETE", opts=__opts__, pillar=__pillar__) + return response["response"].json() diff --git a/src/saltext/vmware/states/content_library.py b/src/saltext/vmware/states/content_library.py new file mode 100644 index 00000000..022f3225 --- /dev/null +++ b/src/saltext/vmware/states/content_library.py @@ -0,0 +1,92 @@ +# Copyright 2021-2023 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 +import logging + +import salt.utils.data + +log = logging.getLogger(__name__) + +__virtualname__ = "vsphere_content_library" + + +def __virtual__(): + return __virtualname__ + + +def _transform_libraries_to_state(libraries): + result = {} + for name, library in libraries.items(): + library_state = {} + library_state["description"] = library["description"] + library_state["published"] = library["publish_info"]["published"] + library_state["authentication"] = library["publish_info"]["authentication_method"] + library_state["datastore"] = library["storage_backings"][0]["datastore_id"] + result[name] = library_state + return result + + +def _transform_config_to_state(config): + result = {} + for library in config: + if "name" not in library: + raise ValueError("Every library configuration should have a name") + library_state = {} + if "description" in library: + library_state["description"] = library["description"] + if "published" in library: + library_state["published"] = library["published"] + if "authentication" in library: + library_state["authentication"] = library["authentication"] + if "datastore" in library: + library_state["datastore"] = library["datastore"] + result[library["name"]] = library_state + return result + + +def local(name, config): + """ + Set local content libraries based on configuration. + + name + Name of configuration. (required). + + libraries + List of libraries with configuration values. (required). + + Example: + + content_library_example: + vsphere_content_library.local: + name: local_example + config: + - name: publish + published: true + authentication: NONE + datastore: datastore-00001 + - name: local + datastore: datastore-00001 + """ + + current_libraries = __salt__["vsphere_content_library.list_detailed"]() + old_state = _transform_libraries_to_state(current_libraries) + new_state = _transform_config_to_state(config) + changes = salt.utils.data.recursive_diff(old_state, new_state) + changes_required = any(changes["new"].values()) + if not __opts__["test"] and changes_required: + for name, library in changes["new"].items(): + if name in changes["old"]: + library_id = current_libraries[name]["id"] + __salt__["vsphere_content_library.update"](library_id, library) + else: + if "datastore" not in library: + raise ValueError( + "Non existing libraries must be provided with a datastore ID for creation" + ) + new_library = { + "name": name, + "datastore": library["datastore"], + "published": library.get("published") or False, + "authentication": library.get("authentication") or "NONE", + } + __salt__["vsphere_content_library.create"](new_library) + return {"name": name, "result": True, "comment": "", "changes": changes} diff --git a/tests/unit/modules/test_content_library.py b/tests/unit/modules/test_content_library.py new file mode 100644 index 00000000..6b35309b --- /dev/null +++ b/tests/unit/modules/test_content_library.py @@ -0,0 +1,116 @@ +""" + Unit Tests for content library module +""" +from unittest.mock import Mock +from unittest.mock import patch + +import pytest +from saltext.vmware.modules import content_library + +dummy_library_id = "d9e26762-493c-4722-856d-ca00097269b5" +dummy_list = [dummy_library_id] +dummy_get = { + "creation_time": "2022-12-14T14:46:13.538Z", + "storage_backings": [{"datastore_id": "datastore-35032", "type": "DATASTORE"}], + "last_modified_time": "2022-12-14T14:47:42.785Z", + "server_guid": "cc6c3419-d4e3-4253-864d-7150e1be1430", + "description": "Modified 1", + "type": "LOCAL", + "version": "3", + "name": "API_changed", + "publish_info": { + "authentication_method": "NONE", + "published": True, + "publish_url": "https://vc-l-01a.corp.local:443/cls/vcsp/lib/d9e26762-493c-4722-856d-ca00097269b5/lib.json", + "persist_json_enabled": False, + }, + "id": "d9e26762-493c-4722-856d-ca00097269b5", +} +dummy_create = { + "name": "Newest", + "description": "Test", + "published": True, + "authentication": "NONE", + "datastore": "datastore-35032", +} + +dummy_update_full = { + "name": "Newest", + "description": "Test", + "published": True, + "authentication": "NONE", + "datastore": "datastore-35032", +} + +dummy_update_partial = { + "description": "Test", +} + + +@pytest.fixture +def configure_loader_modules(): + return {content_library: {}} + + +def get_mock_success_response(body): + response = Mock() + response.status_code = 200 + response.json = Mock(return_value=body) + return response + + +@patch.object(content_library.connect, "request") +def test_content_libraries_list(mock_request): + response = get_mock_success_response(dummy_list) + mock_request.return_value = {"response": response, "token": ""} + result = content_library.list() + assert result == dummy_list + + +@patch.object(content_library, "get") +@patch.object(content_library, "list") +def test_content_libraries_detailed_list(mock_list, mock_get): + mock_list.return_value = dummy_list + mock_get.return_value = dummy_get + result = content_library.list_detailed() + assert result + + +@patch.object(content_library.connect, "request") +def test_content_libraries_get(mock_request): + response = get_mock_success_response(dummy_get) + mock_request.return_value = {"response": response, "token": ""} + result = content_library.get(dummy_library_id) + assert result == dummy_get + + +@patch.object(content_library.connect, "request") +def test_content_libraries_create(mock_request): + response = get_mock_success_response(None) + mock_request.return_value = {"response": response, "token": ""} + result = content_library.create(dummy_create) + assert result is None + + +@patch.object(content_library.connect, "request") +def test_content_libraries_update_full(mock_request): + response = get_mock_success_response(None) + mock_request.return_value = {"response": response, "token": ""} + result = content_library.update(dummy_library_id, dummy_update_full) + assert result is None + + +@patch.object(content_library.connect, "request") +def test_content_libraries_update_partial(mock_request): + response = get_mock_success_response(None) + mock_request.return_value = {"response": response, "token": ""} + result = content_library.update(dummy_library_id, dummy_update_partial) + assert result is None + + +@patch.object(content_library.connect, "request") +def test_content_libraries_delete(mock_request): + response = get_mock_success_response(None) + mock_request.return_value = {"response": response, "token": ""} + result = content_library.delete(dummy_library_id) + assert result is None diff --git a/tests/unit/states/test_content_library.py b/tests/unit/states/test_content_library.py new file mode 100644 index 00000000..015886e9 --- /dev/null +++ b/tests/unit/states/test_content_library.py @@ -0,0 +1,155 @@ +""" + Unit Tests for content library state +""" +from unittest.mock import MagicMock +from unittest.mock import Mock +from unittest.mock import patch + +import pytest +from saltext.vmware.states import content_library + +dummy_list_detailed = { + "existing": { + "creation_time": "2022-12-14T14:46:13.538Z", + "storage_backings": [{"datastore_id": "datastore-35032", "type": "DATASTORE"}], + "last_modified_time": "2022-12-14T14:47:42.785Z", + "server_guid": "cc6c3419-d4e3-4253-864d-7150e1be1430", + "description": "Modified 1", + "type": "LOCAL", + "version": "3", + "name": "existing", + "publish_info": { + "authentication_method": "NONE", + "published": True, + "publish_url": "https://vc-l-01a.corp.local:443/cls/vcsp/lib/d9e26762-493c-4722-856d-ca00097269b5/lib.json", + "persist_json_enabled": False, + }, + "id": "d9e26762-493c-4722-856d-ca00097269b5", + } +} + +dummy_config_unchanged = [ + { + "name": "existing", + "published": True, + } +] + +dummy_config_existing = [ + { + "name": "existing", + "published": False, + } +] + +dummy_config_new = [{"name": "new", "published": False, "datastore": "datastore-35032"}] + +dummy_expected_changes_existing = {"existing": {"published": False}} + +dummy_expected_changes_new = {"new": {"published": False, "datastore": "datastore-35032"}} + + +@pytest.fixture +def configure_loader_modules(): + return {content_library: {}} + + +def test_content_library_local_state_test_existing(): + content_library.connect = Mock() + mock_list = Mock(return_value=dummy_list_detailed) + + with patch.dict( + content_library.__salt__, + { + "vsphere_content_library.list_detailed": mock_list, + }, + ): + with patch.dict(content_library.__opts__, {"test": True}): + result = content_library.local("", dummy_config_existing) + + assert result is not None + assert result["result"] + assert result["changes"]["new"] == dummy_expected_changes_existing + mock_list.assert_called() + + +def test_content_library_local_state_existing(): + content_library.connect = Mock() + mock_list = Mock(return_value=dummy_list_detailed) + mock_create = Mock() + + with patch.dict( + content_library.__salt__, + { + "vsphere_content_library.list_detailed": mock_list, + "vsphere_content_library.update": mock_create, + }, + ): + with patch.dict(content_library.__opts__, {"test": False}): + result = content_library.local("", dummy_config_existing) + + assert result is not None + assert result["result"] + assert result["changes"]["new"] == dummy_expected_changes_existing + mock_list.assert_called() + mock_create.assert_called() + + +def test_content_library_local_state_test_new(): + content_library.connect = Mock() + mock_list = Mock(return_value=dummy_list_detailed) + + with patch.dict( + content_library.__salt__, + { + "vsphere_content_library.list_detailed": mock_list, + }, + ): + with patch.dict(content_library.__opts__, {"test": True}): + result = content_library.local("", dummy_config_new) + + assert result is not None + assert result["result"] + assert result["changes"]["new"] == dummy_expected_changes_new + mock_list.assert_called() + + +def test_content_library_local_state_new(): + content_library.connect = Mock() + mock_list = Mock(return_value=dummy_list_detailed) + mock_create = Mock() + + with patch.dict( + content_library.__salt__, + { + "vsphere_content_library.list_detailed": mock_list, + "vsphere_content_library.create": mock_create, + }, + ): + with patch.dict(content_library.__opts__, {"test": False}): + result = content_library.local("", dummy_config_new) + + assert result is not None + assert result["result"] + assert result["changes"]["new"] == dummy_expected_changes_new + mock_list.assert_called() + mock_create.assert_called() + + +def test_get_advanced_config_unchanged(): + content_library.connect = MagicMock() + mock_list = Mock(return_value=dummy_list_detailed) + + with patch.dict( + content_library.__salt__, + { + "vsphere_content_library.list_detailed": mock_list, + }, + ): + with patch.dict(content_library.__opts__, {"test": False}): + result = content_library.local("", dummy_config_unchanged) + + assert result is not None + assert result["changes"]["new"]["existing"] == {} + assert result["result"] + mock_list.assert_called()