diff --git a/.github/workflows/build-test-release.yml b/.github/workflows/build-test-release.yml index 5d5bf276..9caf4913 100644 --- a/.github/workflows/build-test-release.yml +++ b/.github/workflows/build-test-release.yml @@ -119,23 +119,23 @@ jobs: export SPLUNK_HOME=/opt/splunk wget -qO /tmp/splunk.tgz "${SPLUNK_BUILD_URL}" sudo tar -C /opt -zxf /tmp/splunk.tgz - sudo cp -r tests/integration/data/solnlib_demo $SPLUNK_HOME/etc/apps - sudo cp -r solnlib $SPLUNK_HOME/etc/apps/solnlib_demo/bin/ - sudo mkdir -p $SPLUNK_HOME/etc/apps/Splunk_TA_test/default/ - sudo chown -R "$USER":"$USER" /opt/splunk + sudo chown -R "$USER":"$USER" $SPLUNK_HOME + cp -r tests/integration/data/solnlib_demo $SPLUNK_HOME/etc/apps + cp -r solnlib $SPLUNK_HOME/etc/apps/solnlib_demo/bin/ + mkdir -p $SPLUNK_HOME/etc/apps/Splunk_TA_test/default/ ls $SPLUNK_HOME/etc/apps/solnlib_demo/bin/ - echo -e "[user_info]\nUSERNAME=Admin\nPASSWORD=Chang3d"'!' | sudo tee -a /opt/splunk/etc/system/local/user-seed.conf - echo 'OPTIMISTIC_ABOUT_FILE_LOCKING=1' | sudo tee -a /opt/splunk/etc/splunk-launch.conf - sudo /opt/splunk/bin/splunk start --accept-license - sudo /opt/splunk/bin/splunk cmd python -m pip install solnlib - sudo /opt/splunk/bin/splunk set servername custom-servername -auth admin:Chang3d! - sudo /opt/splunk/bin/splunk restart + echo -e "[user_info]\nUSERNAME=Admin\nPASSWORD=Chang3d"'!' | tee -a $SPLUNK_HOME/etc/system/local/user-seed.conf + echo 'OPTIMISTIC_ABOUT_FILE_LOCKING=1' | tee -a $SPLUNK_HOME/etc/splunk-launch.conf + $SPLUNK_HOME/bin/splunk start --accept-license + $SPLUNK_HOME/bin/splunk cmd python -m pip install solnlib + $SPLUNK_HOME/bin/splunk set servername custom-servername -auth admin:Chang3d! + $SPLUNK_HOME/bin/splunk restart until curl -k -s -u admin:Chang3d! https://localhost:8089/services/server/info\?output_mode\=json | jq '.entry[0].content.kvStoreStatus' | grep -o "ready" ; do echo -n "Waiting for KVStore to become ready-" && sleep 5 ; done timeout-minutes: 5 - name: Run tests run: | poetry install - SPLUNK_HOME=/opt/splunk/ poetry run pytest --junitxml=test-results/results.xml -v tests/integration + SPLUNK_HOME=/opt/splunk SPLUNK_DB=$SPLUNK_HOME/var/lib/splunk poetry run pytest --junitxml=test-results/results.xml -v tests/integration - uses: actions/upload-artifact@v4 with: name: test-splunk-${{ matrix.splunk.version }} diff --git a/.gitignore b/.gitignore index df66d9d2..8954eebf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # IDE related files *.idea *.DS_Store* -.venv/ +# ignore all virtual environments +.venv* # Compiled files __pycache__ @@ -9,3 +10,5 @@ __pycache__ *.pyo .coverage +*.log +events.pickle diff --git a/docs/bulletin_rest_client.md b/docs/bulletin_rest_client.md new file mode 100644 index 00000000..361aff8d --- /dev/null +++ b/docs/bulletin_rest_client.md @@ -0,0 +1 @@ +::: solnlib.bulletin_rest_client \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 312dcb13..04e27ab8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,6 +41,7 @@ nav: - "pattern.py": pattern.md - "server_info.py": server_info.md - "splunk_rest_client.py": splunk_rest_client.md + - "bulletin_rest_client.py": bulletin_rest_client.md - "splunkenv.py": splunkenv.md - "time_parser.py": time_parser.md - "timer_queue.py": timer_queue.md diff --git a/pyproject.toml b/pyproject.toml index d868f131..2d61b050 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ [tool.poetry] name = "solnlib" -version = "5.0.0" +version = "5.1.0-beta.2" description = "The Splunk Software Development Kit for Splunk Solutions" authors = ["Splunk "] license = "Apache-2.0" diff --git a/solnlib/__init__.py b/solnlib/__init__.py index 59213446..32a94f26 100644 --- a/solnlib/__init__.py +++ b/solnlib/__init__.py @@ -18,6 +18,7 @@ from . import ( acl, + bulletin_rest_client, conf_manager, credentials, file_monitor, @@ -37,6 +38,7 @@ __all__ = [ "acl", + "bulletin_rest_client", "conf_manager", "credentials", "file_monitor", @@ -54,4 +56,4 @@ "utils", ] -__version__ = "5.0.0" +__version__ = "5.1.0-beta.2" diff --git a/solnlib/bulletin_rest_client.py b/solnlib/bulletin_rest_client.py new file mode 100644 index 00000000..bf0c7158 --- /dev/null +++ b/solnlib/bulletin_rest_client.py @@ -0,0 +1,151 @@ +# +# Copyright 2024 Splunk Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from solnlib import splunk_rest_client as rest_client +from typing import Optional, List +import json + +__all__ = ["BulletinRestClient"] + + +class BulletinRestClient: + """REST client for handling Bulletin messages.""" + + MESSAGES_ENDPOINT = "/services/messages" + + headers = [("Content-Type", "application/json")] + + class Severity: + INFO = "info" + WARNING = "warn" + ERROR = "error" + + def __init__( + self, + message_name: str, + session_key: str, + app: str, + **context: dict, + ): + """Initializes BulletinRestClient. + When creating a new bulletin message, you must provide a name, which is a kind of ID. + If you try to create another message with the same name (ID), the API will not add another message + to the bulletin, but it will overwrite the existing one. Similar behaviour applies to deletion. + To delete a message, you must indicate the name (ID) of the message. + To provide better and easier control over bulletin messages, this client works in such a way + that there is one instance responsible for handling one specific message. + If you need to add another message to bulletin create another instance + with a different 'message_name' + e.g. + msg_1 = BulletinRestClient("message_1", "") + msg_2 = BulletinRestClient("message_2", "") + + Arguments: + message_name: Name of the message in the Splunk's bulletin. + session_key: Splunk access token. + app: App name of namespace. + context: Other configurations for Splunk rest client. + """ + + self.message_name = message_name + self.session_key = session_key + self.app = app + + self._rest_client = rest_client.SplunkRestClient( + self.session_key, app=self.app, **context + ) + + def create_message( + self, + msg: str, + severity: Severity = Severity.WARNING, + capabilities: Optional[List[str]] = None, + roles: Optional[List] = None, + ): + """Creates a message in the Splunk's bulletin. Calling this method + multiple times for the same instance will overwrite existing message. + + Arguments: + msg: The message which will be displayed in the Splunk's bulletin + severity: Severity level of the message. It has to be one of: 'info', 'warn', 'error'. + If wrong severity is given, ValueError will be raised. + capabilities: One or more capabilities that users must have to view the message. + Capability names are validated. + This argument should be provided as a list of string/s e.g. capabilities=['one', 'two']. + If a non-existent capability is used, HTTP 400 BAD REQUEST exception will be raised. + If argument is not a List[str] ValueError will be raised. + roles: One or more roles that users must have to view the message. Role names are validated. + This argument should be provided as a list of string/s e.g. roles=['user', 'admin']. + If a non-existent role is used, HTTP 400 BAD REQUEST exception will be raised. + If argument is not a List[str] ValueError will be raised. + """ + body = { + "name": self.message_name, + "value": msg, + "severity": severity, + "capability": [], + "role": [], + } + + if severity not in ( + self.Severity.INFO, + self.Severity.WARNING, + self.Severity.ERROR, + ): + raise ValueError( + "Severity must be one of (" + "'BulletinRestClient.Severity.INFO', " + "'BulletinRestClient.Severity.WARNING', " + "'BulletinRestClient.Severity.ERROR'" + ")." + ) + + if capabilities: + body["capability"] = self._validate_and_get_body_value( + capabilities, "Capabilities must be a list of strings." + ) + + if roles: + body["role"] = self._validate_and_get_body_value( + roles, "Roles must be a list of strings." + ) + + self._rest_client.post(self.MESSAGES_ENDPOINT, body=body, headers=self.headers) + + def get_message(self): + """Get specific message created by this instance.""" + endpoint = f"{self.MESSAGES_ENDPOINT}/{self.message_name}" + response = self._rest_client.get(endpoint, output_mode="json").body.read() + return json.loads(response) + + def get_all_messages(self): + """Get all messages in the bulletin.""" + response = self._rest_client.get( + self.MESSAGES_ENDPOINT, output_mode="json" + ).body.read() + return json.loads(response) + + def delete_message(self): + """Delete specific message created by this instance.""" + endpoint = f"{self.MESSAGES_ENDPOINT}/{self.message_name}" + self._rest_client.delete(endpoint) + + @staticmethod + def _validate_and_get_body_value(arg, error_msg) -> List: + if type(arg) is list and (all(isinstance(el, str) for el in arg)): + return [el for el in arg] + else: + raise ValueError(error_msg) diff --git a/solnlib/server_info.py b/solnlib/server_info.py index 7bb24618..01643067 100644 --- a/solnlib/server_info.py +++ b/solnlib/server_info.py @@ -16,13 +16,15 @@ """This module contains Splunk server info related functionalities.""" +import os import json from typing import Any, Dict, Optional +from splunk.rest import getWebCertFile, getWebKeyFile from splunklib import binding - from solnlib import splunk_rest_client as rest_client from solnlib import utils +from solnlib.splunkenv import get_splunkd_access_info __all__ = ["ServerInfo", "ServerInfoException"] @@ -56,6 +58,28 @@ def __init__( port: The port number, default is None. context: Other configurations for Splunk rest client. """ + is_localhost = False + if not all([scheme, host, port]) and os.environ.get("SPLUNK_HOME"): + scheme, host, port = get_splunkd_access_info() + is_localhost = ( + host == "localhost" or host == "127.0.0.1" or host in ("::1", "[::1]") + ) + + if getWebCertFile() and getWebKeyFile(): + context["cert_file"] = getWebCertFile() + context["key_file"] = getWebKeyFile() + + if all([is_localhost, context.get("verify") is None]): + # NOTE: this is specifically for mTLS communication + # ONLY if scheme, host, port aren't provided AND user hasn't provided server certificate + # we set verify to off (similar to 'rest.simpleRequest' implementation) + context["verify"] = False + + elif getWebCertFile() is not None: + context["cert_file"] = getWebCertFile() + if all([is_localhost, context.get("verify") is None]): + context["verify"] = False + self._rest_client = rest_client.SplunkRestClient( session_key, "-", scheme=scheme, host=host, port=port, **context ) diff --git a/solnlib/splunk_rest_client.py b/solnlib/splunk_rest_client.py index 42e93118..c83c2545 100644 --- a/solnlib/splunk_rest_client.py +++ b/solnlib/splunk_rest_client.py @@ -88,10 +88,13 @@ def _request_handler(context): verify = context.get("verify", False) if context.get("key_file") and context.get("cert_file"): - # cert = ('/path/client.cert', '/path/client.key') - cert = context["key_file"], context["cert_file"] + # cert: if tuple, ('cert', 'key') pair as per requests library + cert = context["cert_file"], context["key_file"] elif context.get("cert_file"): cert = context["cert_file"] + elif context.get("cert"): + # as the solnlib uses requests, we need to have a check for 'cert' key as well + cert = context["cert"] else: cert = None diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 00000000..0db43b93 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,8 @@ +import os +import sys + +# path manipulation get the 'splunk' library for the imports while running on GH Actions +sys.path.append( + os.path.sep.join([os.environ["SPLUNK_HOME"], "lib", "python3.7", "site-packages"]) +) +# TODO: 'python3.7' needs to be updated as and when Splunk has new folder for Python. diff --git a/tests/integration/test_bulletin_rest_client.py b/tests/integration/test_bulletin_rest_client.py new file mode 100644 index 00000000..21e344b7 --- /dev/null +++ b/tests/integration/test_bulletin_rest_client.py @@ -0,0 +1,108 @@ +# +# Copyright 2024 Splunk Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import context +from splunklib import binding +import pytest +from solnlib import bulletin_rest_client as brc + + +def _build_bulletin_manager(msg_name, session_key: str) -> brc.BulletinRestClient: + return brc.BulletinRestClient( + msg_name, + session_key, + "-", + owner=context.owner, + scheme=context.scheme, + host=context.host, + port=context.port, + ) + + +def test_create_message(): + session_key = context.get_session_key() + bulletin_client = _build_bulletin_manager("msg_name", session_key) + + with pytest.raises(binding.HTTPError) as e: + bulletin_client.create_message( + "new message to bulletin", + capabilities=["apps_restore", "unknown_cap"], + roles=["admin"], + ) + assert str(e.value.status) == "400" + + with pytest.raises(binding.HTTPError) as e: + bulletin_client.create_message( + "new message to bulletin", roles=["unknown_role"] + ) + assert str(e.value.status) == "400" + + +def test_bulletin_rest_api(): + session_key = context.get_session_key() + bulletin_client_1 = _build_bulletin_manager("msg_name_1", session_key) + bulletin_client_2 = _build_bulletin_manager("msg_name_2", session_key) + + bulletin_client_1.create_message( + "new message to bulletin", + capabilities=["apps_restore", "delete_messages"], + roles=["admin"], + ) + + get_msg_1 = bulletin_client_1.get_message() + assert get_msg_1["entry"][0]["content"]["message"] == "new message to bulletin" + assert get_msg_1["entry"][0]["content"]["severity"] == "warn" + + bulletin_client_1.create_message( + "new message to bulletin", bulletin_client_1.Severity.INFO + ) + get_msg_1 = bulletin_client_1.get_message() + assert get_msg_1["entry"][0]["content"]["severity"] == "info" + + bulletin_client_1.create_message( + "new message to bulletin", bulletin_client_1.Severity.ERROR + ) + get_msg_1 = bulletin_client_1.get_message() + assert get_msg_1["entry"][0]["content"]["severity"] == "error" + + get_all_msg = bulletin_client_1.get_all_messages() + assert len(get_all_msg["entry"]) == 1 + + bulletin_client_2.create_message("new message to bulletin 2") + + get_msg_2 = bulletin_client_2.get_message() + assert get_msg_2["entry"][0]["content"]["message"] == "new message to bulletin 2" + + get_all_msg = bulletin_client_1.get_all_messages() + assert len(get_all_msg["entry"]) == 2 + + bulletin_client_1.delete_message() + + with pytest.raises(binding.HTTPError) as e: + bulletin_client_1.get_message() + assert str(e.value.status) == "404" + + with pytest.raises(binding.HTTPError) as e: + bulletin_client_1.delete_message() + assert str(e.value.status) == "404" + + get_all_msg = bulletin_client_1.get_all_messages() + assert len(get_all_msg["entry"]) == 1 + + bulletin_client_2.delete_message() + + get_all_msg = bulletin_client_1.get_all_messages() + assert len(get_all_msg["entry"]) == 0 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..1dbefb3d --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,6 @@ +import sys +from unittest.mock import MagicMock + +# mock modules of 'splunk' library added 'splunk_rest_client' +sys.modules["splunk"] = MagicMock() +sys.modules["splunk.rest"] = MagicMock() diff --git a/tests/unit/test_bulletin_rest_client.py b/tests/unit/test_bulletin_rest_client.py new file mode 100644 index 00000000..8bebe7a2 --- /dev/null +++ b/tests/unit/test_bulletin_rest_client.py @@ -0,0 +1,64 @@ +# +# Copyright 2024 Splunk Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +from solnlib.bulletin_rest_client import BulletinRestClient + + +context = {"owner": "nobody", "scheme": "https", "host": "localhost", "port": 8089} + + +def test_create_message(monkeypatch): + session_key = "123" + bulletin_client = BulletinRestClient( + "msg_name_1", + session_key, + "_", + **context, + ) + + def new_post(*args, **kwargs) -> str: + return "ok" + + monkeypatch.setattr(bulletin_client._rest_client, "post", new_post) + + bulletin_client.create_message( + "new message to bulletin", + capabilities=["apps_restore", "delete_messages"], + roles=["admin"], + ) + + with pytest.raises(ValueError, match="Severity must be one of"): + bulletin_client.create_message( + "new message to bulletin", + severity="debug", + capabilities=["apps_restore", "delete_messages", 1], + roles=["admin"], + ) + + with pytest.raises(ValueError, match="Capabilities must be a list of strings."): + bulletin_client.create_message( + "new message to bulletin", + capabilities=["apps_restore", "delete_messages", 1], + roles=["admin"], + ) + + with pytest.raises(ValueError, match="Roles must be a list of strings."): + bulletin_client.create_message( + "new message to bulletin", + capabilities=["apps_restore", "delete_messages"], + roles=["admin", 1], + ) diff --git a/tests/unit/test_server_info.py b/tests/unit/test_server_info.py index f312baad..f7f6e5a6 100644 --- a/tests/unit/test_server_info.py +++ b/tests/unit/test_server_info.py @@ -13,8 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import common import pytest +import common +from unittest.mock import patch, MagicMock from splunklib import binding from solnlib import server_info @@ -72,3 +73,91 @@ def _mock_get(self, path_segment, owner=None, app=None, sharing=None, **query): si = server_info.ServerInfo(common.SESSION_KEY) assert si.is_captain_ready() + + @patch("solnlib.server_info.os.environ", autospec=True, return_value="$SPLUNK_HOME") + @patch( + "solnlib.server_info.get_splunkd_access_info", + autospec=True, + return_value=("https", "127.0.0.1", "8089"), + ) + @patch("solnlib.server_info.rest_client", autospec=True) + @patch("solnlib.server_info.getWebCertFile", return_value=None) + @patch("solnlib.server_info.getWebKeyFile", return_value=None) + def test_server_info_object_with_no_certs( + self, mock_web_key, mock_web_cert, mock_rest_client, mock_splunkd, mock_os_env + ): + mock_rest_client.SplunkRestClient = MagicMock() + + server_info.ServerInfo(common.SESSION_KEY) + + for call_arg in mock_rest_client.SplunkRestClient.call_args_list: + _, kwargs = call_arg + assert kwargs.get("cert_file") is None + assert kwargs.get("key_file") is None + assert kwargs.get("verify") is None + + @patch("solnlib.server_info.os.environ", autospec=True, return_value="$SPLUNK_HOME") + @patch( + "solnlib.server_info.get_splunkd_access_info", + autospec=True, + return_value=("https", "127.0.0.1", "8089"), + ) + @patch("solnlib.server_info.rest_client", autospec=True) + @patch("solnlib.server_info.getWebCertFile", return_value="/path/cert/pem") + @patch("solnlib.server_info.getWebKeyFile", return_value="/path/key/pem") + def test_server_info_object_with_both_certs( + self, mock_web_key, mock_web_cert, mock_rest_client, mock_splunkd, mock_os_env + ): + mock_rest_client.SplunkRestClient = MagicMock() + + server_info.ServerInfo(common.SESSION_KEY) + + for call_arg in mock_rest_client.SplunkRestClient.call_args_list: + _, kwargs = call_arg + assert kwargs.get("cert_file") == "/path/cert/pem" + assert kwargs.get("key_file") == "/path/key/pem" + assert kwargs.get("verify") is False + + @patch("solnlib.server_info.os.environ", autospec=True, return_value="$SPLUNK_HOME") + @patch( + "solnlib.server_info.get_splunkd_access_info", + autospec=True, + return_value=("https", "127.0.0.1", "8089"), + ) + @patch("solnlib.server_info.rest_client", autospec=True) + @patch("solnlib.server_info.getWebCertFile", return_value="/path/cert/pem") + @patch("solnlib.server_info.getWebKeyFile", return_value=None) + def test_server_info_object_with_cert_file( + self, mock_web_key, mock_web_cert, mock_rest_client, mock_splunkd, mock_os_env + ): + mock_rest_client.SplunkRestClient = MagicMock() + + server_info.ServerInfo(common.SESSION_KEY) + + for call_arg in mock_rest_client.SplunkRestClient.call_args_list: + _, kwargs = call_arg + assert kwargs.get("cert_file") == "/path/cert/pem" + assert kwargs.get("key_file") is None + assert kwargs.get("verify") is False + + @patch("solnlib.server_info.os.environ", autospec=True, return_value="$SPLUNK_HOME") + @patch( + "solnlib.server_info.get_splunkd_access_info", + autospec=True, + return_value=("https", "127.0.0.1", "8089"), + ) + @patch("solnlib.server_info.rest_client", autospec=True) + @patch("solnlib.server_info.getWebCertFile", return_value=None) + @patch("solnlib.server_info.getWebKeyFile", return_value="/path/key/pem") + def test_server_info_object_with_key_file( + self, mock_web_key, mock_web_cert, mock_rest_client, mock_splunkd, mock_os_env + ): + mock_rest_client.SplunkRestClient = MagicMock() + + server_info.ServerInfo(common.SESSION_KEY) + + for call_arg in mock_rest_client.SplunkRestClient.call_args_list: + _, kwargs = call_arg + assert kwargs.get("cert_file") is None + assert kwargs.get("key_file") is None + assert kwargs.get("verify") is None