From 0d589ed673351fe83a4d49be74e4b01c9f746797 Mon Sep 17 00:00:00 2001 From: Sudesh0119 Date: Tue, 27 Oct 2026 14:37:00 +0530 Subject: [PATCH 1/3] fix: ADDON-91029 scope observability app.conf lookup to the owning add-on The unscoped app.conf lookup merged every installed app, so telemetry could be labelled with another add-on's name and version. Detect the bundling app from solnlib's install path and scope the lookup to it. No public API or method signature changes. Co-Authored-By: Claude Code --- solnlib/observability.py | 42 +++++++----- solnlib/splunkenv.py | 26 ++++++++ tests/unit/test_observability.py | 107 +++++++++++++++++++++++++++++++ tests/unit/test_splunkenv.py | 22 +++++++ 4 files changed, 181 insertions(+), 16 deletions(-) diff --git a/solnlib/observability.py b/solnlib/observability.py index a8249e1f..8f631281 100644 --- a/solnlib/observability.py +++ b/solnlib/observability.py @@ -53,7 +53,7 @@ import urllib.request from typing import Callable, ClassVar, Optional, Union from ._observability_flush import _register_controller -from .splunkenv import get_conf_stanzas +from .splunkenv import _get_app_name_from_path, get_conf_stanzas from opentelemetry.metrics import Instrument, Meter from opentelemetry.sdk.metrics import MeterProvider, Counter, Histogram from opentelemetry.sdk.metrics.export import ( @@ -392,12 +392,13 @@ def __init__( logger: Python logger (or ``LoggerAdapter``) for all diagnostic output. Typically the caller's own module-level logger. ta_name: Add-on identifier, e.g. ``"Splunk_TA_myapp"``. When - *None* the value is read from the ``[id]`` stanza of - ``app.conf`` via :func:`~solnlib.splunkenv.get_conf_stanzas`. - Pass it explicitly when the add-on runs outside a full Splunk - environment or to avoid the ``app.conf`` lookup. + *None* the value is read from the ``[id]`` stanza of the + ``app.conf`` of the app that bundles solnlib (detected from + the install path, falling back to the app directory name). + Pass it explicitly when the add-on runs outside a standard + ``etc/apps/`` layout or to avoid the ``app.conf`` lookup. ta_version: Add-on version string, e.g. ``"3.1.0"``. When *None* - the value is read from the ``[launcher]`` stanza of + the value is read from the ``[launcher]`` stanza of the same ``app.conf``. Falls back to ``"unknown"`` if it cannot be determined. extra_exporters: Optional list of additional @@ -504,19 +505,28 @@ def __init__( ) def _read_ta_info(self) -> tuple[Optional[str], Optional[str]]: - """Read the add-on name and version from ``app.conf``. + """Read the add-on name and version from the owning app's ``app.conf``. - Returns a ``(ta_name, ta_version)`` tuple. Either value is - ``None`` when the corresponding key is missing or when - ``app.conf`` cannot be read (e.g. outside a Splunk environment). + The owning app is the one that bundles solnlib, detected from the + install path (``etc/apps//...``). The lookup is always scoped to + that app: an unscoped ``app.conf`` merge can return another installed + add-on's ``[id]`` stanza. + + Returns a ``(ta_name, ta_version)`` tuple. Both are ``None`` when the + owning app cannot be detected or ``app.conf`` cannot be read; the + version alone is ``None`` when ``[launcher] version`` is missing. """ - try: - stanzas = get_conf_stanzas("app") - ta_name = stanzas.get("id", {}).get("name") or None - scoped_stanzas = ( - get_conf_stanzas("app", app_name=ta_name) if ta_name else stanzas + app_name = _get_app_name_from_path() + if not app_name: + self._logger.info( + "Could not determine the add-on that bundles solnlib from its " + "install path; pass ta_name explicitly to enable telemetry" ) - ta_version = scoped_stanzas.get("launcher", {}).get("version") or None + return None, None + try: + stanzas = get_conf_stanzas("app", app_name=app_name) + ta_name = stanzas.get("id", {}).get("name") or app_name + ta_version = stanzas.get("launcher", {}).get("version") or None return ta_name, ta_version except Exception as error: self._logger.info( diff --git a/solnlib/splunkenv.py b/solnlib/splunkenv.py index dae14af4..6d20c50a 100644 --- a/solnlib/splunkenv.py +++ b/solnlib/splunkenv.py @@ -22,6 +22,7 @@ import json from configparser import ConfigParser from io import StringIO +from pathlib import Path from typing import Optional, Union import __main__ from solnlib._settings import use_btool @@ -66,6 +67,7 @@ def msp(*args, **kwargs): ETC_LEAF = "etc" APP_SYSTEM = "system" APP_HEC = "splunk_httpinput" +_APPS_DIRS = ("apps", "peer-apps", "slave-apps") class SessionKeyNotFound(Exception): @@ -92,6 +94,30 @@ def make_splunkhome_path(parts: Union[list, tuple]) -> str: return msp(parts) +def _get_app_name_from_path(path: Optional[str] = None) -> Optional[str]: + """Return the name of the Splunk app that contains `path`. + + The app is the directory directly under ``$SPLUNK_HOME/etc/apps`` + (or ``etc/peer-apps`` / ``etc/slave-apps`` on indexer cluster peers). + For example, ``$SPLUNK_HOME/etc/apps/Splunk_TA_test/lib/solnlib/splunkenv.py`` + resolves to ``"Splunk_TA_test"``. + + Arguments: + path: File system path to inspect. Defaults to the location of this + module, i.e. the app that bundles solnlib. + + Returns: + App name, or None if `path` is not inside a Splunk apps directory. + """ + # abspath, not resolve(): apps symlinked into etc/apps must keep their name. + resolved = Path(op.abspath(path or __file__)) + for candidate in (resolved, *resolved.parents): + parent = candidate.parent + if parent.name in _APPS_DIRS and parent.parent.name == ETC_LEAF: + return candidate.name + return None + + def get_splunk_host_info(session_key: Optional[str] = None) -> tuple: """Get splunk host info. diff --git a/tests/unit/test_observability.py b/tests/unit/test_observability.py index fd53a367..86fefc30 100644 --- a/tests/unit/test_observability.py +++ b/tests/unit/test_observability.py @@ -765,6 +765,9 @@ def test_register_instrument_calls_callback(self, logger, monkeypatch): def test_read_ta_info_returns_values(self, logger, monkeypatch): # Arrange + monkeypatch.setattr( + "solnlib.observability._get_app_name_from_path", lambda: "my_ta" + ) monkeypatch.setattr( "solnlib.observability.get_conf_stanzas", lambda conf, **kwargs: { @@ -781,8 +784,109 @@ def test_read_ta_info_returns_values(self, logger, monkeypatch): # Assert assert svc._meter is not None + def test_read_ta_info_always_scoped_to_owning_app(self, logger, monkeypatch): + # Arrange + monkeypatch.setattr( + "solnlib.observability._get_app_name_from_path", + lambda: "Splunk_TA_google-cloudplatform", + ) + get_conf = MagicMock( + return_value={ + "id": {"name": "Splunk_TA_google-cloudplatform"}, + "launcher": {"version": "5.0.0"}, + } + ) + monkeypatch.setattr("solnlib.observability.get_conf_stanzas", get_conf) + svc = _make_service(logger, monkeypatch) + # Act + result = svc._read_ta_info() + # Assert + assert result == ("Splunk_TA_google-cloudplatform", "5.0.0") + get_conf.assert_called_once_with( + "app", app_name="Splunk_TA_google-cloudplatform" + ) + + def test_read_ta_info_reports_owning_app_when_other_apps_installed( + self, logger, monkeypatch + ): + # Arrange: an unscoped lookup would return Splunk_TA_cyberark's [id]. + apps = { + "Splunk_TA_cyberark": { + "id": {"name": "Splunk_TA_cyberark"}, + "launcher": {"version": "1.2.0"}, + }, + "Splunk_TA_google-cloudplatform": { + "id": {"name": "Splunk_TA_google-cloudplatform"}, + "launcher": {"version": "5.0.0"}, + }, + } + + def fake_get_conf_stanzas(conf, app_name=None): + if app_name is None: + return apps["Splunk_TA_cyberark"] + return apps[app_name] + + monkeypatch.setattr( + "solnlib.observability.get_conf_stanzas", fake_get_conf_stanzas + ) + monkeypatch.setattr( + "solnlib.observability._get_app_name_from_path", + lambda: "Splunk_TA_google-cloudplatform", + ) + svc = _make_service(logger, monkeypatch) + # Act / Assert + assert svc._read_ta_info() == ("Splunk_TA_google-cloudplatform", "5.0.0") + + def test_read_ta_info_missing_id_falls_back_to_app_dir(self, logger, monkeypatch): + # Arrange + monkeypatch.setattr( + "solnlib.observability._get_app_name_from_path", lambda: "Splunk_TA_aws" + ) + monkeypatch.setattr( + "solnlib.observability.get_conf_stanzas", + lambda conf, **kwargs: {"launcher": {"version": "7.0.0"}}, + ) + svc = _make_service(logger, monkeypatch) + # Act / Assert + assert svc._read_ta_info() == ("Splunk_TA_aws", "7.0.0") + + def test_read_ta_info_no_detectable_app_returns_none(self, logger, monkeypatch): + # Arrange + monkeypatch.setattr( + "solnlib.observability._get_app_name_from_path", lambda: None + ) + get_conf = MagicMock() + monkeypatch.setattr("solnlib.observability.get_conf_stanzas", get_conf) + svc = _make_service(logger, monkeypatch) + logger.reset_mock() + # Act + result = svc._read_ta_info() + # Assert + assert result == (None, None) + get_conf.assert_not_called() + logger.info.assert_called() + logger.warning.assert_not_called() + + def test_no_detectable_app_skips_telemetry(self, logger, monkeypatch): + # Arrange + monkeypatch.setattr( + "solnlib.observability._get_app_name_from_path", lambda: None + ) + monkeypatch.setattr( + "solnlib.observability.ObservabilityService._create_otlp_exporter", + lambda self: None, + ) + # Act + svc = ObservabilityService(modinput_type="test-input", logger=logger) + # Assert + assert svc._meter is None + logger.warning.assert_not_called() + def test_read_ta_info_handles_exception(self, logger, monkeypatch): # Arrange + monkeypatch.setattr( + "solnlib.observability._get_app_name_from_path", lambda: "my_ta" + ) monkeypatch.setattr( "solnlib.observability.ObservabilityService._create_otlp_exporter", lambda self: None, @@ -1516,6 +1620,9 @@ def test_read_ta_info_exception_uses_safe_str(self, logger, monkeypatch): monkeypatch.setattr( "solnlib.observability._safe_exception_str", lambda error: "SAFE" ) + monkeypatch.setattr( + "solnlib.observability._get_app_name_from_path", lambda: "my_ta" + ) monkeypatch.setattr( "solnlib.observability.ObservabilityService._create_otlp_exporter", lambda self: None, diff --git a/tests/unit/test_splunkenv.py b/tests/unit/test_splunkenv.py index 9580d285..11905035 100644 --- a/tests/unit/test_splunkenv.py +++ b/tests/unit/test_splunkenv.py @@ -164,3 +164,25 @@ def test_splunkenv_used_outside_splunk_env(): "server", "app", session_key="session_key" ) assert exc_info.value.args[0] == "This module requires Splunk to be installed." + + +@pytest.mark.parametrize( + "rel_path,expected", + [ + ("etc/apps/Splunk_TA_aws/lib/solnlib/splunkenv.py", "Splunk_TA_aws"), + ("etc/apps/Splunk_TA_aws/bin/lib/solnlib/splunkenv.py", "Splunk_TA_aws"), + ("etc/peer-apps/Splunk_TA_aws/lib/solnlib/splunkenv.py", "Splunk_TA_aws"), + ("etc/slave-apps/Splunk_TA_aws/lib/solnlib/splunkenv.py", "Splunk_TA_aws"), + ("etc/apps/Splunk_TA_aws", "Splunk_TA_aws"), + ("etc/apps", None), + ("src/apps/Splunk_TA_aws/lib/solnlib/splunkenv.py", None), + ("checkout/solnlib/splunkenv.py", None), + ], +) +def test_get_app_name_from_path(tmp_path, rel_path, expected): + assert splunkenv._get_app_name_from_path(str(tmp_path / rel_path)) == expected + + +def test_get_app_name_from_path_defaults_to_module_location(): + # The repo checkout is not under etc/apps. + assert splunkenv._get_app_name_from_path() is None From 09f02cd26caf9999542730be6c23c0c0b12fb608 Mon Sep 17 00:00:00 2001 From: Sudesh0119 Date: Tue, 27 Oct 2026 20:39:08 +0530 Subject: [PATCH 2/3] fix: detect the running add-on from the main script path solnlib's own __file__ is already symlink-resolved (sys.path[0] is realpath'd), and SPLUNK_ETC may be relocated. Detect the app from the main script first, fall back to solnlib's path, and accept SPLUNK_ETC. Co-Authored-By: Claude Code --- solnlib/observability.py | 22 ++++++----- solnlib/splunkenv.py | 46 ++++++++++++++++++----- tests/unit/test_observability.py | 20 ++++------ tests/unit/test_splunkenv.py | 64 ++++++++++++++++++++++++++++++-- 4 files changed, 116 insertions(+), 36 deletions(-) diff --git a/solnlib/observability.py b/solnlib/observability.py index 8f631281..5330b883 100644 --- a/solnlib/observability.py +++ b/solnlib/observability.py @@ -53,7 +53,7 @@ import urllib.request from typing import Callable, ClassVar, Optional, Union from ._observability_flush import _register_controller -from .splunkenv import _get_app_name_from_path, get_conf_stanzas +from .splunkenv import _get_running_app_name, get_conf_stanzas from opentelemetry.metrics import Instrument, Meter from opentelemetry.sdk.metrics import MeterProvider, Counter, Histogram from opentelemetry.sdk.metrics.export import ( @@ -393,8 +393,9 @@ def __init__( output. Typically the caller's own module-level logger. ta_name: Add-on identifier, e.g. ``"Splunk_TA_myapp"``. When *None* the value is read from the ``[id]`` stanza of the - ``app.conf`` of the app that bundles solnlib (detected from - the install path, falling back to the app directory name). + ``app.conf`` of the running app (detected from the main + script's path, then solnlib's own path; falling back to the + app directory name). Pass it explicitly when the add-on runs outside a standard ``etc/apps/`` layout or to avoid the ``app.conf`` lookup. ta_version: Add-on version string, e.g. ``"3.1.0"``. When *None* @@ -507,20 +508,21 @@ def __init__( def _read_ta_info(self) -> tuple[Optional[str], Optional[str]]: """Read the add-on name and version from the owning app's ``app.conf``. - The owning app is the one that bundles solnlib, detected from the - install path (``etc/apps//...``). The lookup is always scoped to - that app: an unscoped ``app.conf`` merge can return another installed - add-on's ``[id]`` stanza. + The owning app is the one whose script is running + (``etc/apps//bin/...``), or failing that the one that bundles + solnlib. The lookup is always scoped to that app: an unscoped + ``app.conf`` merge can return another installed add-on's ``[id]`` + stanza. Returns a ``(ta_name, ta_version)`` tuple. Both are ``None`` when the owning app cannot be detected or ``app.conf`` cannot be read; the version alone is ``None`` when ``[launcher] version`` is missing. """ - app_name = _get_app_name_from_path() + app_name = _get_running_app_name() if not app_name: self._logger.info( - "Could not determine the add-on that bundles solnlib from its " - "install path; pass ta_name explicitly to enable telemetry" + "Could not determine the running add-on from the script or " + "solnlib path; pass ta_name explicitly to enable telemetry" ) return None, None try: diff --git a/solnlib/splunkenv.py b/solnlib/splunkenv.py index 6d20c50a..3b12d8dd 100644 --- a/solnlib/splunkenv.py +++ b/solnlib/splunkenv.py @@ -19,6 +19,7 @@ import os.path as op import socket import subprocess +import sys import json from configparser import ConfigParser from io import StringIO @@ -94,30 +95,55 @@ def make_splunkhome_path(parts: Union[list, tuple]) -> str: return msp(parts) -def _get_app_name_from_path(path: Optional[str] = None) -> Optional[str]: +def _is_splunk_etc_dir(path: Path) -> bool: + if path.name == ETC_LEAF: + return True + splunk_etc = os.environ.get("SPLUNK_ETC") + return bool(splunk_etc) and op.realpath(splunk_etc) == op.realpath(path) + + +def _get_app_name_from_path(path: str) -> Optional[str]: """Return the name of the Splunk app that contains `path`. - The app is the directory directly under ``$SPLUNK_HOME/etc/apps`` - (or ``etc/peer-apps`` / ``etc/slave-apps`` on indexer cluster peers). - For example, ``$SPLUNK_HOME/etc/apps/Splunk_TA_test/lib/solnlib/splunkenv.py`` - resolves to ``"Splunk_TA_test"``. + The app is the directory directly under ``$SPLUNK_ETC/apps`` (or + ``peer-apps`` / ``slave-apps`` on indexer cluster peers). For example, + ``$SPLUNK_HOME/etc/apps/Splunk_TA_test/bin/input.py`` resolves to + ``"Splunk_TA_test"``. Arguments: - path: File system path to inspect. Defaults to the location of this - module, i.e. the app that bundles solnlib. + path: File system path to inspect. Returns: App name, or None if `path` is not inside a Splunk apps directory. """ # abspath, not resolve(): apps symlinked into etc/apps must keep their name. - resolved = Path(op.abspath(path or __file__)) - for candidate in (resolved, *resolved.parents): + absolute = Path(op.abspath(path)) + for candidate in (absolute, *absolute.parents): parent = candidate.parent - if parent.name in _APPS_DIRS and parent.parent.name == ETC_LEAF: + if parent.name in _APPS_DIRS and _is_splunk_etc_dir(parent.parent): return candidate.name return None +def _get_running_app_name() -> Optional[str]: + """Return the name of the Splunk app whose script is running. + + Tries the main script first (modular inputs, alert actions and search + commands run from ``etc/apps//bin``; this path also keeps + symlinked app directories intact), then falls back to the app that + bundles solnlib. + + Returns: + App name, or None if neither path is inside a Splunk apps directory. + """ + main_path = getattr(__main__, "__file__", None) or ( + sys.argv[0] if sys.argv else None + ) + return (main_path and _get_app_name_from_path(main_path)) or ( + _get_app_name_from_path(__file__) + ) + + def get_splunk_host_info(session_key: Optional[str] = None) -> tuple: """Get splunk host info. diff --git a/tests/unit/test_observability.py b/tests/unit/test_observability.py index 86fefc30..456c0553 100644 --- a/tests/unit/test_observability.py +++ b/tests/unit/test_observability.py @@ -766,7 +766,7 @@ def test_register_instrument_calls_callback(self, logger, monkeypatch): def test_read_ta_info_returns_values(self, logger, monkeypatch): # Arrange monkeypatch.setattr( - "solnlib.observability._get_app_name_from_path", lambda: "my_ta" + "solnlib.observability._get_running_app_name", lambda: "my_ta" ) monkeypatch.setattr( "solnlib.observability.get_conf_stanzas", @@ -787,7 +787,7 @@ def test_read_ta_info_returns_values(self, logger, monkeypatch): def test_read_ta_info_always_scoped_to_owning_app(self, logger, monkeypatch): # Arrange monkeypatch.setattr( - "solnlib.observability._get_app_name_from_path", + "solnlib.observability._get_running_app_name", lambda: "Splunk_TA_google-cloudplatform", ) get_conf = MagicMock( @@ -830,7 +830,7 @@ def fake_get_conf_stanzas(conf, app_name=None): "solnlib.observability.get_conf_stanzas", fake_get_conf_stanzas ) monkeypatch.setattr( - "solnlib.observability._get_app_name_from_path", + "solnlib.observability._get_running_app_name", lambda: "Splunk_TA_google-cloudplatform", ) svc = _make_service(logger, monkeypatch) @@ -840,7 +840,7 @@ def fake_get_conf_stanzas(conf, app_name=None): def test_read_ta_info_missing_id_falls_back_to_app_dir(self, logger, monkeypatch): # Arrange monkeypatch.setattr( - "solnlib.observability._get_app_name_from_path", lambda: "Splunk_TA_aws" + "solnlib.observability._get_running_app_name", lambda: "Splunk_TA_aws" ) monkeypatch.setattr( "solnlib.observability.get_conf_stanzas", @@ -852,9 +852,7 @@ def test_read_ta_info_missing_id_falls_back_to_app_dir(self, logger, monkeypatch def test_read_ta_info_no_detectable_app_returns_none(self, logger, monkeypatch): # Arrange - monkeypatch.setattr( - "solnlib.observability._get_app_name_from_path", lambda: None - ) + monkeypatch.setattr("solnlib.observability._get_running_app_name", lambda: None) get_conf = MagicMock() monkeypatch.setattr("solnlib.observability.get_conf_stanzas", get_conf) svc = _make_service(logger, monkeypatch) @@ -869,9 +867,7 @@ def test_read_ta_info_no_detectable_app_returns_none(self, logger, monkeypatch): def test_no_detectable_app_skips_telemetry(self, logger, monkeypatch): # Arrange - monkeypatch.setattr( - "solnlib.observability._get_app_name_from_path", lambda: None - ) + monkeypatch.setattr("solnlib.observability._get_running_app_name", lambda: None) monkeypatch.setattr( "solnlib.observability.ObservabilityService._create_otlp_exporter", lambda self: None, @@ -885,7 +881,7 @@ def test_no_detectable_app_skips_telemetry(self, logger, monkeypatch): def test_read_ta_info_handles_exception(self, logger, monkeypatch): # Arrange monkeypatch.setattr( - "solnlib.observability._get_app_name_from_path", lambda: "my_ta" + "solnlib.observability._get_running_app_name", lambda: "my_ta" ) monkeypatch.setattr( "solnlib.observability.ObservabilityService._create_otlp_exporter", @@ -1621,7 +1617,7 @@ def test_read_ta_info_exception_uses_safe_str(self, logger, monkeypatch): "solnlib.observability._safe_exception_str", lambda error: "SAFE" ) monkeypatch.setattr( - "solnlib.observability._get_app_name_from_path", lambda: "my_ta" + "solnlib.observability._get_running_app_name", lambda: "my_ta" ) monkeypatch.setattr( "solnlib.observability.ObservabilityService._create_otlp_exporter", diff --git a/tests/unit/test_splunkenv.py b/tests/unit/test_splunkenv.py index 11905035..f8f74166 100644 --- a/tests/unit/test_splunkenv.py +++ b/tests/unit/test_splunkenv.py @@ -179,10 +179,66 @@ def test_splunkenv_used_outside_splunk_env(): ("checkout/solnlib/splunkenv.py", None), ], ) -def test_get_app_name_from_path(tmp_path, rel_path, expected): +def test_get_app_name_from_path(tmp_path, monkeypatch, rel_path, expected): + monkeypatch.delenv("SPLUNK_ETC", raising=False) assert splunkenv._get_app_name_from_path(str(tmp_path / rel_path)) == expected -def test_get_app_name_from_path_defaults_to_module_location(): - # The repo checkout is not under etc/apps. - assert splunkenv._get_app_name_from_path() is None +def test_get_app_name_from_path_relocated_splunk_etc(tmp_path, monkeypatch): + splunk_etc = tmp_path / "splunkcfg" + script = splunk_etc / "apps" / "Splunk_TA_aws" / "bin" / "input.py" + monkeypatch.setenv("SPLUNK_ETC", str(splunk_etc)) + assert splunkenv._get_app_name_from_path(str(script)) == "Splunk_TA_aws" + monkeypatch.delenv("SPLUNK_ETC") + assert splunkenv._get_app_name_from_path(str(script)) is None + + +def test_get_app_name_from_path_keeps_symlinked_app_name(tmp_path): + target = tmp_path / "src" / "MyTA" + (target / "bin").mkdir(parents=True) + (tmp_path / "etc" / "apps").mkdir(parents=True) + (tmp_path / "etc" / "apps" / "MyTA").symlink_to(target) + script = tmp_path / "etc" / "apps" / "MyTA" / "bin" / "input.py" + assert splunkenv._get_app_name_from_path(str(script)) == "MyTA" + + +def test_get_running_app_name_prefers_main_script(tmp_path, monkeypatch): + monkeypatch.setattr( + splunkenv.__main__, + "__file__", + str(tmp_path / "etc/apps/TA_running/bin/input.py"), + raising=False, + ) + monkeypatch.setattr( + splunkenv, "__file__", str(tmp_path / "etc/apps/TA_lib/lib/solnlib/x.py") + ) + assert splunkenv._get_running_app_name() == "TA_running" + + +def test_get_running_app_name_uses_argv_without_main_file(tmp_path, monkeypatch): + monkeypatch.delattr(splunkenv.__main__, "__file__", raising=False) + monkeypatch.setattr( + splunkenv.sys, "argv", [str(tmp_path / "etc/apps/TA_argv/bin/input.py")] + ) + assert splunkenv._get_running_app_name() == "TA_argv" + + +def test_get_running_app_name_falls_back_to_solnlib_path(tmp_path, monkeypatch): + monkeypatch.setattr( + splunkenv.__main__, + "__file__", + str(tmp_path / "opt/splunk/lib/python3/splunk/persistconn/appserver.py"), + raising=False, + ) + monkeypatch.setattr( + splunkenv, "__file__", str(tmp_path / "etc/apps/TA_lib/lib/solnlib/x.py") + ) + assert splunkenv._get_running_app_name() == "TA_lib" + + +def test_get_running_app_name_none_outside_splunk(tmp_path, monkeypatch): + monkeypatch.setattr( + splunkenv.__main__, "__file__", str(tmp_path / "x.py"), raising=False + ) + monkeypatch.setattr(splunkenv, "__file__", str(tmp_path / "solnlib/x.py")) + assert splunkenv._get_running_app_name() is None From 06e9254715f6515d1d610adc311af142487f69db Mon Sep 17 00:00:00 2001 From: Sudesh0119 Date: Wed, 28 Oct 2026 11:35:54 +0530 Subject: [PATCH 3/3] fix: harden running add-on detection Prefer the outermost etc/apps match, compare paths case-insensitively on Windows, tolerate a missing sys.argv or path errors, and keep detection failures inside _read_ta_info so the solnlib-path fallback still applies. Co-Authored-By: Claude Code --- solnlib/observability.py | 18 +++++++++--------- solnlib/splunkenv.py | 32 +++++++++++++++++++++----------- tests/unit/test_observability.py | 11 +++++++++++ tests/unit/test_splunkenv.py | 20 ++++++++++++++++++++ 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/solnlib/observability.py b/solnlib/observability.py index 5330b883..ffa70a39 100644 --- a/solnlib/observability.py +++ b/solnlib/observability.py @@ -435,8 +435,8 @@ def __init__( if not ta_name: raise ValueError( - "ta_name could not be determined: pass it explicitly or ensure " - "app.conf is readable via btool" + "ta_name could not be determined: pass it explicitly or run " + "the add-on from etc/apps/" ) resource = Resource( @@ -518,14 +518,14 @@ def _read_ta_info(self) -> tuple[Optional[str], Optional[str]]: owning app cannot be detected or ``app.conf`` cannot be read; the version alone is ``None`` when ``[launcher] version`` is missing. """ - app_name = _get_running_app_name() - if not app_name: - self._logger.info( - "Could not determine the running add-on from the script or " - "solnlib path; pass ta_name explicitly to enable telemetry" - ) - return None, None try: + app_name = _get_running_app_name() + if not app_name: + self._logger.info( + "Could not determine the running add-on from the script " + "or solnlib path; app.conf not read" + ) + return None, None stanzas = get_conf_stanzas("app", app_name=app_name) ta_name = stanzas.get("id", {}).get("name") or app_name ta_version = stanzas.get("launcher", {}).get("version") or None diff --git a/solnlib/splunkenv.py b/solnlib/splunkenv.py index 3b12d8dd..3846ba33 100644 --- a/solnlib/splunkenv.py +++ b/solnlib/splunkenv.py @@ -96,10 +96,12 @@ def make_splunkhome_path(parts: Union[list, tuple]) -> str: def _is_splunk_etc_dir(path: Path) -> bool: - if path.name == ETC_LEAF: + if op.normcase(path.name) == ETC_LEAF: return True splunk_etc = os.environ.get("SPLUNK_ETC") - return bool(splunk_etc) and op.realpath(splunk_etc) == op.realpath(path) + return bool(splunk_etc) and op.normcase(op.realpath(splunk_etc)) == op.normcase( + op.realpath(path) + ) def _get_app_name_from_path(path: str) -> Optional[str]: @@ -116,12 +118,21 @@ def _get_app_name_from_path(path: str) -> Optional[str]: Returns: App name, or None if `path` is not inside a Splunk apps directory. """ - # abspath, not resolve(): apps symlinked into etc/apps must keep their name. - absolute = Path(op.abspath(path)) - for candidate in (absolute, *absolute.parents): - parent = candidate.parent - if parent.name in _APPS_DIRS and _is_splunk_etc_dir(parent.parent): - return candidate.name + # Not utils.get_appname_from_path: that one returns "-", ignores + # SPLUNK_ETC and slave-apps, and is public, so its behaviour is kept. + try: + # abspath, not resolve(): apps symlinked into etc/apps keep their name. + absolute = Path(op.abspath(path)) + # Outermost match first: Splunk's etc/apps sits above anything an app + # vendors that happens to contain etc/apps. + for candidate in reversed((absolute, *absolute.parents)): + parent = candidate.parent + if op.normcase(parent.name) in _APPS_DIRS and _is_splunk_etc_dir( + parent.parent + ): + return candidate.name + except (OSError, ValueError): + pass return None @@ -136,9 +147,8 @@ def _get_running_app_name() -> Optional[str]: Returns: App name, or None if neither path is inside a Splunk apps directory. """ - main_path = getattr(__main__, "__file__", None) or ( - sys.argv[0] if sys.argv else None - ) + argv = getattr(sys, "argv", None) + main_path = getattr(__main__, "__file__", None) or (argv[0] if argv else None) return (main_path and _get_app_name_from_path(main_path)) or ( _get_app_name_from_path(__file__) ) diff --git a/tests/unit/test_observability.py b/tests/unit/test_observability.py index 456c0553..bde40a54 100644 --- a/tests/unit/test_observability.py +++ b/tests/unit/test_observability.py @@ -878,6 +878,17 @@ def test_no_detectable_app_skips_telemetry(self, logger, monkeypatch): assert svc._meter is None logger.warning.assert_not_called() + def test_read_ta_info_app_detection_error_returns_none(self, logger, monkeypatch): + monkeypatch.setattr( + "solnlib.observability._get_running_app_name", + MagicMock(side_effect=RuntimeError("boom")), + ) + svc = _make_service(logger, monkeypatch) + logger.reset_mock() + assert svc._read_ta_info() == (None, None) + logger.info.assert_called() + logger.warning.assert_not_called() + def test_read_ta_info_handles_exception(self, logger, monkeypatch): # Arrange monkeypatch.setattr( diff --git a/tests/unit/test_splunkenv.py b/tests/unit/test_splunkenv.py index f8f74166..cc185f40 100644 --- a/tests/unit/test_splunkenv.py +++ b/tests/unit/test_splunkenv.py @@ -176,6 +176,9 @@ def test_splunkenv_used_outside_splunk_env(): ("etc/apps/Splunk_TA_aws", "Splunk_TA_aws"), ("etc/apps", None), ("src/apps/Splunk_TA_aws/lib/solnlib/splunkenv.py", None), + ("etc/apps/Splunk_TA_aws/lib/pkg/etc/apps/inner/x.py", "Splunk_TA_aws"), + ("etc/deployment-apps/Splunk_TA_aws/bin/input.py", None), + ("etc/shcluster/apps/Splunk_TA_aws/bin/input.py", None), ("checkout/solnlib/splunkenv.py", None), ], ) @@ -223,6 +226,23 @@ def test_get_running_app_name_uses_argv_without_main_file(tmp_path, monkeypatch) assert splunkenv._get_running_app_name() == "TA_argv" +def test_get_running_app_name_without_sys_argv(tmp_path, monkeypatch): + monkeypatch.delattr(splunkenv.__main__, "__file__", raising=False) + monkeypatch.delattr(splunkenv.sys, "argv") + monkeypatch.setattr( + splunkenv, "__file__", str(tmp_path / "etc/apps/TA_lib/lib/solnlib/x.py") + ) + assert splunkenv._get_running_app_name() == "TA_lib" + + +def test_get_running_app_name_survives_path_errors(tmp_path, monkeypatch): + monkeypatch.setattr( + splunkenv.__main__, "__file__", "relative/input.py", raising=False + ) + monkeypatch.setattr(splunkenv.op, "abspath", mock.Mock(side_effect=OSError)) + assert splunkenv._get_running_app_name() is None + + def test_get_running_app_name_falls_back_to_solnlib_path(tmp_path, monkeypatch): monkeypatch.setattr( splunkenv.__main__,