├── .addonmatrix ├── .flake8 ├── .fossa.yml ├── .github ├── CODEOWNERS └── workflows │ ├── agreements.yaml │ ├── build-test-release.yml │ └── docs.yaml ├── .gitignore ├── .licenserc.yaml ├── .pre-commit-config.yaml ├── .releaserc ├── .semgrepignore ├── LICENSE ├── README.md ├── docs ├── acl.md ├── bulletin_rest_client.md ├── conf_manager.md ├── credentials.md ├── file_monitor.md ├── hec_config.md ├── index.md ├── log.md ├── modular_input │ ├── checkpointer.md │ ├── event.md │ ├── event_writer.md │ └── modular_input.md ├── net_utils.md ├── orphan_process_monitor.md ├── pattern.md ├── release_6_0_0.md ├── server_info.md ├── splunk_rest_client.md ├── splunkenv.md ├── theme_overrides │ └── partials │ │ └── header.html ├── time_parser.md ├── timer_queue.md ├── user_access.md └── utils.md ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── renovate.json ├── solnlib ├── __init__.py ├── _utils.py ├── acl.py ├── alerts_rest_client.py ├── bulletin_rest_client.py ├── concurrent │ ├── concurrent_executor.py │ ├── process_pool.py │ └── thread_pool.py ├── conf_manager.py ├── credentials.py ├── file_monitor.py ├── hec_config.py ├── log.py ├── modular_input │ ├── __init__.py │ ├── checkpointer.py │ ├── event.py │ ├── event_writer.py │ ├── modinput.py │ └── modular_input.py ├── net_utils.py ├── orphan_process_monitor.py ├── pattern.py ├── rest.py ├── schedule │ ├── job.py │ └── scheduler.py ├── server_info.py ├── soln_exceptions.py ├── splunk_rest_client.py ├── splunkenv.py ├── time_parser.py ├── timer_queue.py ├── user_access.py └── utils.py └── tests ├── integration ├── _search.py ├── conftest.py ├── context.py ├── data │ └── solnlib_demo │ │ ├── README.txt │ │ ├── README │ │ └── inputs.conf.spec │ │ ├── bin │ │ └── solnlib_demo_collector.py │ │ ├── default │ │ ├── app.conf │ │ ├── inputs.conf │ │ ├── splunk_ta_addon_settings.conf │ │ └── splunk_ta_addon_settings_invalid.conf │ │ ├── local │ │ ├── collections.conf │ │ └── eventtypes.conf │ │ └── metadata │ │ ├── default.meta │ │ └── local.meta ├── test__kvstore.py ├── test_acl.py ├── test_alerts_rest_client.py ├── test_bulletin_rest_client.py ├── test_conf_manager.py ├── test_credentials.py ├── test_hec_config.py ├── test_hec_event_writer.py ├── test_logger.py ├── test_server_info.py ├── test_splunk_rest_client.py ├── test_splunkenv.py ├── test_time_parser.py └── test_user_access.py └── unit ├── common.py ├── data └── mock_splunk │ └── etc │ ├── apps │ ├── splunk_httpinput │ │ └── local │ │ │ └── inputs.conf │ └── unittest │ │ └── metadata │ │ ├── default.meta │ │ └── local.meta │ └── system │ └── default │ ├── server.conf │ └── web.conf ├── fakes ├── __init__.py └── fake_kv_store_collection_data.py ├── test_acl.py ├── test_bulletin_rest_client.py ├── test_conf_manager.py ├── test_credentials.py ├── test_file_monitor.py ├── test_log.py ├── test_modular_input.py ├── test_modular_input_checkpointer.py ├── test_modular_input_event.py ├── test_modular_input_event_writer.py ├── test_net_utils.py ├── test_orphan_process_monitor.py ├── test_server_info.py ├── test_splunk_rest_client.py ├── test_splunkenv.py ├── test_time_parser.py ├── test_timer_queue.py ├── test_user_access.py └── test_utils.py /.addonmatrix: -------------------------------------------------------------------------------- 1 | --splunkfeatures METRICS_MULTI,PYTHON3 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /.fossa.yml: -------------------------------------------------------------------------------- 1 | version: 3 2 | server: https://app.fossa.com 3 | 4 | project: 5 | id: "addonfactory-solutions-library-python" 6 | team: "TA-Automation" 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @splunk/ucc-be-developers 2 | -------------------------------------------------------------------------------- /.github/workflows/agreements.yaml: -------------------------------------------------------------------------------- 1 | name: "CLA Assistant" 2 | on: 3 | issue_comment: 4 | types: [created] 5 | pull_request_target: 6 | types: [opened, closed, synchronize] 7 | 8 | jobs: 9 | call-workflow-agreements: 10 | uses: splunk/addonfactory-github-workflows/.github/workflows/reusable-agreements.yaml@v1 11 | permissions: 12 | actions: read 13 | contents: read 14 | pull-requests: write 15 | statuses: read 16 | secrets: 17 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | PERSONAL_ACCESS_TOKEN: ${{ secrets.PAT_CLATOOL }} 19 | -------------------------------------------------------------------------------- /.github/workflows/build-test-release.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | - "develop" 8 | tags: 9 | - "v[0-9]+.[0-9]+.[0-9]+" 10 | pull_request: 11 | branches: [main, develop] 12 | 13 | jobs: 14 | meta: 15 | runs-on: ubuntu-latest 16 | outputs: 17 | matrix_supportedSplunk: ${{ steps.matrix.outputs.supportedSplunk }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | - id: matrix 21 | uses: splunk/addonfactory-test-matrix-action@v2 22 | 23 | fossa-scan: 24 | continue-on-error: true 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: run fossa anlyze and create report 29 | run: | 30 | curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install-latest.sh | bash 31 | fossa analyze --debug 32 | fossa report attribution --format text > /tmp/THIRDPARTY 33 | env: 34 | FOSSA_API_KEY: ${{ secrets.FOSSA_API_KEY }} 35 | - name: upload THIRDPARTY file 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: THIRDPARTY 39 | path: /tmp/THIRDPARTY 40 | - name: run fossa test 41 | run: | 42 | fossa test --debug 43 | env: 44 | FOSSA_API_KEY: ${{ secrets.FOSSA_API_KEY }} 45 | 46 | compliance-copyrights: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: apache/skywalking-eyes@v0.6.0 51 | 52 | pre-commit: 53 | runs-on: ubuntu-22.04 54 | steps: 55 | - uses: actions/checkout@v4 56 | - uses: actions/setup-python@v5 57 | with: 58 | python-version: "3.7" 59 | - uses: pre-commit/action@v3.0.1 60 | 61 | semgrep: 62 | if: github.actor != 'dependabot[bot]' 63 | uses: splunk/sast-scanning/.github/workflows/sast-scan.yml@main 64 | secrets: 65 | SEMGREP_KEY: ${{ secrets.SEMGREP_PUBLISH_TOKEN }} 66 | 67 | run-unit-tests: 68 | name: test-unit ${{ matrix.python-version }} 69 | runs-on: ubuntu-22.04 70 | continue-on-error: true 71 | strategy: 72 | matrix: 73 | python-version: 74 | - "3.7" 75 | - "3.8" 76 | - "3.9" 77 | - "3.10" 78 | - "3.11" 79 | - "3.12" 80 | - "3.13" 81 | steps: 82 | - uses: actions/checkout@v4 83 | - uses: actions/setup-python@v5 84 | with: 85 | python-version: ${{ matrix.python-version }} 86 | - run: curl -sSL https://install.python-poetry.org | python3 - --version 1.5.1 87 | - run: | 88 | poetry install 89 | poetry run pytest tests/unit 90 | 91 | test-splunk: 92 | runs-on: ubuntu-22.04 93 | continue-on-error: true 94 | needs: 95 | - meta 96 | strategy: 97 | matrix: 98 | splunk: ${{ fromJson(needs.meta.outputs.matrix_supportedSplunk) }} 99 | steps: 100 | - uses: actions/checkout@v4 101 | - uses: actions/setup-python@v5 102 | with: 103 | python-version: 3.7 104 | - run: curl -sSL https://install.python-poetry.org | python3 - --version 1.5.1 105 | - name: Install Splunk 106 | run: | 107 | export SPLUNK_PRODUCT=splunk 108 | export SPLUNK_VERSION=${{ matrix.splunk.version }} 109 | export SPLUNK_BUILD=${{ matrix.splunk.build }} 110 | export SPLUNK_SLUG=$SPLUNK_VERSION-$SPLUNK_BUILD 111 | export SPLUNK_ARCH=amd64 112 | export SPLUNK_LINUX_FILENAME=splunk-${SPLUNK_VERSION}-${SPLUNK_BUILD}-linux-${SPLUNK_ARCH}.tgz 113 | 114 | # Before 9.4, the filename was splunk---Linux-x86_64.tgz 115 | if [[ $(echo $SPLUNK_VERSION | cut -d. -f1) -le 8 ]] || \ 116 | [[ $SPLUNK_VERSION == 9.0.* ]] || \ 117 | [[ $SPLUNK_VERSION == 9.1.* ]] || \ 118 | [[ $SPLUNK_VERSION == 9.2.* ]] || \ 119 | [[ $SPLUNK_VERSION == 9.3.* ]] 120 | then 121 | export SPLUNK_ARCH=x86_64 122 | export SPLUNK_LINUX_FILENAME=splunk-${SPLUNK_VERSION}-${SPLUNK_BUILD}-Linux-${SPLUNK_ARCH}.tgz 123 | fi 124 | 125 | export SPLUNK_BUILD_URL=https://download.splunk.com/products/${SPLUNK_PRODUCT}/releases/${SPLUNK_VERSION}/linux/${SPLUNK_LINUX_FILENAME} 126 | echo "$SPLUNK_BUILD_URL" 127 | export SPLUNK_HOME=/opt/splunk 128 | wget -qO /tmp/splunk.tgz "${SPLUNK_BUILD_URL}" 129 | sudo tar -C /opt -zxf /tmp/splunk.tgz 130 | sudo chown -R "$USER":"$USER" $SPLUNK_HOME 131 | cp -r tests/integration/data/solnlib_demo $SPLUNK_HOME/etc/apps 132 | cp -r solnlib $SPLUNK_HOME/etc/apps/solnlib_demo/bin/ 133 | mkdir -p $SPLUNK_HOME/etc/apps/Splunk_TA_test/default/ 134 | ls $SPLUNK_HOME/etc/apps/solnlib_demo/bin/ 135 | echo -e "[user_info]\nUSERNAME=Admin\nPASSWORD=Chang3d"'!' | tee -a $SPLUNK_HOME/etc/system/local/user-seed.conf 136 | echo 'OPTIMISTIC_ABOUT_FILE_LOCKING=1' | tee -a $SPLUNK_HOME/etc/splunk-launch.conf 137 | $SPLUNK_HOME/bin/splunk start --accept-license 138 | $SPLUNK_HOME/bin/splunk cmd python -m pip install solnlib 139 | $SPLUNK_HOME/bin/splunk set servername custom-servername -auth admin:Chang3d! 140 | $SPLUNK_HOME/bin/splunk restart 141 | 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 142 | timeout-minutes: 5 143 | - name: Run tests 144 | run: | 145 | poetry install 146 | SPLUNK_HOME=/opt/splunk SPLUNK_DB=$SPLUNK_HOME/var/lib/splunk poetry run pytest --junitxml=test-results/results.xml -v tests/integration 147 | - uses: actions/upload-artifact@v4 148 | with: 149 | name: test-splunk-${{ matrix.splunk.version }} 150 | path: test-results 151 | 152 | publish: 153 | needs: 154 | - fossa-scan 155 | - compliance-copyrights 156 | - pre-commit 157 | - semgrep 158 | - run-unit-tests 159 | - test-splunk 160 | runs-on: ubuntu-22.04 161 | steps: 162 | - uses: actions/checkout@v4 163 | with: 164 | submodules: false 165 | # Very important: semantic-release won't trigger a tagged 166 | # build if this is not set false 167 | persist-credentials: false 168 | - uses: actions/setup-python@v5 169 | with: 170 | python-version: "3.7" 171 | - run: curl -sSL https://install.python-poetry.org | python3 - --version 1.5.1 172 | - run: | 173 | poetry install 174 | poetry build 175 | - id: semantic 176 | uses: splunk/semantic-release-action@v1.3 177 | with: 178 | git_committer_name: ${{ secrets.SA_GH_USER_NAME }} 179 | git_committer_email: ${{ secrets.SA_GH_USER_EMAIL }} 180 | gpg_private_key: ${{ secrets.SA_GPG_PRIVATE_KEY }} 181 | passphrase: ${{ secrets.SA_GPG_PASSPHRASE }} 182 | extra_plugins: | 183 | semantic-release-replace-plugin 184 | env: 185 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN_ADMIN }} 186 | - if: ${{ steps.semantic.outputs.new_release_published == 'true' }} 187 | run: | 188 | poetry build 189 | poetry publish -n -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_TOKEN }} 190 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | pages: write 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.12 17 | - run: curl -sSL https://install.python-poetry.org | python3 - --version 1.5.1 18 | - run: | 19 | poetry install 20 | poetry run mkdocs gh-deploy --force 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE related files 2 | *.idea 3 | *.DS_Store* 4 | # ignore all virtual environments 5 | .venv* 6 | 7 | # Compiled files 8 | __pycache__ 9 | *.pyc 10 | *.pyo 11 | 12 | .coverage 13 | *.log 14 | events.pickle 15 | -------------------------------------------------------------------------------- /.licenserc.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | header: 17 | license: 18 | spdx-id: Apache-2.0 19 | copyright-owner: Splunk Inc. 20 | 21 | paths-ignore: 22 | - ".github/" 23 | - "README.md" 24 | - "LICENSE" 25 | - "*.lock" 26 | - "tests/**" 27 | - ".*" 28 | - "mkdocs.yml" 29 | - "docs/" 30 | - "renovate.json" 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v3.1.0 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py37-plus] 7 | - repo: https://github.com/psf/black 8 | rev: 22.10.0 9 | hooks: 10 | - id: black 11 | - repo: https://github.com/myint/docformatter 12 | rev: v1.5.0 13 | hooks: 14 | - id: docformatter 15 | args: [--in-place] 16 | - repo: https://github.com/PyCQA/flake8 17 | rev: 5.0.4 18 | hooks: 19 | - id: flake8 20 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | branches: 3 | [ 4 | "+([0-9])?(.{+([0-9]),x}).x", 5 | "main", 6 | { name: "develop", prerelease: "beta", channel: "beta" }, 7 | ], 8 | plugins: 9 | [ 10 | "@semantic-release/commit-analyzer", 11 | [ 12 | "semantic-release-replace-plugin", 13 | { 14 | "replacements": [ 15 | { 16 | "files": ["solnlib/__init__.py"], 17 | "from": "__version__ ?=.*", 18 | "to": "__version__ = \"${nextRelease.version}\"", 19 | "results": [ 20 | { 21 | "file": "solnlib/__init__.py", 22 | "hasChanged": true, 23 | "numMatches": 1, 24 | "numReplacements": 1 25 | } 26 | ], 27 | "countMatches": true 28 | }, 29 | { 30 | "files": ["pyproject.toml"], 31 | "from": "version = \".*\"", 32 | "to": "version = \"${nextRelease.version}\"", 33 | "results": [ 34 | { 35 | "file": "pyproject.toml", 36 | "hasChanged": true, 37 | "numMatches": 1, 38 | "numReplacements": 1 39 | } 40 | ], 41 | "countMatches": true 42 | } 43 | ] 44 | } 45 | ], 46 | "@semantic-release/release-notes-generator", 47 | [ 48 | "@semantic-release/exec", 49 | { 50 | "verifyReleaseCmd": "echo \"version=${nextRelease.version}\" >> $GITHUB_OUTPUT", 51 | "successCmd": "echo \"new_release_published=${'true'}\" >> $GITHUB_OUTPUT" 52 | }, 53 | ], 54 | [ 55 | "@semantic-release/git", 56 | { 57 | "assets": ["NOTICE", "pyproject.toml", "solnlib/__init__.py"], 58 | "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}", 59 | }, 60 | ], 61 | ["@semantic-release/github", { "assets": ["NOTICE", "pyproject.toml"] }], 62 | ], 63 | } 64 | -------------------------------------------------------------------------------- /.semgrepignore: -------------------------------------------------------------------------------- 1 | ## Default semgrep ignore 2 | # Ignore git items 3 | .gitignore 4 | .git/ 5 | :include .gitignore 6 | 7 | tests/ 8 | .github/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | Splunk Solutions SDK is an open source packaged solution for getting data into Splunk using modular inputs. 4 | This SDK is used by Splunk Add-on builder, and Splunk UCC based add-ons and is intended for use by partner 5 | developers. This SDK/Library extends the Splunk SDK for python 6 | 7 | Documentation is available [here](https://splunk.github.io/addonfactory-solutions-library-python/). 8 | 9 | ## Communication channels 10 | 11 | If you are a Splunker use: https://splunk.slack.com/archives/C03T8QCHBTJ 12 | 13 | If you are a part of the community use: https://splunk-usergroups.slack.com/archives/C03SG3ZL4S1 14 | 15 | ## Support 16 | 17 | Splunk Solutions SDK is an open source product developed by Splunkers. This SDK is not "Supported Software" by Splunk, Inc. issues and defects can be reported 18 | via the public issue tracker. 19 | 20 | ## License 21 | 22 | * Configuration and documentation licensed subject to [APACHE-2.0](LICENSE) 23 | -------------------------------------------------------------------------------- /docs/acl.md: -------------------------------------------------------------------------------- 1 | # acl.py 2 | 3 | ::: solnlib.acl -------------------------------------------------------------------------------- /docs/bulletin_rest_client.md: -------------------------------------------------------------------------------- 1 | # bulletin_rest_client.py 2 | 3 | ::: solnlib.bulletin_rest_client -------------------------------------------------------------------------------- /docs/conf_manager.md: -------------------------------------------------------------------------------- 1 | # conf_manager.py 2 | 3 | ::: solnlib.conf_manager -------------------------------------------------------------------------------- /docs/credentials.md: -------------------------------------------------------------------------------- 1 | # credentials.py 2 | 3 | ::: solnlib.credentials -------------------------------------------------------------------------------- /docs/file_monitor.md: -------------------------------------------------------------------------------- 1 | # file_monitor.py 2 | 3 | ::: solnlib.file_monitor -------------------------------------------------------------------------------- /docs/hec_config.md: -------------------------------------------------------------------------------- 1 | # hec_config.py 2 | 3 | ::: solnlib.hec_config -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Splunk Solutions SDK is an open source packaged solution for getting data into Splunk using modular inputs. 4 | This SDK is used by Splunk Add-on builder, and Splunk UCC based add-ons and is intended for use by partner 5 | developers. This SDK/Library extends the Splunk SDK for Python. 6 | 7 | > Note: this project uses `poetry` 1.5.1. 8 | -------------------------------------------------------------------------------- /docs/log.md: -------------------------------------------------------------------------------- 1 | # log.py 2 | 3 | ::: solnlib.log -------------------------------------------------------------------------------- /docs/modular_input/checkpointer.md: -------------------------------------------------------------------------------- 1 | # checkpointer.py 2 | 3 | ::: solnlib.modular_input.checkpointer -------------------------------------------------------------------------------- /docs/modular_input/event.md: -------------------------------------------------------------------------------- 1 | # event.py 2 | 3 | ::: solnlib.modular_input.event -------------------------------------------------------------------------------- /docs/modular_input/event_writer.md: -------------------------------------------------------------------------------- 1 | # event_writer.py 2 | 3 | ::: solnlib.modular_input.event_writer -------------------------------------------------------------------------------- /docs/modular_input/modular_input.md: -------------------------------------------------------------------------------- 1 | # modular_input.py 2 | 3 | ::: solnlib.modular_input.modular_input -------------------------------------------------------------------------------- /docs/net_utils.md: -------------------------------------------------------------------------------- 1 | # net_utils.py 2 | 3 | ::: solnlib.net_utils -------------------------------------------------------------------------------- /docs/orphan_process_monitor.md: -------------------------------------------------------------------------------- 1 | # orphan_process_monitor.py 2 | 3 | ::: solnlib.orphan_process_monitor -------------------------------------------------------------------------------- /docs/pattern.md: -------------------------------------------------------------------------------- 1 | # pattern.py 2 | 3 | ::: solnlib.pattern -------------------------------------------------------------------------------- /docs/release_6_0_0.md: -------------------------------------------------------------------------------- 1 | # Removed requests and urllib3 from solnlib 2 | The `requests` and `urllib3` libraries has been removed from solnlib, so solnlib now depends on the `requests` and `urllib3` libraries from the running environment. 3 | By default, Splunk delivers the above libraries and their version depends on the Splunk version. More information [here](https://docs.splunk.com/Documentation/Splunk/9.2.3/ReleaseNotes/Credits). 4 | 5 | **IMPORTANT**: `urllib3` is available in Splunk `v8.1.0` and later 6 | 7 | Please note that if `requests` or `urllib3` are installed in `/lib` e.g. as a dependency of another library, that version will be taken first. 8 | If `requests` or `urllib3` is missing in the add-on's `lib` directory, the version provided by Splunk will be used. 9 | 10 | ## Custom Version of requests and urllib3 11 | In case the Splunk's `requests` or `urllib3` version is not sufficient for you, 12 | you can deliver version you need by simply adding it to the `requirements.txt` or `pyproject.toml` file in your add-on. 13 | 14 | ## Use solnlib outside the Splunk 15 | **Solnlib** no longer provides `requests` and `urllib3` so if you want to use **solnlib** outside the Splunk, please note that you will need to 16 | provide these libraries yourself in the environment where **solnlib** is used. 17 | -------------------------------------------------------------------------------- /docs/server_info.md: -------------------------------------------------------------------------------- 1 | # server_info.py 2 | 3 | ::: solnlib.server_info -------------------------------------------------------------------------------- /docs/splunk_rest_client.md: -------------------------------------------------------------------------------- 1 | # splunk_rest_client.py 2 | 3 | ::: solnlib.splunk_rest_client -------------------------------------------------------------------------------- /docs/splunkenv.md: -------------------------------------------------------------------------------- 1 | # splunkenv.py 2 | 3 | ::: solnlib.splunkenv -------------------------------------------------------------------------------- /docs/theme_overrides/partials/header.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | {% set class = "md-header" %} 6 | {% if "navigation.tabs.sticky" in features %} 7 | {% set class = class ~ " md-header--shadow md-header--lifted" %} 8 | {% elif "navigation.tabs" not in features %} 9 | {% set class = class ~ " md-header--shadow" %} 10 | {% endif %} 11 | 12 | 13 |
14 | 97 | 98 | 99 | {% if "navigation.tabs.sticky" in features %} 100 | {% if "navigation.tabs" in features %} 101 | {% include "partials/tabs.html" %} 102 | {% endif %} 103 | {% endif %} 104 |
105 | -------------------------------------------------------------------------------- /docs/time_parser.md: -------------------------------------------------------------------------------- 1 | # time_parser.py 2 | 3 | ::: solnlib.time_parser -------------------------------------------------------------------------------- /docs/timer_queue.md: -------------------------------------------------------------------------------- 1 | # timer_queue.py 2 | 3 | ::: solnlib.timer_queue -------------------------------------------------------------------------------- /docs/user_access.md: -------------------------------------------------------------------------------- 1 | # user_access.py 2 | 3 | ::: solnlib.user_access -------------------------------------------------------------------------------- /docs/utils.md: -------------------------------------------------------------------------------- 1 | # utils.py 2 | 3 | ::: solnlib.utils -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Splunk Solutions SDK 2 | 3 | theme: 4 | name: "material" 5 | custom_dir: docs/theme_overrides 6 | palette: 7 | primary: "black" 8 | accent: "orange" 9 | features: 10 | - content.code.copy 11 | - navigation.indexes 12 | 13 | markdown_extensions: 14 | - toc: 15 | permalink: True 16 | - smarty 17 | - fenced_code 18 | - sane_lists 19 | - codehilite 20 | 21 | plugins: 22 | - mkdocstrings: 23 | handlers: 24 | python: 25 | options: 26 | show_if_no_docstring: true 27 | - autorefs 28 | - search 29 | - print-site # should be at the end 30 | 31 | nav: 32 | - Home: index.md 33 | - Release 6.0.0: release_6_0_0.md 34 | - References: 35 | - modular_input: 36 | - "checkpointer.py": modular_input/checkpointer.md 37 | - "event.py": modular_input/event.md 38 | - "event_writer.py": modular_input/event_writer.md 39 | - "modular_input.py": modular_input/modular_input.md 40 | - "acl.py": acl.md 41 | - "credentials.py": credentials.md 42 | - "conf_manager.py": conf_manager.md 43 | - "file_monitor.py": file_monitor.md 44 | - "hec_config.py": hec_config.md 45 | - "log.py": log.md 46 | - "net_utils.py": net_utils.md 47 | - "orphan_process_monitor.py": orphan_process_monitor.md 48 | - "pattern.py": pattern.md 49 | - "server_info.py": server_info.md 50 | - "splunk_rest_client.py": splunk_rest_client.md 51 | - "bulletin_rest_client.py": bulletin_rest_client.md 52 | - "splunkenv.py": splunkenv.md 53 | - "time_parser.py": time_parser.md 54 | - "timer_queue.py": timer_queue.md 55 | - "user_access.py": user_access.md 56 | - "utils.py": utils.md 57 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | [tool.poetry] 18 | name = "solnlib" 19 | version = "6.2.1" 20 | description = "The Splunk Software Development Kit for Splunk Solutions" 21 | authors = ["Splunk "] 22 | license = "Apache-2.0" 23 | repository = "https://github.com/splunk/addonfactory-solutions-library-python" 24 | keywords = ["splunk", "ucc"] 25 | classifiers = [ 26 | "Programming Language :: Python", 27 | "Development Status :: 5 - Production/Stable", 28 | "Intended Audience :: Developers", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | "Topic :: Software Development :: Code Generators", 31 | "License :: OSI Approved :: Apache Software License", 32 | "Programming Language :: Python :: 3.7", 33 | "Programming Language :: Python :: 3.8", 34 | "Programming Language :: Python :: 3.9", 35 | "Programming Language :: Python :: 3.10", 36 | "Programming Language :: Python :: 3.11", 37 | "Programming Language :: Python :: 3.12", 38 | "Programming Language :: Python :: 3.13", 39 | ] 40 | 41 | [tool.poetry.dependencies] 42 | python = ">=3.7,<3.14" 43 | sortedcontainers = ">=2" 44 | defusedxml = ">=0.7" 45 | splunk-sdk = ">=2.0.2" 46 | 47 | [tool.poetry.group.dev.dependencies] 48 | pytest = ">=7" 49 | mkdocs = ">=1" 50 | mkdocs-material = ">=9" 51 | mkdocstrings = {version=">=0", extras=["python"]} 52 | mkdocs-print-site-plugin = "^2.3.6" 53 | 54 | [build-system] 55 | requires = ["poetry>=1.0.0"] 56 | build-backend = "poetry.masonry.api" 57 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "group:all", 5 | ":semanticCommitTypeAll(chore)", 6 | ":disableDependencyDashboard" 7 | ], 8 | "lockFileMaintenance": { 9 | "enabled": true, 10 | "extends": [ 11 | "schedule:weekends" 12 | ] 13 | }, 14 | "schedule": [ 15 | "every 2 weeks on Sunday" 16 | ], 17 | "packageRules": [ 18 | { 19 | "matchPackageNames": ["urllib3"], 20 | "allowedVersions": "<2.0.0" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /solnlib/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """The Splunk Software Development Kit for Solutions.""" 18 | 19 | from . import ( 20 | acl, 21 | bulletin_rest_client, 22 | conf_manager, 23 | credentials, 24 | file_monitor, 25 | hec_config, 26 | log, 27 | net_utils, 28 | orphan_process_monitor, 29 | pattern, 30 | server_info, 31 | splunk_rest_client, 32 | splunkenv, 33 | time_parser, 34 | timer_queue, 35 | user_access, 36 | utils, 37 | ) 38 | 39 | __all__ = [ 40 | "acl", 41 | "bulletin_rest_client", 42 | "conf_manager", 43 | "credentials", 44 | "file_monitor", 45 | "hec_config", 46 | "log", 47 | "net_utils", 48 | "orphan_process_monitor", 49 | "pattern", 50 | "server_info", 51 | "splunk_rest_client", 52 | "splunkenv", 53 | "time_parser", 54 | "timer_queue", 55 | "user_access", 56 | "utils", 57 | ] 58 | 59 | __version__ = "6.2.1" 60 | -------------------------------------------------------------------------------- /solnlib/_utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | """This module provide utils that are private to solnlib.""" 17 | 18 | import re 19 | from typing import Any, Dict, Optional, Union 20 | 21 | from splunklib import binding, client 22 | 23 | from solnlib import splunk_rest_client 24 | from solnlib.utils import retry 25 | 26 | 27 | @retry(exceptions=[binding.HTTPError]) 28 | def get_collection_data( 29 | collection_name: str, 30 | session_key: str, 31 | app: str, 32 | owner: Optional[str] = None, 33 | scheme: Optional[str] = None, 34 | host: Optional[str] = None, 35 | port: Optional[Union[str, int]] = None, 36 | fields: Optional[Dict] = None, 37 | **context: Any, 38 | ) -> client.KVStoreCollectionData: 39 | """Get collection data, if there is no such collection - creates one. 40 | 41 | Arguments: 42 | collection_name: Collection name of KV Store checkpointer. 43 | session_key: Splunk access token. 44 | app: App name of namespace. 45 | owner: Owner of namespace, default is `nobody`. 46 | scheme: The access scheme, default is None. 47 | host: The host name, default is None. 48 | port: The port number, default is None. 49 | fields: Fields used to initialize the collection if it's missing. 50 | context: Other configurations for Splunk rest client. 51 | 52 | Raises: 53 | binding.HTTPError: HTTP error different from 404, for example 503 when 54 | KV Store is initializing and not ready to serve requests. 55 | KeyError: KV Store did not get collection_name. 56 | 57 | Returns: 58 | KV Store collections data instance. 59 | """ 60 | kvstore = splunk_rest_client.SplunkRestClient( 61 | session_key, app, owner=owner, scheme=scheme, host=host, port=port, **context 62 | ).kvstore 63 | 64 | collection_name = re.sub(r"[^\w]+", "_", collection_name) 65 | try: 66 | kvstore.get(name=collection_name) 67 | except binding.HTTPError as e: 68 | if e.status != 404: 69 | raise 70 | 71 | fields = fields if fields is not None else {} 72 | kvstore.create(collection_name, fields=fields) 73 | 74 | collections = kvstore.list(search=collection_name) 75 | for collection in collections: 76 | if collection.name == collection_name: 77 | return collection.data 78 | else: 79 | raise KeyError(f"Get collection data: {collection_name} failed.") 80 | -------------------------------------------------------------------------------- /solnlib/acl.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """This module contains interfaces that support CRUD operations on ACL.""" 18 | 19 | import json 20 | from typing import List 21 | 22 | from splunklib import binding 23 | 24 | from . import splunk_rest_client as rest_client 25 | from .utils import retry 26 | 27 | __all__ = ["ACLException", "ACLManager"] 28 | 29 | 30 | class ACLException(Exception): 31 | """Exception raised by ACLManager.""" 32 | 33 | pass 34 | 35 | 36 | class ACLManager: 37 | """ACL manager. 38 | 39 | Examples: 40 | >>> import solnlib.acl as sacl 41 | >>> saclm = sacl.ACLManager(session_key, 'Splunk_TA_test') 42 | >>> saclm.get('data/transforms/extractions') 43 | >>> saclm.update('data/transforms/extractions/_acl', 44 | perms_read=['*'], perms_write=['*']) 45 | """ 46 | 47 | def __init__( 48 | self, 49 | session_key: str, 50 | app: str, 51 | owner: str = "nobody", 52 | scheme: str = None, 53 | host: str = None, 54 | port: int = None, 55 | **context: dict 56 | ): 57 | """Initializes ACLManager. 58 | 59 | Arguments: 60 | session_key: Splunk access token. 61 | app: App name of namespace. 62 | owner: (optional) Owner of namespace, default is `nobody`. 63 | scheme: (optional) The access scheme, default is None. 64 | host: (optional) The host name, default is None. 65 | port: (optional) The port number, default is None. 66 | context: Other configurations for Splunk rest client. 67 | """ 68 | self._rest_client = rest_client.SplunkRestClient( 69 | session_key, 70 | app, 71 | owner=owner, 72 | scheme=scheme, 73 | host=host, 74 | port=port, 75 | **context 76 | ) 77 | 78 | @retry(exceptions=[binding.HTTPError]) 79 | def get(self, path: str) -> dict: 80 | """Get ACL of /servicesNS/{`owner`}/{`app`}/{`path`}. 81 | 82 | Arguments: 83 | path: Path of ACL relative to /servicesNS/{`owner`}/{`app`} 84 | 85 | Returns: 86 | A dict contains ACL. 87 | 88 | Raises: 89 | ACLException: If `path` is invalid. 90 | 91 | Examples: 92 | >>> aclm = acl.ACLManager(session_key, 'Splunk_TA_test') 93 | >>> perms = aclm.get('data/transforms/extractions/_acl') 94 | """ 95 | 96 | try: 97 | content = self._rest_client.get(path, output_mode="json").body.read() 98 | except binding.HTTPError as e: 99 | if e.status != 404: 100 | raise 101 | 102 | raise ACLException("Invalid endpoint: %s.", path) 103 | 104 | return json.loads(content)["entry"][0]["acl"] 105 | 106 | @retry(exceptions=[binding.HTTPError]) 107 | def update( 108 | self, 109 | path: str, 110 | owner: str = None, 111 | perms_read: List = None, 112 | perms_write: List = None, 113 | ) -> dict: 114 | """Update ACL of /servicesNS/{`owner`}/{`app`}/{`path`}. 115 | 116 | If the ACL is per-entity (ends in /acl), owner can be reassigned. If 117 | the acl is endpoint-level (ends in _acl), owner will be ignored. The 118 | 'sharing' setting is always retrieved from the current. 119 | 120 | Arguments: 121 | path: Path of ACL relative to /servicesNS/{owner}/{app}. MUST 122 | end with /acl or /_acl indicating whether the permission is applied 123 | at the per-entity level or endpoint level respectively. 124 | owner: (optional) New owner of ACL, default is `nobody`. 125 | perms_read: (optional) List of roles (['*'] for all roles). If 126 | unspecified we will POST with current (if available) perms.read, 127 | default is None. 128 | perms_write: (optional) List of roles (['*'] for all roles). If 129 | unspecified we will POST with current (if available) perms.write, 130 | default is None. 131 | 132 | Returns: 133 | A dict contains ACL after update. 134 | 135 | Raises: 136 | ACLException: If `path` is invalid. 137 | 138 | Examples: 139 | >>> aclm = acl.ACLManager(session_key, 'Splunk_TA_test') 140 | >>> perms = aclm.update('data/transforms/extractions/_acl', 141 | perms_read=['admin'], perms_write=['admin']) 142 | """ 143 | 144 | if not path.endswith("/acl") and not path.endswith("/_acl"): 145 | raise ACLException( 146 | "Invalid endpoint: %s, must end with /acl or /_acl." % path 147 | ) 148 | 149 | curr_acl = self.get(path) 150 | 151 | postargs = {} 152 | if perms_read: 153 | postargs["perms.read"] = ",".join(perms_read) 154 | else: 155 | curr_read = curr_acl["perms"].get("read", []) 156 | if curr_read: 157 | postargs["perms.read"] = ",".join(curr_read) 158 | 159 | if perms_write: 160 | postargs["perms.write"] = ",".join(perms_write) 161 | else: 162 | curr_write = curr_acl["perms"].get("write", []) 163 | if curr_write: 164 | postargs["perms.write"] = ",".join(curr_write) 165 | 166 | if path.endswith("/acl"): 167 | # Allow ownership to be reset only at entity level. 168 | postargs["owner"] = owner or curr_acl["owner"] 169 | 170 | postargs["sharing"] = curr_acl["sharing"] 171 | 172 | try: 173 | content = self._rest_client.post( 174 | path, body=binding._encode(**postargs), output_mode="json" 175 | ).body.read() 176 | except binding.HTTPError as e: 177 | if e.status != 404: 178 | raise 179 | 180 | raise ACLException("Invalid endpoint: %s.", path) 181 | 182 | return json.loads(content)["entry"][0]["acl"] 183 | -------------------------------------------------------------------------------- /solnlib/bulletin_rest_client.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from solnlib import splunk_rest_client as rest_client 18 | from typing import Optional, List 19 | import json 20 | 21 | __all__ = ["BulletinRestClient"] 22 | 23 | 24 | class BulletinRestClient: 25 | """REST client for handling Bulletin messages.""" 26 | 27 | MESSAGES_ENDPOINT = "/services/messages" 28 | 29 | headers = [("Content-Type", "application/json")] 30 | 31 | class Severity: 32 | INFO = "info" 33 | WARNING = "warn" 34 | ERROR = "error" 35 | 36 | def __init__( 37 | self, 38 | message_name: str, 39 | session_key: str, 40 | app: str, 41 | **context: dict, 42 | ): 43 | """Initializes BulletinRestClient. 44 | When creating a new bulletin message, you must provide a name, which is a kind of ID. 45 | If you try to create another message with the same name (ID), the API will not add another message 46 | to the bulletin, but it will overwrite the existing one. Similar behaviour applies to deletion. 47 | To delete a message, you must indicate the name (ID) of the message. 48 | To provide better and easier control over bulletin messages, this client works in such a way 49 | that there is one instance responsible for handling one specific message. 50 | If you need to add another message to bulletin create another instance 51 | with a different 'message_name' 52 | e.g. 53 | msg_1 = BulletinRestClient("message_1", "") 54 | msg_2 = BulletinRestClient("message_2", "") 55 | 56 | Arguments: 57 | message_name: Name of the message in the Splunk's bulletin. 58 | session_key: Splunk access token. 59 | app: App name of namespace. 60 | context: Other configurations for Splunk rest client. 61 | """ 62 | 63 | self.message_name = message_name 64 | self.session_key = session_key 65 | self.app = app 66 | 67 | self._rest_client = rest_client.SplunkRestClient( 68 | self.session_key, app=self.app, **context 69 | ) 70 | 71 | def create_message( 72 | self, 73 | msg: str, 74 | severity: Severity = Severity.WARNING, 75 | capabilities: Optional[List[str]] = None, 76 | roles: Optional[List] = None, 77 | ): 78 | """Creates a message in the Splunk's bulletin. Calling this method 79 | multiple times for the same instance will overwrite existing message. 80 | 81 | Arguments: 82 | msg: The message which will be displayed in the Splunk's bulletin 83 | severity: Severity level of the message. It has to be one of: 'info', 'warn', 'error'. 84 | If wrong severity is given, ValueError will be raised. 85 | capabilities: One or more capabilities that users must have to view the message. 86 | Capability names are validated. 87 | This argument should be provided as a list of string/s e.g. capabilities=['one', 'two']. 88 | If a non-existent capability is used, HTTP 400 BAD REQUEST exception will be raised. 89 | If argument is not a List[str] ValueError will be raised. 90 | roles: One or more roles that users must have to view the message. Role names are validated. 91 | This argument should be provided as a list of string/s e.g. roles=['user', 'admin']. 92 | If a non-existent role is used, HTTP 400 BAD REQUEST exception will be raised. 93 | If argument is not a List[str] ValueError will be raised. 94 | """ 95 | body = { 96 | "name": self.message_name, 97 | "value": msg, 98 | "severity": severity, 99 | "capability": [], 100 | "role": [], 101 | } 102 | 103 | if severity not in ( 104 | self.Severity.INFO, 105 | self.Severity.WARNING, 106 | self.Severity.ERROR, 107 | ): 108 | raise ValueError( 109 | "Severity must be one of (" 110 | "'BulletinRestClient.Severity.INFO', " 111 | "'BulletinRestClient.Severity.WARNING', " 112 | "'BulletinRestClient.Severity.ERROR'" 113 | ")." 114 | ) 115 | 116 | if capabilities: 117 | body["capability"] = self._validate_and_get_body_value( 118 | capabilities, "Capabilities must be a list of strings." 119 | ) 120 | 121 | if roles: 122 | body["role"] = self._validate_and_get_body_value( 123 | roles, "Roles must be a list of strings." 124 | ) 125 | 126 | self._rest_client.post(self.MESSAGES_ENDPOINT, body=body, headers=self.headers) 127 | 128 | def get_message(self): 129 | """Get specific message created by this instance.""" 130 | endpoint = f"{self.MESSAGES_ENDPOINT}/{self.message_name}" 131 | response = self._rest_client.get(endpoint, output_mode="json").body.read() 132 | return json.loads(response) 133 | 134 | def get_all_messages(self): 135 | """Get all messages in the bulletin.""" 136 | response = self._rest_client.get( 137 | self.MESSAGES_ENDPOINT, output_mode="json" 138 | ).body.read() 139 | return json.loads(response) 140 | 141 | def delete_message(self): 142 | """Delete specific message created by this instance.""" 143 | endpoint = f"{self.MESSAGES_ENDPOINT}/{self.message_name}" 144 | self._rest_client.delete(endpoint) 145 | 146 | @staticmethod 147 | def _validate_and_get_body_value(arg, error_msg) -> List: 148 | if type(arg) is list and (all(isinstance(el, str) for el in arg)): 149 | return [el for el in arg] 150 | else: 151 | raise ValueError(error_msg) 152 | -------------------------------------------------------------------------------- /solnlib/concurrent/concurrent_executor.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """Concurrent executor provides concurrent executing function either in a 18 | thread pool or a process pool.""" 19 | 20 | import solnlib.concurrent.process_pool as pp 21 | import solnlib.concurrent.thread_pool as tp 22 | 23 | 24 | class ConcurrentExecutor: 25 | def __init__(self, config): 26 | """ 27 | :param config: dict like object, contains thread_min_size (int), 28 | thread_max_size (int), daemonize_thread (bool), 29 | process_size (int) 30 | """ 31 | 32 | self._io_executor = tp.ThreadPool( 33 | config.get("thread_min_size", 0), 34 | config.get("thread_max_size", 0), 35 | config.get("task_queue_size", 1024), 36 | config.get("daemonize_thread", True), 37 | ) 38 | self._compute_executor = None 39 | if config.get("process_size", 0): 40 | self._compute_executor = pp.ProcessPool(config.get("process_size", 0)) 41 | 42 | def start(self): 43 | self._io_executor.start() 44 | 45 | def tear_down(self): 46 | self._io_executor.tear_down() 47 | if self._compute_executor is not None: 48 | self._compute_executor.tear_down() 49 | 50 | def run_io_func_sync(self, func, args=(), kwargs=None): 51 | """ 52 | :param func: callable 53 | :param args: free params 54 | :param kwargs: named params 55 | :return whatever the func returns 56 | """ 57 | 58 | return self._io_executor.apply(func, args, kwargs) 59 | 60 | def run_io_func_async(self, func, args=(), kwargs=None, callback=None): 61 | """ 62 | :param func: callable 63 | :param args: free params 64 | :param kwargs: named params 65 | :calllback: when func is done and without exception, call the callback 66 | :return whatever the func returns 67 | """ 68 | 69 | return self._io_executor.apply_async(func, args, kwargs, callback) 70 | 71 | def enqueue_io_funcs(self, funcs, block=True): 72 | """run jobs in a fire and forget way, no result will be handled over to 73 | clients. 74 | 75 | :param funcs: tuple/list-like or generator like object, func shall be 76 | callable 77 | """ 78 | 79 | return self._io_executor.enqueue_funcs(funcs, block) 80 | 81 | def run_compute_func_sync(self, func, args=(), kwargs={}): 82 | """ 83 | :param func: callable 84 | :param args: free params 85 | :param kwargs: named params 86 | :return whatever the func returns 87 | """ 88 | 89 | assert self._compute_executor is not None 90 | return self._compute_executor.apply(func, args, kwargs) 91 | 92 | def run_compute_func_async(self, func, args=(), kwargs={}, callback=None): 93 | """ 94 | :param func: callable 95 | :param args: free params 96 | :param kwargs: named params 97 | :calllback: when func is done and without exception, call the callback 98 | :return whatever the func returns 99 | """ 100 | 101 | assert self._compute_executor is not None 102 | return self._compute_executor.apply_async(func, args, kwargs, callback) 103 | -------------------------------------------------------------------------------- /solnlib/concurrent/process_pool.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """A wrapper of multiprocessing.pool.""" 18 | 19 | import multiprocessing 20 | 21 | import logging 22 | 23 | 24 | class ProcessPool: 25 | """A simple wrapper of multiprocessing.pool.""" 26 | 27 | def __init__(self, size=0, maxtasksperchild=10000): 28 | if size <= 0: 29 | size = multiprocessing.cpu_count() 30 | self.size = size 31 | self._pool = multiprocessing.Pool( 32 | processes=size, maxtasksperchild=maxtasksperchild 33 | ) 34 | self._stopped = False 35 | 36 | def tear_down(self): 37 | """Tear down the pool.""" 38 | 39 | if self._stopped: 40 | logging.info("ProcessPool has already stopped.") 41 | return 42 | self._stopped = True 43 | 44 | self._pool.close() 45 | self._pool.join() 46 | logging.info("ProcessPool stopped.") 47 | 48 | def apply(self, func, args=(), kwargs={}): 49 | """ 50 | :param func: callable 51 | :param args: free params 52 | :param kwargs: named params 53 | :return whatever the func returns 54 | """ 55 | 56 | if self._stopped: 57 | logging.info("ProcessPool has already stopped.") 58 | return None 59 | 60 | return self._pool.apply(func, args, kwargs) 61 | 62 | def apply_async(self, func, args=(), kwargs={}, callback=None): 63 | """ 64 | :param func: callable 65 | :param args: free params 66 | :param kwargs: named params 67 | :callback: when func is done without exception, call this callack 68 | :return whatever the func returns 69 | """ 70 | 71 | if self._stopped: 72 | logging.info("ProcessPool has already stopped.") 73 | return None 74 | 75 | return self._pool.apply_async(func, args, kwargs, callback) 76 | -------------------------------------------------------------------------------- /solnlib/file_monitor.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """This module contains file monitoring class that can be used to check files 18 | change periodically and call callback function to handle properly when 19 | detecting files change.""" 20 | import logging 21 | import os.path as op 22 | import threading 23 | import time 24 | import traceback 25 | from typing import Any, Callable, List 26 | 27 | __all__ = ["FileChangesChecker", "FileMonitor"] 28 | 29 | 30 | class FileChangesChecker: 31 | """Files change checker.""" 32 | 33 | def __init__(self, callback: Callable[[List[str]], Any], files: List): 34 | """Initializes FileChangesChecker. 35 | 36 | Arguments: 37 | callback: Callback function for files change. 38 | files: Files to be monitored with full path. 39 | """ 40 | self._callback = callback 41 | self._files = files 42 | 43 | self.file_mtimes = {file_name: None for file_name in self._files} 44 | for k in self.file_mtimes: 45 | try: 46 | self.file_mtimes[k] = op.getmtime(k) 47 | except OSError: 48 | logging.debug(f"Getmtime for {k}, failed: {traceback.format_exc()}") 49 | 50 | def check_changes(self) -> bool: 51 | """Check files change. 52 | 53 | If some files are changed and callback function is not None, call 54 | callback function to handle files change. 55 | 56 | Returns: 57 | True if files changed else False 58 | """ 59 | logging.debug(f"Checking files={self._files}") 60 | file_mtimes = self.file_mtimes 61 | changed_files = [] 62 | for f, last_mtime in list(file_mtimes.items()): 63 | try: 64 | current_mtime = op.getmtime(f) 65 | if current_mtime != last_mtime: 66 | file_mtimes[f] = current_mtime 67 | changed_files.append(f) 68 | logging.info(f"Detect {f} has changed", f) 69 | except OSError: 70 | pass 71 | if changed_files: 72 | if self._callback: 73 | self._callback(changed_files) 74 | return True 75 | return False 76 | 77 | 78 | class FileMonitor: 79 | """Files change monitor. 80 | 81 | Monitor files change in a separated thread and call callback 82 | when there is files change. 83 | 84 | Examples: 85 | >>> import solnlib.file_monitor as fm 86 | >>> fm = fm.FileMonitor(fm_callback, files_list, 5) 87 | >>> fm.start() 88 | """ 89 | 90 | def __init__( 91 | self, callback: Callable[[List[str]], Any], files: List, interval: int = 1 92 | ): 93 | """Initializes FileMonitor. 94 | 95 | Arguments: 96 | callback: Callback for handling files change. 97 | files: Files to monitor. 98 | interval: Interval to check files change. 99 | """ 100 | self._checker = FileChangesChecker(callback, files) 101 | self._thr = threading.Thread(target=self._do_monitor) 102 | self._thr.daemon = True 103 | self._interval = interval 104 | self._started = False 105 | 106 | def start(self): 107 | """Start file monitor. 108 | 109 | Start a background thread to monitor files change. 110 | """ 111 | 112 | if self._started: 113 | return 114 | self._started = True 115 | 116 | self._thr.start() 117 | 118 | def stop(self): 119 | """Stop file monitor. 120 | 121 | Stop the background thread to monitor files change. 122 | """ 123 | 124 | self._started = False 125 | 126 | def _do_monitor(self): 127 | while self._started: 128 | self._checker.check_changes() 129 | 130 | for _ in range(self._interval): 131 | if not self._started: 132 | break 133 | time.sleep(1) 134 | -------------------------------------------------------------------------------- /solnlib/hec_config.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from splunklib import binding 18 | 19 | from . import splunk_rest_client as rest_client 20 | from .utils import retry 21 | 22 | __all__ = ["HECConfig"] 23 | 24 | 25 | class HECConfig: 26 | """HTTP Event Collector configuration.""" 27 | 28 | input_type = "http" 29 | 30 | def __init__( 31 | self, 32 | session_key: str, 33 | scheme: str = None, 34 | host: str = None, 35 | port: int = None, 36 | **context: dict 37 | ): 38 | """Initializes HECConfig. 39 | 40 | Arguments: 41 | session_key: Splunk access token. 42 | scheme: (optional) The access scheme, default is None. 43 | host: (optional) The host name, default is None. 44 | port: (optional) The port number, default is None. 45 | context: Other configurations for Splunk rest client. 46 | """ 47 | self._rest_client = rest_client.SplunkRestClient( 48 | session_key, 49 | "splunk_httpinput", 50 | scheme=scheme, 51 | host=host, 52 | port=port, 53 | **context 54 | ) 55 | 56 | @retry(exceptions=[binding.HTTPError]) 57 | def get_settings(self) -> dict: 58 | """Get http data input global settings. 59 | 60 | Returns: 61 | HTTP global settings, for example: 62 | 63 | { 64 | 'enableSSL': 1, 65 | 'disabled': 0, 66 | 'useDeploymentServer': 0, 67 | 'port': 8088 68 | } 69 | """ 70 | 71 | return self._do_get_input(self.input_type).content 72 | 73 | @retry(exceptions=[binding.HTTPError]) 74 | def update_settings(self, settings: dict): 75 | """Update http data input global settings. 76 | 77 | Arguments: 78 | settings: HTTP global settings. 79 | """ 80 | 81 | res = self._do_get_input(self.input_type) 82 | res.update(**settings) 83 | 84 | @retry(exceptions=[binding.HTTPError]) 85 | def create_input(self, name: str, stanza: dict) -> dict: 86 | """Create http data input. 87 | 88 | Arguments: 89 | name: HTTP data input name. 90 | stanza: Data input stanza content. 91 | 92 | Returns: 93 | Created input. 94 | 95 | Examples: 96 | >>> from solnlib.hec_config import HECConfig 97 | >>> hec = HECConfig(session_key) 98 | >>> hec.create_input('my_hec_data_input', 99 | {'index': 'main', 'sourcetype': 'hec'}) 100 | """ 101 | 102 | res = self._rest_client.inputs.create(name, self.input_type, **stanza) 103 | return res.content 104 | 105 | @retry(exceptions=[binding.HTTPError]) 106 | def update_input(self, name: str, stanza: dict): 107 | """Update http data input. 108 | 109 | It will create if the data input doesn't exist. 110 | 111 | Arguments: 112 | name: HTTP data input name. 113 | stanza: Data input stanza. 114 | 115 | Examples: 116 | >>> from solnlib import HEConfig 117 | >>> hec = HECConfig(session_key) 118 | >>> hec.update_input('my_hec_data_input', 119 | {'index': 'main', 'sourcetype': 'hec2'}) 120 | """ 121 | 122 | res = self._do_get_input(name) 123 | if res is None: 124 | return self.create_input(name, stanza) 125 | res.update(**stanza) 126 | 127 | @retry(exceptions=[binding.HTTPError]) 128 | def delete_input(self, name: str): 129 | """Delete http data input. 130 | 131 | Arguments: 132 | name: HTTP data input name. 133 | """ 134 | 135 | try: 136 | self._rest_client.inputs.delete(name, self.input_type) 137 | except KeyError: 138 | pass 139 | 140 | @retry(exceptions=[binding.HTTPError]) 141 | def get_input(self, name: str) -> dict: 142 | """Get http data input. 143 | 144 | Arguments: 145 | name: HTTP event collector data input name. 146 | 147 | Returns: 148 | HTTP event collector data input config dict. 149 | """ 150 | 151 | res = self._do_get_input(name) 152 | if res: 153 | return res.content 154 | else: 155 | return None 156 | 157 | def _do_get_input(self, name): 158 | try: 159 | return self._rest_client.inputs[(name, self.input_type)] 160 | except KeyError: 161 | return None 162 | 163 | @retry(exceptions=[binding.HTTPError]) 164 | def get_limits(self) -> dict: 165 | """Get HTTP input limits. 166 | 167 | Returns: 168 | HTTP input limits. 169 | """ 170 | 171 | return self._rest_client.confs["limits"]["http_input"].content 172 | 173 | @retry(exceptions=[binding.HTTPError]) 174 | def set_limits(self, limits: dict): 175 | """Set HTTP input limits. 176 | 177 | Arguments: 178 | limits: HTTP input limits. 179 | """ 180 | 181 | res = self._rest_client.confs["limits"]["http_input"] 182 | res.submit(limits) 183 | -------------------------------------------------------------------------------- /solnlib/modular_input/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """Splunk modular input.""" 18 | 19 | from splunklib.modularinput.argument import Argument 20 | 21 | from .checkpointer import CheckpointerException, FileCheckpointer, KVStoreCheckpointer 22 | from .event import EventException, HECEvent, XMLEvent 23 | from .event_writer import ClassicEventWriter, HECEventWriter 24 | from .modular_input import ModularInput, ModularInputException 25 | 26 | __all__ = [ 27 | "EventException", 28 | "XMLEvent", 29 | "HECEvent", 30 | "ClassicEventWriter", 31 | "HECEventWriter", 32 | "CheckpointerException", 33 | "KVStoreCheckpointer", 34 | "FileCheckpointer", 35 | "Argument", 36 | "ModularInputException", 37 | "ModularInput", 38 | ] 39 | -------------------------------------------------------------------------------- /solnlib/modular_input/modinput.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import subprocess 18 | import sys 19 | import traceback 20 | 21 | import solnlib.splunkenv as sp 22 | import logging 23 | 24 | 25 | def _parse_modinput_configs(root, outer_block, inner_block): 26 | """When user splunkd spawns modinput script to do config check or run. 27 | 28 | 29 | 30 | localhost.localdomain 31 | https://127.0.0.1:8089 32 | xxxyyyzzz 33 | ckpt_dir 34 | 35 | 36 | 60 37 | localhost.localdomain 38 | snow 39 | 10 40 | 41 | ... 42 | 43 | 44 | 45 | When user create an stanza through data input on WebUI 46 | 47 | 48 | 49 | localhost.localdomain 50 | https://127.0.0.1:8089 51 | xxxyyyzzz 52 | ckpt_dir 53 | 54 | 60 55 | 56 | localhost.localdomain 57 | snow 58 | 10 59 | 60 | 61 | """ 62 | 63 | confs = root.getElementsByTagName(outer_block) 64 | if not confs: 65 | logging.error("Invalid config, missing %s section", outer_block) 66 | raise Exception(f"Invalid config, missing {outer_block} section") 67 | 68 | configs = [] 69 | stanzas = confs[0].getElementsByTagName(inner_block) 70 | for stanza in stanzas: 71 | config = {} 72 | stanza_name = stanza.getAttribute("name") 73 | if not stanza_name: 74 | logging.error("Invalid config, missing name") 75 | raise Exception("Invalid config, missing name") 76 | 77 | config["name"] = stanza_name 78 | params = stanza.getElementsByTagName("param") 79 | for param in params: 80 | name = param.getAttribute("name") 81 | if ( 82 | name 83 | and param.firstChild 84 | and param.firstChild.nodeType == param.firstChild.TEXT_NODE 85 | ): 86 | config[name] = param.firstChild.data 87 | configs.append(config) 88 | return configs 89 | 90 | 91 | def parse_modinput_configs(config_str): 92 | """ 93 | @config_str: modinput XML configuration feed by splunkd 94 | @return: meta_config and stanza_config 95 | """ 96 | 97 | import defusedxml.minidom as xdm 98 | 99 | meta_configs = { 100 | "server_host": None, 101 | "server_uri": None, 102 | "session_key": None, 103 | "checkpoint_dir": None, 104 | } 105 | root = xdm.parseString(config_str) 106 | doc = root.documentElement 107 | for tag in meta_configs.keys(): 108 | nodes = doc.getElementsByTagName(tag) 109 | if not nodes: 110 | logging.error("Invalid config, missing %s section", tag) 111 | raise Exception("Invalid config, missing %s section", tag) 112 | 113 | if nodes[0].firstChild and nodes[0].firstChild.nodeType == nodes[0].TEXT_NODE: 114 | meta_configs[tag] = nodes[0].firstChild.data 115 | else: 116 | logging.error("Invalid config, expect text ndoe") 117 | raise Exception("Invalid config, expect text ndoe") 118 | 119 | if doc.nodeName == "input": 120 | configs = _parse_modinput_configs(doc, "configuration", "stanza") 121 | else: 122 | configs = _parse_modinput_configs(root, "items", "item") 123 | return meta_configs, configs 124 | 125 | 126 | def get_modinput_configs_from_cli(modinput, modinput_stanza=None): 127 | """ 128 | @modinput: modinput name 129 | @modinput_stanza: modinput stanza name, for multiple instance only 130 | """ 131 | 132 | assert modinput 133 | 134 | splunkbin = sp.get_splunk_bin() 135 | cli = [splunkbin, "cmd", "splunkd", "print-modinput-config", modinput] 136 | if modinput_stanza: 137 | cli.append(modinput_stanza) 138 | 139 | out, err = subprocess.Popen( 140 | cli, stdout=subprocess.PIPE, stderr=subprocess.PIPE 141 | ).communicate() 142 | if err: 143 | logging.error("Failed to get modinput configs with error: %s", err) 144 | return None, None 145 | else: 146 | return parse_modinput_configs(out) 147 | 148 | 149 | def get_modinput_config_str_from_stdin(): 150 | """Get modinput from stdin which is feed by splunkd.""" 151 | 152 | try: 153 | return sys.stdin.read(5000) 154 | except Exception: 155 | logging.error(traceback.format_exc()) 156 | raise 157 | 158 | 159 | def get_modinput_configs_from_stdin(): 160 | config_str = get_modinput_config_str_from_stdin() 161 | return parse_modinput_configs(config_str) 162 | -------------------------------------------------------------------------------- /solnlib/net_utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """Net utilities.""" 18 | import re 19 | import socket 20 | 21 | __all__ = ["resolve_hostname", "validate_scheme_host_port"] 22 | 23 | from typing import Optional, Union 24 | 25 | 26 | def resolve_hostname(addr: str) -> Optional[str]: 27 | """Try to resolve an IP to a host name and returns None on common failures. 28 | 29 | Arguments: 30 | addr: IP address to resolve. 31 | 32 | Returns: 33 | Host name if success else None. 34 | 35 | Raises: 36 | ValueError: If `addr` is not a valid address. 37 | """ 38 | 39 | if is_valid_ip(addr): 40 | try: 41 | name, _, _ = socket.gethostbyaddr(addr) 42 | return name 43 | except socket.gaierror: 44 | # [Errno 8] nodename nor servname provided, or not known 45 | pass 46 | except socket.herror: 47 | # [Errno 1] Unknown host 48 | pass 49 | except socket.timeout: 50 | # Timeout. 51 | pass 52 | 53 | return None 54 | else: 55 | raise ValueError("Invalid ip address.") 56 | 57 | 58 | def is_valid_ip(addr: str) -> bool: 59 | """Validate an IPV4 address. 60 | 61 | Arguments: 62 | addr: IP address to validate. 63 | 64 | Returns: 65 | True if is valid else False. 66 | """ 67 | 68 | ip_rx = re.compile( 69 | r""" 70 | ^((( 71 | [0-1]\d{2} # matches 000-199 72 | | 2[0-4]\d # matches 200-249 73 | | 25[0-5] # matches 250-255 74 | | \d{1,2} # matches 0-9, 00-99 75 | )\.){3}) # 3 of the preceding stanzas 76 | ([0-1]\d{2}|2[0-4]\d|25[0-5]|\d{1,2})$ # final octet 77 | """, 78 | re.VERBOSE, 79 | ) 80 | 81 | try: 82 | return ip_rx.match(addr.strip()) 83 | except AttributeError: 84 | # Value was not a string 85 | return False 86 | 87 | 88 | def is_valid_hostname(hostname: str) -> bool: 89 | """Validate a host name. 90 | 91 | Arguments: 92 | hostname: host name to validate. 93 | 94 | Returns: 95 | True if is valid else False. 96 | """ 97 | # Splunk IPv6 support. 98 | # https://docs.splunk.com/Documentation/Splunk/9.0.0/Admin/ConfigureSplunkforIPv6#Change_the_prioritization_of_IPv4_and_IPv6_communications 99 | if hostname == "[::1]": 100 | return True 101 | 102 | if len(hostname) > 255: 103 | return False 104 | if hostname[-1:] == ".": 105 | hostname = hostname[:-1] 106 | allowed = re.compile(r"(?!-)(::)?[A-Z\d-]{1,63}(? bool: 111 | """Validate a port. 112 | 113 | Arguments: 114 | port: port to validate. 115 | 116 | Returns: 117 | True if is valid else False. 118 | """ 119 | 120 | try: 121 | return 0 < int(port) <= 65535 122 | except ValueError: 123 | return False 124 | 125 | 126 | def is_valid_scheme(scheme: str) -> bool: 127 | """Validate a scheme. 128 | 129 | Arguments: 130 | scheme: scheme to validate. 131 | 132 | Returns: 133 | True if is valid else False. 134 | """ 135 | 136 | return scheme.lower() in ("http", "https") 137 | 138 | 139 | def validate_scheme_host_port(scheme: str, host: str, port: Union[str, int]): 140 | """Validates scheme, host and port. 141 | 142 | Arguments: 143 | scheme: scheme to validate. 144 | host: hostname to validate. 145 | port: port to validate. 146 | 147 | Raises: 148 | ValueError: if scheme, host or port are invalid. 149 | """ 150 | if scheme is not None and not is_valid_scheme(scheme): 151 | raise ValueError("Invalid scheme") 152 | if host is not None and not is_valid_hostname(host): 153 | raise ValueError("Invalid host") 154 | if port is not None and not is_valid_port(port): 155 | raise ValueError("Invalid port") 156 | -------------------------------------------------------------------------------- /solnlib/orphan_process_monitor.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """Orphan process monitor.""" 18 | 19 | import os 20 | import threading 21 | import time 22 | from typing import Callable 23 | 24 | __all__ = ["OrphanProcessChecker", "OrphanProcessMonitor"] 25 | 26 | 27 | class OrphanProcessChecker: 28 | """Orphan process checker. 29 | 30 | Only work for Linux platform. On Windows platform, is_orphan is 31 | always False and there is no need to do this monitoring on Windows. 32 | """ 33 | 34 | def __init__(self, callback: Callable = None): 35 | """Initializes OrphanProcessChecker. 36 | 37 | Arguments: 38 | callback: (optional) Callback for orphan process. 39 | """ 40 | if os.name == "nt": 41 | self._ppid = 0 42 | else: 43 | self._ppid = os.getppid() 44 | self._callback = callback 45 | 46 | def is_orphan(self) -> bool: 47 | """Check process is orphan. 48 | 49 | For windows platform just return False. 50 | 51 | Returns: 52 | True for orphan process else False. 53 | """ 54 | 55 | if os.name == "nt": 56 | return False 57 | return self._ppid != os.getppid() 58 | 59 | def check_orphan(self) -> bool: 60 | """Check if the process becomes orphan. 61 | 62 | If the process becomes orphan then call callback function 63 | to handle properly. 64 | 65 | Returns: 66 | True for orphan process else False. 67 | """ 68 | 69 | res = self.is_orphan() 70 | if res and self._callback: 71 | self._callback() 72 | return res 73 | 74 | 75 | class OrphanProcessMonitor: 76 | """Orphan process monitor. 77 | 78 | Check if process become orphan in background thread per interval and 79 | call callback if process become orphan. 80 | """ 81 | 82 | def __init__(self, callback: Callable, interval: int = 1): 83 | """Initializes OrphanProcessMonitor. 84 | 85 | Arguments: 86 | callback: Callback for orphan process monitor. 87 | interval: (optional) Interval to monitor. 88 | """ 89 | self._checker = OrphanProcessChecker(callback) 90 | self._thr = threading.Thread(target=self._do_monitor) 91 | self._thr.daemon = True 92 | self._started = False 93 | self._interval = interval 94 | 95 | def start(self): 96 | """Start orphan process monitor.""" 97 | 98 | if self._started: 99 | return 100 | self._started = True 101 | 102 | self._thr.start() 103 | 104 | def stop(self): 105 | """Stop orphan process monitor.""" 106 | 107 | joinable = self._started 108 | self._started = False 109 | if joinable: 110 | self._thr.join(timeout=1) 111 | 112 | def _do_monitor(self): 113 | while self._started: 114 | if self._checker.check_orphan(): 115 | break 116 | 117 | for _ in range(self._interval): 118 | if not self._started: 119 | break 120 | time.sleep(1) 121 | -------------------------------------------------------------------------------- /solnlib/pattern.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """This module provides some common used patterns.""" 18 | 19 | __all__ = ["Singleton"] 20 | 21 | 22 | class Singleton(type): 23 | """Singleton meta class. 24 | 25 | Examples: 26 | >>> class Test(object): 27 | >>> __metaclass__ = Singleton 28 | >>> 29 | >>> def __init__(self): 30 | >>> pass 31 | """ 32 | 33 | def __init__(cls, name, bases, attrs): 34 | super().__init__(name, bases, attrs) 35 | cls._instance = None 36 | 37 | def __call__(cls, *args, **kwargs): 38 | if cls._instance is None: 39 | cls._instance = super().__call__(*args, **kwargs) 40 | return cls._instance 41 | -------------------------------------------------------------------------------- /solnlib/rest.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import json 18 | import urllib.parse 19 | from traceback import format_exc 20 | from typing import Optional 21 | 22 | import requests 23 | 24 | import logging 25 | 26 | 27 | def splunkd_request( 28 | splunkd_uri, 29 | session_key, 30 | method="GET", 31 | headers=None, 32 | data=None, 33 | timeout=300, 34 | retry=1, 35 | verify=False, 36 | ) -> Optional[requests.Response]: 37 | 38 | headers = headers if headers is not None else {} 39 | headers["Authorization"] = f"Splunk {session_key}" 40 | content_type = headers.get("Content-Type") 41 | if not content_type: 42 | content_type = headers.get("content-type") 43 | 44 | if not content_type: 45 | content_type = "application/x-www-form-urlencoded" 46 | headers["Content-Type"] = content_type 47 | 48 | if data is not None: 49 | if content_type == "application/json": 50 | data = json.dumps(data) 51 | else: 52 | data = urllib.parse.urlencode(data) 53 | 54 | msg_temp = "Failed to send rest request=%s, errcode=%s, reason=%s" 55 | resp = None 56 | for _ in range(retry): 57 | try: 58 | resp = requests.request( 59 | method=method, 60 | url=splunkd_uri, 61 | data=data, 62 | headers=headers, 63 | timeout=timeout, 64 | verify=verify, 65 | ) 66 | except Exception: 67 | logging.error(msg_temp, splunkd_uri, "unknown", format_exc()) 68 | else: 69 | if resp.status_code not in (200, 201): 70 | if not (method == "GET" and resp.status_code == 404): 71 | logging.debug( 72 | msg_temp, splunkd_uri, resp.status_code, code_to_msg(resp) 73 | ) 74 | else: 75 | return resp 76 | else: 77 | return resp 78 | 79 | 80 | def code_to_msg(response: requests.Response): 81 | code_msg_tbl = { 82 | 400: f"Request error. reason={response.text}", 83 | 401: "Authentication failure, invalid access credentials.", 84 | 402: "In-use license disables this feature.", 85 | 403: "Insufficient permission.", 86 | 404: "Requested endpoint does not exist.", 87 | 409: f"Invalid operation for this endpoint. reason={response.text}", 88 | 500: f"Unspecified internal server error. reason={response.text}", 89 | 503: ( 90 | "Feature is disabled in the configuration file. " 91 | "reason={}".format(response.text) 92 | ), 93 | } 94 | 95 | return code_msg_tbl.get(response.status_code, response.text) 96 | -------------------------------------------------------------------------------- /solnlib/schedule/job.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import threading 18 | import time 19 | 20 | 21 | class Job: 22 | """Timer wraps the callback and timestamp related stuff.""" 23 | 24 | _ident = 0 25 | _lock = threading.Lock() 26 | 27 | def __init__(self, func, job_props, interval, when=None, job_id=None): 28 | """ 29 | @job_props: dict like object 30 | @func: execution function 31 | @interval: execution interval 32 | @when: seconds from epoch 33 | @job_id: a unique id for the job 34 | """ 35 | 36 | self._props = job_props 37 | self._func = func 38 | if when is None: 39 | self._when = time.time() 40 | else: 41 | self._when = when 42 | self._interval = interval 43 | 44 | if job_id is not None: 45 | self._id = job_id 46 | else: 47 | with Job._lock: 48 | self._id = Job._ident + 1 49 | Job._ident = Job._ident + 1 50 | self._stopped = False 51 | 52 | def ident(self): 53 | return self._id 54 | 55 | def get_interval(self): 56 | return self._interval 57 | 58 | def set_interval(self, interval): 59 | self._interval = interval 60 | 61 | def get_expiration(self): 62 | return self._when 63 | 64 | def set_initial_due_time(self, when): 65 | if self._when is None: 66 | self._when = when 67 | 68 | def update_expiration(self): 69 | self._when += self._interval 70 | 71 | def get(self, key, default): 72 | return self._props.get(key, default) 73 | 74 | def get_props(self): 75 | return self._props 76 | 77 | def set_props(self, props): 78 | self._props = props 79 | 80 | def __cmp__(self, other): 81 | if other is None: 82 | return 1 83 | 84 | self_k = (self.get_expiration(), self.ident()) 85 | other_k = (other.get_expiration(), other.ident()) 86 | 87 | if self_k == other_k: 88 | return 0 89 | elif self_k < other_k: 90 | return -1 91 | else: 92 | return 1 93 | 94 | def __eq__(self, other): 95 | return isinstance(other, Job) and (self.ident() == other.ident()) 96 | 97 | def __lt__(self, other): 98 | return self.__cmp__(other) == -1 99 | 100 | def __gt__(self, other): 101 | return self.__cmp__(other) == 1 102 | 103 | def __ne__(self, other): 104 | return not self.__eq__(other) 105 | 106 | def __le__(self, other): 107 | return self.__lt__(other) or self.__eq__(other) 108 | 109 | def __ge__(self, other): 110 | return self.__gt__(other) or self.__eq__(other) 111 | 112 | def __hash__(self): 113 | return self.ident() 114 | 115 | def __call__(self): 116 | self._func(self) 117 | 118 | def stop(self): 119 | self._stopped = True 120 | 121 | def stopped(self): 122 | return self._stopped 123 | -------------------------------------------------------------------------------- /solnlib/schedule/scheduler.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import queue 18 | import random 19 | import threading 20 | from time import time 21 | 22 | import logging 23 | 24 | 25 | class Scheduler: 26 | """A simple scheduler which schedules the periodic or once event.""" 27 | 28 | import sortedcontainers as sc 29 | 30 | max_delay_time = 60 31 | 32 | def __init__(self): 33 | self._jobs = Scheduler.sc.SortedSet() 34 | self._wakeup_q = queue.Queue() 35 | self._lock = threading.Lock() 36 | self._thr = threading.Thread(target=self._do_jobs) 37 | # FIXME: the `daemon` property HAS to be passed in init() call ONLY, 38 | # the below attribute setting is of incorrect spelling 39 | self._thr.deamon = True 40 | self._started = False 41 | 42 | def start(self): 43 | """Start the schduler which will start the internal thread for 44 | scheduling jobs. 45 | 46 | Please do tear_down when doing cleanup 47 | """ 48 | 49 | if self._started: 50 | logging.info("Scheduler already started.") 51 | return 52 | self._started = True 53 | 54 | self._thr.start() 55 | 56 | def tear_down(self): 57 | """Stop the schduler which will stop the internal thread for scheduling 58 | jobs.""" 59 | 60 | if not self._started: 61 | logging.info("Scheduler already tear down.") 62 | return 63 | 64 | self._wakeup_q.put(True) 65 | 66 | def _do_jobs(self): 67 | while 1: 68 | (sleep_time, jobs) = self.get_ready_jobs() 69 | self._do_execution(jobs) 70 | try: 71 | done = self._wakeup_q.get(timeout=sleep_time) 72 | except queue.Empty: 73 | pass 74 | else: 75 | if done: 76 | break 77 | self._started = False 78 | logging.info("Scheduler exited.") 79 | 80 | def get_ready_jobs(self): 81 | """ 82 | @return: a 2 element tuple. The first element is the next ready 83 | duration. The second element is ready jobs list 84 | """ 85 | 86 | now = time() 87 | ready_jobs = [] 88 | sleep_time = 1 89 | 90 | with self._lock: 91 | job_set = self._jobs 92 | total_jobs = len(job_set) 93 | for job in job_set: 94 | if job.get_expiration() <= now: 95 | ready_jobs.append(job) 96 | 97 | if ready_jobs: 98 | del job_set[: len(ready_jobs)] 99 | 100 | for job in ready_jobs: 101 | if job.get_interval() != 0 and not job.stopped(): 102 | # repeated job, calculate next due time and enqueue 103 | job.update_expiration() 104 | job_set.add(job) 105 | 106 | if job_set: 107 | sleep_time = job_set[0].get_expiration() - now 108 | if sleep_time < 0: 109 | logging.warn("Scheduler satuation, sleep_time=%s", sleep_time) 110 | sleep_time = 0.1 111 | 112 | if ready_jobs: 113 | logging.info( 114 | "Get %d ready jobs, next duration is %f, " 115 | "and there are %s jobs scheduling", 116 | len(ready_jobs), 117 | sleep_time, 118 | total_jobs, 119 | ) 120 | 121 | ready_jobs.sort(key=lambda job: job.get("priority", 0), reverse=True) 122 | return (sleep_time, ready_jobs) 123 | 124 | def add_jobs(self, jobs): 125 | with self._lock: 126 | now = time() 127 | job_set = self._jobs 128 | for job in jobs: 129 | delay_time = random.randrange(0, self.max_delay_time) 130 | job.set_initial_due_time(now + delay_time) 131 | job_set.add(job) 132 | self._wakeup() 133 | 134 | def update_jobs(self, jobs): 135 | with self._lock: 136 | job_set = self._jobs 137 | for njob in jobs: 138 | job_set.discard(njob) 139 | job_set.add(njob) 140 | self._wakeup() 141 | 142 | def remove_jobs(self, jobs): 143 | with self._lock: 144 | job_set = self._jobs 145 | for njob in jobs: 146 | njob.stop() 147 | job_set.discard(njob) 148 | self._wakeup() 149 | 150 | def number_of_jobs(self): 151 | with self._lock: 152 | return len(self._jobs) 153 | 154 | def disable_randomization(self): 155 | self.max_delay_time = 1 156 | 157 | def _wakeup(self): 158 | self._wakeup_q.put(None) 159 | 160 | def _do_execution(self, jobs): 161 | for job in jobs: 162 | job() 163 | -------------------------------------------------------------------------------- /solnlib/soln_exceptions.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | class ConfManagerException(Exception): 17 | """Exception raised by ConfManager class.""" 18 | 19 | pass 20 | 21 | 22 | class ConfStanzaNotExistException(Exception): 23 | """Exception raised by ConfFile class.""" 24 | 25 | pass 26 | 27 | 28 | class InvalidPortError(ValueError): 29 | """Exception raised when an invalid proxy port is provided.""" 30 | 31 | pass 32 | 33 | 34 | class InvalidHostnameError(ValueError): 35 | """Exception raised when an invalid proxy hostname is provided.""" 36 | 37 | pass 38 | -------------------------------------------------------------------------------- /solnlib/time_parser.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """This module provides interfaces to parse and convert timestamp.""" 18 | 19 | import datetime 20 | import json 21 | from typing import Any 22 | 23 | from splunklib import binding 24 | 25 | from . import splunk_rest_client as rest_client 26 | from .utils import retry 27 | 28 | __all__ = ["TimeParser"] 29 | 30 | 31 | class InvalidTimeFormatException(Exception): 32 | """Exception for invalid time format.""" 33 | 34 | pass 35 | 36 | 37 | class TimeParser: 38 | """Datetime parser. 39 | 40 | Use splunkd rest to parse datetime. 41 | 42 | Examples: 43 | >>> from solnlib import time_parser 44 | >>> tp = time_parser.TimeParser(session_key) 45 | >>> tp.to_seconds('2011-07-06T21:54:23.000-07:00') 46 | >>> tp.to_utc('2011-07-06T21:54:23.000-07:00') 47 | >>> tp.to_local('2011-07-06T21:54:23.000-07:00') 48 | """ 49 | 50 | URL = "/services/search/timeparser" 51 | 52 | def __init__( 53 | self, 54 | session_key: str, 55 | scheme: str = None, 56 | host: str = None, 57 | port: int = None, 58 | **context: Any, 59 | ): 60 | """Initializes TimeParser. 61 | 62 | Arguments: 63 | session_key: Splunk access token. 64 | scheme: (optional) The access scheme, default is None. 65 | host: (optional) The host name, default is None. 66 | port: (optional) The port number, default is None. 67 | context: Other configurations for Splunk rest client. 68 | 69 | Raises: 70 | ValueError: if scheme, host or port are invalid. 71 | """ 72 | self._rest_client = rest_client.SplunkRestClient( 73 | session_key, "-", scheme=scheme, host=host, port=port, **context 74 | ) 75 | 76 | @retry(exceptions=[binding.HTTPError]) 77 | def to_seconds(self, time_str: str) -> float: 78 | """Parse `time_str` and convert to seconds since epoch. 79 | 80 | Arguments: 81 | time_str: ISO8601 format timestamp, example: 2011-07-06T21:54:23.000-07:00. 82 | 83 | Raises: 84 | binding.HTTPError: rest client returns an exception (everything 85 | else than 400 code). 86 | InvalidTimeFormatException: when time format is invalid (rest 87 | client returns 400 code). 88 | 89 | Returns: 90 | Seconds since epoch. 91 | """ 92 | 93 | try: 94 | response = self._rest_client.get( 95 | self.URL, output_mode="json", time=time_str, output_time_format="%s" 96 | ).body.read() 97 | except binding.HTTPError as e: 98 | if e.status != 400: 99 | raise 100 | 101 | raise InvalidTimeFormatException(f"Invalid time format: {time_str}.") 102 | 103 | seconds = json.loads(response)[time_str] 104 | return float(seconds) 105 | 106 | def to_utc(self, time_str: str) -> datetime.datetime: 107 | """Parse `time_str` and convert to UTC timestamp. 108 | 109 | Arguments: 110 | time_str: ISO8601 format timestamp, example: 2011-07-06T21:54:23.000-07:00. 111 | 112 | Raises: 113 | binding.HTTPError: rest client returns an exception (everything 114 | else than 400 code). 115 | InvalidTimeFormatException: when time format is invalid (rest 116 | client returns 400 code). 117 | 118 | Returns: 119 | UTC timestamp. 120 | """ 121 | 122 | return datetime.datetime.utcfromtimestamp(self.to_seconds(time_str)) 123 | 124 | @retry(exceptions=[binding.HTTPError]) 125 | def to_local(self, time_str: str) -> str: 126 | """Parse `time_str` and convert to local timestamp. 127 | 128 | Arguments: 129 | time_str: ISO8601 format timestamp, example: 2011-07-06T21:54:23.000-07:00. 130 | 131 | Raises: 132 | binding.HTTPError: rest client returns an exception (everything 133 | else than 400 code). 134 | InvalidTimeFormatException: when time format is invalid (rest 135 | client returns 400 code). 136 | 137 | Returns: 138 | Local timestamp in ISO8601 format. 139 | """ 140 | 141 | try: 142 | response = self._rest_client.get( 143 | self.URL, output_mode="json", time=time_str 144 | ).body.read() 145 | except binding.HTTPError as e: 146 | if e.status != 400: 147 | raise 148 | 149 | raise InvalidTimeFormatException(f"Invalid time format: {time_str}.") 150 | 151 | return json.loads(response)[time_str] 152 | -------------------------------------------------------------------------------- /solnlib/utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """Common utilities.""" 18 | 19 | import datetime 20 | import logging 21 | import os 22 | import signal 23 | import time 24 | import traceback 25 | from functools import wraps 26 | from typing import Any, Callable, List, Tuple, Union 27 | from urllib import parse as urlparse 28 | 29 | __all__ = [ 30 | "handle_teardown_signals", 31 | "datetime_to_seconds", 32 | "is_true", 33 | "is_false", 34 | "retry", 35 | "extract_http_scheme_host_port", 36 | "remove_http_proxy_env_vars", 37 | ] 38 | 39 | 40 | def remove_http_proxy_env_vars() -> None: 41 | """Removes HTTP(s) proxies from environment variables. 42 | 43 | Removes the following environment variables: 44 | * http_proxy 45 | * https_proxy 46 | * HTTP_PROXY 47 | * HTTPS_PROXY 48 | 49 | This function can be used in Splunk modular inputs code before starting the 50 | ingestion to ensure that no proxy is going to be used when doing requests. 51 | In case of proxy is needed, it can be defined in the modular inputs code. 52 | """ 53 | env_vars_to_remove = ( 54 | "http_proxy", 55 | "https_proxy", 56 | "HTTP_PROXY", 57 | "HTTPS_PROXY", 58 | ) 59 | for env_var in env_vars_to_remove: 60 | if env_var in os.environ: 61 | del os.environ[env_var] 62 | 63 | 64 | def handle_teardown_signals(callback: Callable): 65 | """Register handler for SIGTERM/SIGINT/SIGBREAK signal. 66 | 67 | Catch SIGTERM/SIGINT/SIGBREAK signals, and invoke callback 68 | Note: this should be called in main thread since Python only catches 69 | signals in main thread. 70 | 71 | Arguments: 72 | callback: Callback for tear down signals. 73 | """ 74 | 75 | signal.signal(signal.SIGTERM, callback) 76 | signal.signal(signal.SIGINT, callback) 77 | 78 | if os.name == "nt": 79 | signal.signal(signal.SIGBREAK, callback) 80 | 81 | 82 | def datetime_to_seconds(dt: datetime.datetime) -> float: 83 | """Convert UTC datetime to seconds since epoch. 84 | 85 | Arguments: 86 | dt: Date time. 87 | 88 | Returns: 89 | Seconds since epoch. 90 | """ 91 | 92 | epoch_time = datetime.datetime.utcfromtimestamp(0) 93 | return (dt - epoch_time).total_seconds() 94 | 95 | 96 | def is_true(val: Union[str, int]) -> bool: 97 | """Decide if `val` is true. 98 | 99 | Arguments: 100 | val: Value to check. 101 | 102 | Returns: 103 | True or False. 104 | """ 105 | 106 | value = str(val).strip().upper() 107 | if value in ("1", "TRUE", "T", "Y", "YES"): 108 | return True 109 | return False 110 | 111 | 112 | def is_false(val: Union[str, int]) -> bool: 113 | """Decide if `val` is false. 114 | 115 | Arguments: 116 | val: Value to check. 117 | 118 | Returns: 119 | True or False. 120 | """ 121 | 122 | value = str(val).strip().upper() 123 | if value in ("0", "FALSE", "F", "N", "NO", "NONE", ""): 124 | return True 125 | return False 126 | 127 | 128 | def retry( 129 | retries: int = 3, 130 | reraise: bool = True, 131 | default_return: Any = None, 132 | exceptions: List = None, 133 | ): 134 | """A decorator to run function with max `retries` times if there is 135 | exception. 136 | 137 | Arguments: 138 | retries: (optional) Max retries times, default is 3. 139 | reraise: Whether exception should be reraised, default is True. 140 | default_return: (optional) Default return value for function 141 | run after max retries and reraise is False. 142 | exceptions: (optional) List of exceptions that should retry. 143 | """ 144 | 145 | max_tries = max(retries, 0) + 1 146 | 147 | def do_retry(func): 148 | @wraps(func) 149 | def wrapper(*args, **kwargs): 150 | last_ex = None 151 | for i in range(max_tries): 152 | try: 153 | return func(*args, **kwargs) 154 | except Exception as e: 155 | logging.warning( 156 | "Run function: %s failed: %s.", 157 | func.__name__, 158 | traceback.format_exc(), 159 | ) 160 | if not exceptions or any( 161 | isinstance(e, exception) for exception in exceptions 162 | ): 163 | last_ex = e 164 | if i < max_tries - 1: 165 | time.sleep(2**i) 166 | else: 167 | raise 168 | 169 | if reraise: 170 | raise last_ex 171 | else: 172 | return default_return 173 | 174 | return wrapper 175 | 176 | return do_retry 177 | 178 | 179 | def extract_http_scheme_host_port(http_url: str) -> Tuple: 180 | """Extract scheme, host and port from a HTTP URL. 181 | 182 | Arguments: 183 | http_url: HTTP URL to extract. 184 | 185 | Returns: 186 | A tuple of scheme, host and port 187 | 188 | Raises: 189 | ValueError: If `http_url` is not in http(s)://hostname:port format. 190 | """ 191 | 192 | http_info = urlparse.urlparse(http_url) 193 | if not http_info.scheme or not http_info.hostname or not http_info.port: 194 | raise ValueError(http_url + " is not in http(s)://hostname:port format") 195 | return http_info.scheme, http_info.hostname, http_info.port 196 | 197 | 198 | def get_appname_from_path(absolute_path): 199 | """Gets name of the app from its path. 200 | 201 | Arguments: 202 | absolute_path: path of app 203 | 204 | Returns: 205 | """ 206 | absolute_path = os.path.normpath(absolute_path) 207 | parts = absolute_path.split(os.path.sep) 208 | parts.reverse() 209 | for key in ("apps", "peer-apps", "manager-apps"): 210 | try: 211 | idx = parts.index(key) 212 | except ValueError: 213 | continue 214 | else: 215 | try: 216 | if parts[idx + 1] == "etc": 217 | return parts[idx - 1] 218 | except IndexError: 219 | pass 220 | continue 221 | return "-" 222 | -------------------------------------------------------------------------------- /tests/integration/_search.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | import context 17 | import os.path as op 18 | import sys 19 | import time 20 | 21 | from splunklib import client 22 | from splunklib import results as splunklib_results 23 | 24 | sys.path.insert(0, op.dirname(op.dirname(op.abspath(__file__)))) 25 | 26 | 27 | def search(session_key, query): 28 | service = client.connect(host=context.host, token=session_key) 29 | job = service.jobs.create(query) 30 | while True: 31 | while not job.is_ready(): 32 | pass 33 | stats = { 34 | "isDone": job["isDone"], 35 | "doneProgress": job["doneProgress"], 36 | "scanCount": job["scanCount"], 37 | "eventCount": job["eventCount"], 38 | "resultCount": job["resultCount"], 39 | } 40 | if stats["isDone"] == "1": 41 | break 42 | time.sleep(0.5) 43 | json_results_reader = splunklib_results.JSONResultsReader( 44 | job.results(output_mode="json") 45 | ) 46 | results = [] 47 | for result in json_results_reader: 48 | if isinstance(result, dict): 49 | results.append(result) 50 | return results 51 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import pytest 5 | 6 | import context 7 | 8 | 9 | @pytest.fixture(autouse=True, scope="session") 10 | def setup_env(): 11 | # path manipulation get the 'splunk' library for the imports while running on GH Actions 12 | if "SPLUNK_HOME" in os.environ: 13 | sys.path.append( 14 | os.path.sep.join( 15 | [os.environ["SPLUNK_HOME"], "lib", "python3.7", "site-packages"] 16 | ) 17 | ) 18 | # TODO: 'python3.7' needs to be updated as and when Splunk has new folder for Python. 19 | 20 | 21 | @pytest.fixture(scope="session") 22 | def session_key(): 23 | return context.get_session_key() 24 | -------------------------------------------------------------------------------- /tests/integration/context.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | from urllib import parse 17 | 18 | import requests 19 | 20 | owner = "nobody" 21 | app = "solnlib_demo" 22 | 23 | username = "admin" 24 | password = "Chang3d!" 25 | scheme = "https" 26 | host = "localhost" 27 | port = 8089 28 | 29 | 30 | def get_session_key(): 31 | response = requests.post( 32 | f"{scheme}://{host}:{port}/services/auth/login?output_mode=json", 33 | data=parse.urlencode({"username": username, "password": password}), 34 | verify=False, 35 | ) 36 | content = response.json() 37 | return content["sessionKey"] 38 | -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/README.txt: -------------------------------------------------------------------------------- 1 | solnlib_demo 2 | -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/README/inputs.conf.spec: -------------------------------------------------------------------------------- 1 | [solnlib_demo_collector://default] 2 | state = 3 | timeout = 4 | do_check = 5 | -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/bin/solnlib_demo_collector.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | 4 | from solnlib import log 5 | from solnlib.modular_input import ModularInput, Argument 6 | 7 | # Set log context 8 | log.Logs.set_context(namespace="solnlib_demo", root_logger_log_file="collector") 9 | # Get named logger 10 | logger = log.Logs().get_logger("collector") 11 | 12 | 13 | # Define orphan process handler 14 | def orphan_handler(md): 15 | logger.info("Solnlib demo collector becomes orphan process, will teardown...") 16 | md.should_exit = True 17 | 18 | 19 | # Define teardown signal handler 20 | def teardown_handler(md): 21 | logger.info("Solnlib demo collector got teardown signal, will teardown...") 22 | md.should_exit = True 23 | 24 | 25 | # Custom modular input 26 | class SolnlibDemoCollector(ModularInput): 27 | # Override app name 28 | app = "solnlib_demo" 29 | # Override modular input name 30 | name = "solnlib_demo_collector" 31 | # Override modular input scheme title 32 | title = "Solnlib Demo Collector" 33 | # Override modular input scheme description 34 | description = "Solnlib demo collector" 35 | # Override modular input use_external_validation 36 | use_external_validation = True 37 | # Override modular input use_single_instance 38 | use_single_instance = False 39 | # Override use_kvstore_checkpointer 40 | use_kvstore_checkpointer = True 41 | # Override kvstore_checkpoint_collection_name 42 | kvstore_checkpointer_collection_name = "SolnDemoCollectorCheckpoint" 43 | # Override use_hec_event_writer 44 | use_hec_event_writer = True 45 | # Override hec_token_name 46 | hec_input_name = "SolnlibDemoCollectorHECToken" 47 | 48 | # Override extra_arguments function 49 | def extra_arguments(self): 50 | return [ 51 | { 52 | "name": "state", 53 | "description": "Solnlib demo collector state", 54 | "data_type": Argument.data_type_string, 55 | "required_on_create": True, 56 | }, 57 | { 58 | "name": "timeout", 59 | "description": "Solnlib demo collector collect data timeout", 60 | "data_type": Argument.data_type_number, 61 | "required_on_create": True, 62 | }, 63 | { 64 | "name": "do_check", 65 | "description": "Solnlib demo collector check collected data flag", 66 | "data_type": Argument.data_type_boolean, 67 | "required_on_create": True, 68 | }, 69 | ] 70 | 71 | # Override do_run function 72 | def do_run(self, inputs): 73 | logger.info("Solnlib demo modular input start...") 74 | # for CVE-2023-32712 integration test 75 | msg = "ASCII Table in one string: " 76 | for i in range(9): 77 | msg += chr(i) 78 | for i in range(11, 13): 79 | msg += chr(i) 80 | for i in range(14, 128): 81 | msg += chr(i) 82 | logger.info(msg) 83 | # Register orphan process handler 84 | self.register_orphan_handler(orphan_handler, self) 85 | # Register teardown signal handler 86 | self.register_teardown_handler(teardown_handler, self) 87 | 88 | # Get event writer to write events 89 | event_writer = self.event_writer 90 | # Get checkpoint to manage checkpoint 91 | checkpointer = self.checkpointer 92 | 93 | # Main loop 94 | while not self.should_exit: 95 | # Get checkpoint 96 | state = checkpointer.get("solnlib_demo_collector_state") 97 | if state: 98 | logger.info("Get checkpoint: event1=%s.", state[0]) 99 | logger.info("Get checkpoint: event2:%s.", state[1]) 100 | 101 | # Create events 102 | tm = time.time() 103 | data1 = {"id": uuid.uuid4().hex, "time": tm} 104 | # Use class method of event writer to create a new event 105 | event1 = event_writer.create_event( 106 | data1, time=tm, source="solnlib_demo", sourcetype="solnlib_demo" 107 | ) 108 | data2 = uuid.uuid4().hex 109 | event2 = event_writer.create_event( 110 | data2, time=tm, source="solnlib_demo", sourcetype="solnlib_demo" 111 | ) 112 | # Use event writer to write events 113 | event_writer.write_events([event1, event2]) 114 | # Prepare checkpoint state 115 | state = [str(event1), str(event2)] 116 | # Save checkpoint 117 | checkpointer.update("solnlib_demo_collector_state", state) 118 | time.sleep(5) 119 | 120 | logger.info("Solnlib demo modular input stop...") 121 | 122 | 123 | if __name__ == "__main__": 124 | # Create custom modular input 125 | md = SolnlibDemoCollector() 126 | # Run modular input 127 | md.execute() 128 | -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/default/app.conf: -------------------------------------------------------------------------------- 1 | ###################################################### 2 | # 3 | # solnlib_demo 4 | # 5 | # Copyright 2016 Splunk, Inc. 6 | # 7 | ###################################################### 8 | [install] 9 | is_configured = 1 10 | state = enabled 11 | build = 1.0.0 12 | 13 | [ui] 14 | is_visible = false 15 | label = solnlib demo 16 | docs_section_override=AddOns:released 17 | 18 | [launcher] 19 | author = Splunk 20 | version = 1.0.0 21 | description = solnlib demo 22 | 23 | [package] 24 | id = solnlib_demo 25 | -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/default/inputs.conf: -------------------------------------------------------------------------------- 1 | [solnlib_demo_collector://test] 2 | state = success 3 | timeout = 20 4 | do_check = 1 5 | interval = 30 6 | -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/default/splunk_ta_addon_settings.conf: -------------------------------------------------------------------------------- 1 | [logging] 2 | log_level = DEBUG 3 | 4 | [proxy] 5 | proxy_enabled = 6 | proxy_type = http 7 | proxy_url = remote_host 8 | proxy_port = 3128 9 | proxy_username = 10 | proxy_password = 11 | proxy_rdns = 12 | -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/default/splunk_ta_addon_settings_invalid.conf: -------------------------------------------------------------------------------- 1 | 2 | [invalid_proxy] 3 | proxy_enabled = 4 | proxy_type = http3 5 | proxy_url = remote:host:invalid 6 | proxy_port = 99999 7 | proxy_username = 8 | proxy_password = 9 | proxy_rdns = -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/local/collections.conf: -------------------------------------------------------------------------------- 1 | [mycollection] 2 | 3 | field.foo = number 4 | field.bar = string 5 | accelerated_fields.myacceleration = {"foo": 1, "bar": -1} -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/local/eventtypes.conf: -------------------------------------------------------------------------------- 1 | [test1] 2 | search = xxx="1111" -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/metadata/default.meta: -------------------------------------------------------------------------------- 1 | 2 | [] 3 | access = read : [ * ], write : [ admin] 4 | export = system 5 | -------------------------------------------------------------------------------- /tests/integration/data/solnlib_demo/metadata/local.meta: -------------------------------------------------------------------------------- 1 | 2 | [collections/mycollection] 3 | access = read : [ * ] 4 | owner = admin 5 | modtime = 1622554901.741956000 6 | 7 | [eventtypes/test1] 8 | access = read : [ * ] 9 | export = system 10 | owner = admin 11 | modtime = 1622554901.741956000 -------------------------------------------------------------------------------- /tests/integration/test__kvstore.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import context 18 | import json 19 | import os.path as op 20 | import sys 21 | import time 22 | import uuid 23 | 24 | import pytest 25 | 26 | from splunklib import binding, client 27 | from splunklib.binding import HTTPError 28 | 29 | sys.path.insert(0, op.dirname(op.dirname(op.abspath(__file__)))) 30 | 31 | 32 | def test_kvstore(): 33 | session_key = context.get_session_key() 34 | kvstore = client.Service( 35 | scheme=context.scheme, 36 | host=context.host, 37 | port=context.port, 38 | token=session_key, 39 | app=context.app, 40 | owner=context.owner, 41 | autologin=True, 42 | ).kvstore 43 | fields = {"id": "string", "name": "string", "user": "string"} 44 | 45 | last_ex = None 46 | for i in range(3): 47 | try: 48 | kvstore.create("sessions", fields=fields) 49 | break 50 | except binding.HTTPError as e: 51 | last_ex = e 52 | time.sleep(2 ** (i + 1)) 53 | else: 54 | if last_ex: 55 | raise last_ex 56 | 57 | collections = kvstore.list() 58 | collection_data = None 59 | for collection in collections: 60 | if collection.name == "sessions": 61 | collection_data = collection.data 62 | break 63 | assert collection_data 64 | 65 | record = {"id": uuid.uuid4().hex, "name": "session1", "user": "admin"} 66 | _key = collection_data.insert(json.dumps(record))["_key"] 67 | resp_record = collection_data.query_by_id(_key) 68 | resp_record = { 69 | key: resp_record[key] for key in resp_record if not key.startswith("_") 70 | } 71 | assert sorted(resp_record.values()) == sorted(record.values()) 72 | 73 | record = {"id": uuid.uuid4().hex, "name": "session4", "user": "test"} 74 | collection_data.update(_key, json.dumps(record)) 75 | resp_record = collection_data.query_by_id(_key) 76 | resp_record = { 77 | key: resp_record[key] for key in resp_record if not key.startswith("_") 78 | } 79 | assert sorted(resp_record.values()) == sorted(record.values()) 80 | 81 | collection_data.delete_by_id(_key) 82 | with pytest.raises(HTTPError): 83 | collection_data.query_by_id(_key) 84 | -------------------------------------------------------------------------------- /tests/integration/test_acl.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import context 18 | import os.path as op 19 | import sys 20 | from solnlib import acl 21 | 22 | sys.path.insert(0, op.dirname(op.dirname(op.abspath(__file__)))) 23 | 24 | 25 | def test_acl_manager(): 26 | session_key = context.get_session_key() 27 | 28 | aclm = acl.ACLManager( 29 | session_key, 30 | context.app, 31 | owner=context.owner, 32 | scheme=context.scheme, 33 | host=context.host, 34 | port=context.port, 35 | ) 36 | origin_perms = aclm.get("storage/collections/config/sessions/acl") 37 | 38 | perms = aclm.update( 39 | "storage/collections/config/sessions/acl", 40 | perms_read=["admin"], 41 | perms_write=["admin"], 42 | ) 43 | 44 | origin_perms["perms"]["read"] = ["admin"] 45 | origin_perms["perms"]["write"] = ["admin"] 46 | assert origin_perms == perms 47 | -------------------------------------------------------------------------------- /tests/integration/test_alerts_rest_client.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | import uuid 17 | from collections import namedtuple 18 | 19 | from splunklib.binding import HTTPError 20 | 21 | import context 22 | import pytest 23 | from solnlib.alerts_rest_client import ( 24 | AlertsRestClient, 25 | AlertType, 26 | AlertComparator, 27 | AlertSeverity, 28 | ) 29 | 30 | 31 | @pytest.fixture 32 | def client(session_key) -> AlertsRestClient: 33 | return AlertsRestClient( 34 | session_key, 35 | "search", 36 | owner=context.owner, 37 | scheme=context.scheme, 38 | host=context.host, 39 | port=context.port, 40 | ) 41 | 42 | 43 | @pytest.fixture 44 | def example_name(): 45 | return f"solnlib_test_alert_{uuid.uuid4().hex}" 46 | 47 | 48 | AlertDetails = namedtuple( 49 | "AlertDetails", ["name", "search", "description", "cron_schedule"] 50 | ) 51 | 52 | 53 | @pytest.fixture 54 | def example_alert(client, example_name): 55 | details = AlertDetails( 56 | example_name, 57 | f"index=main some_search_{uuid.uuid4().hex}", 58 | "Test alert", 59 | "* * * * *", 60 | ) 61 | client.create_search_alert( 62 | details.name, 63 | details.search, 64 | description=details.description, 65 | cron_schedule=details.cron_schedule, 66 | ) 67 | yield details 68 | client.delete_search_alert(details.name) 69 | 70 | 71 | def test_delete_nonexistent_alert(client, example_name): 72 | with pytest.raises(HTTPError) as err: 73 | client.delete_search_alert(example_name) 74 | 75 | assert err.value.status == 404 76 | assert f"Could not find object id={example_name}" in err.value.body.decode() 77 | 78 | 79 | def test_get_nonexistent_alert(client, example_name): 80 | with pytest.raises(HTTPError) as err: 81 | client.get_search_alert(example_name) 82 | 83 | assert err.value.status == 404 84 | assert f"Could not find object id={example_name}" in err.value.body.decode() 85 | 86 | 87 | def test_create_duplicate_alert_error(client, example_alert): 88 | name = example_alert.name 89 | search = f"index=main some_search_{uuid.uuid4().hex}" 90 | 91 | with pytest.raises(HTTPError) as err: 92 | client.create_search_alert( 93 | name, 94 | search, 95 | ) 96 | 97 | assert err.value.status == 409 98 | assert ( 99 | f"Unable to create saved search with name '{name}'. A saved search with that name already exists." 100 | in err.value.body.decode() 101 | ) 102 | 103 | 104 | def test_update_alert(client, example_alert): 105 | name = example_alert.name 106 | description = "Updated test alert" 107 | cron_schedule = "*/5 * * * *" 108 | 109 | client.update_search_alert( 110 | name, 111 | description=description, 112 | cron_schedule=cron_schedule, 113 | ) 114 | 115 | alert = client.get_search_alert(name)["entry"][0] 116 | assert alert["name"] == name 117 | assert alert["content"]["description"] == description 118 | assert alert["content"]["cron_schedule"] == cron_schedule 119 | 120 | # Assert that the search and other details have not changed 121 | assert alert["content"]["search"] == example_alert.search 122 | assert alert["content"]["alert_type"] == AlertType.NUMBER_OF_EVENTS.value 123 | 124 | other_search = f"index=main other_search_{uuid.uuid4().hex}" 125 | client.update_search_alert( 126 | name, 127 | search=other_search, 128 | description="Updated test alert", 129 | alert_type=AlertType.NUMBER_OF_HOSTS, 130 | alert_comparator=AlertComparator.LESS_THAN, 131 | alert_threshold=10, 132 | time_window=("-2h", "now"), 133 | alert_severity=AlertSeverity.SEVERE, 134 | cron_schedule="*/10 * * * *", 135 | expires="3d", 136 | disabled=False, 137 | ) 138 | 139 | alert = client.get_search_alert(name)["entry"][0] 140 | assert alert["name"] == name 141 | assert alert["content"]["search"] == other_search 142 | assert alert["content"]["description"] == description 143 | assert alert["content"]["alert_type"] == AlertType.NUMBER_OF_HOSTS.value 144 | assert alert["content"]["alert_comparator"] == AlertComparator.LESS_THAN.value 145 | assert alert["content"]["alert_threshold"] == "10" 146 | assert alert["content"]["dispatch.earliest_time"] == "-2h" 147 | assert alert["content"]["dispatch.latest_time"] == "now" 148 | assert alert["content"]["alert.severity"] == AlertSeverity.SEVERE.value 149 | assert alert["content"]["cron_schedule"] == "*/10 * * * *" 150 | assert alert["content"]["alert.expires"] == "3d" 151 | assert not alert["content"]["disabled"] 152 | 153 | 154 | def test_create_get_list_and_delete_alerts(client, example_name): 155 | def get_alert_names_set(): 156 | response = client.get_all_search_alerts() 157 | return {alert["name"] for alert in response["entry"]} 158 | 159 | initial_alerts = get_alert_names_set() 160 | 161 | search = f"index=main some_search_{uuid.uuid4().hex}" 162 | 163 | # Alert has not been created yet so getting it should raise an error 164 | def assert_alert_not_found(): 165 | with pytest.raises(HTTPError) as err: 166 | client.get_search_alert(example_name) 167 | 168 | assert err.value.status == 404 169 | assert f"Could not find object id={example_name}" in err.value.body.decode() 170 | 171 | assert_alert_not_found() 172 | 173 | # Create alert 174 | client.create_search_alert( 175 | example_name, 176 | search, 177 | ) 178 | 179 | # Get alert 180 | alert = client.get_search_alert(example_name)["entry"][0] 181 | assert alert["name"] == example_name 182 | assert alert["content"]["search"] == search 183 | 184 | # Check default permissions 185 | assert alert["acl"]["sharing"] == "app" 186 | 187 | # Get all alerts 188 | alerts = get_alert_names_set() 189 | assert alerts - initial_alerts == {example_name} 190 | 191 | # Delete alert 192 | client.delete_search_alert(example_name) 193 | 194 | # Alert has been deleted so getting it should raise an error 195 | assert_alert_not_found() 196 | 197 | # Try to delete the same alert again 198 | with pytest.raises(HTTPError): 199 | client.delete_search_alert(example_name) 200 | -------------------------------------------------------------------------------- /tests/integration/test_bulletin_rest_client.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import context 18 | from splunklib import binding 19 | import pytest 20 | from solnlib import bulletin_rest_client as brc 21 | 22 | 23 | def _build_bulletin_manager(msg_name, session_key: str) -> brc.BulletinRestClient: 24 | return brc.BulletinRestClient( 25 | msg_name, 26 | session_key, 27 | "-", 28 | owner=context.owner, 29 | scheme=context.scheme, 30 | host=context.host, 31 | port=context.port, 32 | ) 33 | 34 | 35 | def test_create_message(): 36 | session_key = context.get_session_key() 37 | bulletin_client = _build_bulletin_manager("msg_name", session_key) 38 | 39 | with pytest.raises(binding.HTTPError) as e: 40 | bulletin_client.create_message( 41 | "new message to bulletin", 42 | capabilities=["apps_restore", "unknown_cap"], 43 | roles=["admin"], 44 | ) 45 | assert str(e.value.status) == "400" 46 | 47 | with pytest.raises(binding.HTTPError) as e: 48 | bulletin_client.create_message( 49 | "new message to bulletin", roles=["unknown_role"] 50 | ) 51 | assert str(e.value.status) == "400" 52 | 53 | 54 | def test_bulletin_rest_api(): 55 | session_key = context.get_session_key() 56 | bulletin_client_1 = _build_bulletin_manager("msg_name_1", session_key) 57 | bulletin_client_2 = _build_bulletin_manager("msg_name_2", session_key) 58 | 59 | # clear bulletin before tests 60 | _clear_bulletin() 61 | 62 | bulletin_client_1.create_message( 63 | "new message to bulletin", 64 | capabilities=["apps_restore", "edit_roles"], 65 | roles=["admin"], 66 | ) 67 | 68 | get_msg_1 = bulletin_client_1.get_message() 69 | assert get_msg_1["entry"][0]["content"]["message"] == "new message to bulletin" 70 | assert get_msg_1["entry"][0]["content"]["severity"] == "warn" 71 | 72 | bulletin_client_1.create_message( 73 | "new message to bulletin", bulletin_client_1.Severity.INFO 74 | ) 75 | get_msg_1 = bulletin_client_1.get_message() 76 | assert get_msg_1["entry"][0]["content"]["severity"] == "info" 77 | 78 | bulletin_client_1.create_message( 79 | "new message to bulletin", bulletin_client_1.Severity.ERROR 80 | ) 81 | get_msg_1 = bulletin_client_1.get_message() 82 | assert get_msg_1["entry"][0]["content"]["severity"] == "error" 83 | 84 | get_all_msg = bulletin_client_1.get_all_messages() 85 | assert len(get_all_msg["entry"]) == 1 86 | 87 | bulletin_client_2.create_message("new message to bulletin 2") 88 | 89 | get_msg_2 = bulletin_client_2.get_message() 90 | assert get_msg_2["entry"][0]["content"]["message"] == "new message to bulletin 2" 91 | 92 | get_all_msg = bulletin_client_1.get_all_messages() 93 | assert len(get_all_msg["entry"]) == 2 94 | 95 | bulletin_client_1.delete_message() 96 | 97 | with pytest.raises(binding.HTTPError) as e: 98 | bulletin_client_1.get_message() 99 | assert str(e.value.status) == "404" 100 | 101 | with pytest.raises(binding.HTTPError) as e: 102 | bulletin_client_1.delete_message() 103 | assert str(e.value.status) == "404" 104 | 105 | get_all_msg = bulletin_client_1.get_all_messages() 106 | assert len(get_all_msg["entry"]) == 1 107 | 108 | bulletin_client_2.delete_message() 109 | 110 | get_all_msg = bulletin_client_1.get_all_messages() 111 | assert len(get_all_msg["entry"]) == 0 112 | 113 | 114 | def _clear_bulletin(): 115 | session_key = context.get_session_key() 116 | bulletin_client = _build_bulletin_manager("", session_key) 117 | 118 | msg_to_del = [el["name"] for el in bulletin_client.get_all_messages()["entry"]] 119 | for msg in msg_to_del: 120 | endpoint = f"{bulletin_client.MESSAGES_ENDPOINT}/{msg}" 121 | bulletin_client._rest_client.delete(endpoint) 122 | -------------------------------------------------------------------------------- /tests/integration/test_conf_manager.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import context 18 | import pytest 19 | from solnlib import conf_manager, soln_exceptions 20 | from unittest import mock 21 | 22 | 23 | VALID_PROXY_DICT = { 24 | "proxy_enabled": None, 25 | "proxy_type": "http", 26 | "proxy_url": "remote_host", 27 | "proxy_port": "3128", 28 | "proxy_username": None, 29 | "proxy_password": None, 30 | "proxy_rdns": None, 31 | } 32 | 33 | 34 | def _build_conf_manager(session_key: str) -> conf_manager.ConfManager: 35 | return conf_manager.ConfManager( 36 | session_key, 37 | context.app, 38 | owner=context.owner, 39 | scheme=context.scheme, 40 | host=context.host, 41 | port=context.port, 42 | ) 43 | 44 | 45 | def test_conf_manager_when_no_conf_then_throw_exception(): 46 | session_key = context.get_session_key() 47 | cfm = _build_conf_manager(session_key) 48 | 49 | with pytest.raises(soln_exceptions.ConfManagerException): 50 | cfm.get_conf("non_existent_configuration_file") 51 | 52 | 53 | def test_conf_manager_when_conf_file_exists_but_no_specific_stanza_then_throw_exception(): 54 | session_key = context.get_session_key() 55 | cfm = _build_conf_manager(session_key) 56 | 57 | splunk_ta_addon_settings_conf_file = cfm.get_conf("splunk_ta_addon_settings") 58 | 59 | with pytest.raises(soln_exceptions.ConfStanzaNotExistException): 60 | splunk_ta_addon_settings_conf_file.get( 61 | "non_existent_stanza_under_existing_conf_file" 62 | ) 63 | 64 | 65 | @pytest.mark.parametrize( 66 | "stanza_name,expected_result", 67 | [ 68 | ("logging", True), 69 | ("proxy", True), 70 | ("non_existent_stanza_under_existing_conf_file", False), 71 | ], 72 | ) 73 | def test_conf_manager_stanza_exist(stanza_name, expected_result): 74 | session_key = context.get_session_key() 75 | cfm = _build_conf_manager(session_key) 76 | 77 | splunk_ta_addon_settings_conf_file = cfm.get_conf("splunk_ta_addon_settings") 78 | 79 | assert ( 80 | splunk_ta_addon_settings_conf_file.stanza_exist(stanza_name) == expected_result 81 | ) 82 | 83 | 84 | def test_conf_manager_when_conf_file_exists(): 85 | session_key = context.get_session_key() 86 | cfm = _build_conf_manager(session_key) 87 | 88 | splunk_ta_addon_settings_conf_file = cfm.get_conf("splunk_ta_addon_settings") 89 | 90 | expected_result = { 91 | "disabled": "0", 92 | "eai:access": { 93 | "app": "solnlib_demo", 94 | "can_change_perms": "1", 95 | "can_list": "1", 96 | "can_share_app": "1", 97 | "can_share_global": "1", 98 | "can_share_user": "0", 99 | "can_write": "1", 100 | "modifiable": "1", 101 | "owner": "nobody", 102 | "perms": {"read": ["*"], "write": ["admin"]}, 103 | "removable": "0", 104 | "sharing": "global", 105 | }, 106 | "eai:appName": "solnlib_demo", 107 | "eai:userName": "nobody", 108 | "log_level": "DEBUG", 109 | } 110 | assert splunk_ta_addon_settings_conf_file.get("logging") == expected_result 111 | 112 | 113 | def test_conf_manager_delete_non_existent_stanza_then_throw_exception(): 114 | session_key = context.get_session_key() 115 | cfm = _build_conf_manager(session_key) 116 | 117 | splunk_ta_addon_settings_conf_file = cfm.get_conf("splunk_ta_addon_settings") 118 | 119 | with pytest.raises(soln_exceptions.ConfStanzaNotExistException): 120 | splunk_ta_addon_settings_conf_file.delete( 121 | "non_existent_stanza_under_existing_conf_file" 122 | ) 123 | 124 | 125 | def test_conf_manager_create_conf(): 126 | session_key = context.get_session_key() 127 | cfm = _build_conf_manager(session_key) 128 | 129 | conf_file = cfm.create_conf("conf_file_that_did_not_exist_before") 130 | conf_file.update("stanza", {"key": "value"}) 131 | 132 | assert conf_file.get("stanza")["key"] == "value" 133 | 134 | 135 | def test_conf_manager_update_conf_with_encrypted_keys(): 136 | session_key = context.get_session_key() 137 | cfm = _build_conf_manager(session_key) 138 | 139 | conf_file = cfm.create_conf("conf_file_with_encrypted_keys") 140 | conf_file.update( 141 | "stanza", {"key1": "value1", "key2": "value2"}, encrypt_keys=["key2"] 142 | ) 143 | 144 | assert conf_file.get("stanza")["key2"] == "value2" 145 | 146 | 147 | def test_get_log_level(): 148 | session_key = context.get_session_key() 149 | expected_log_level = "DEBUG" 150 | 151 | log_level = conf_manager.get_log_level( 152 | logger=mock.MagicMock(), 153 | session_key=session_key, 154 | app_name="solnlib_demo", 155 | conf_name="splunk_ta_addon_settings", 156 | log_level_field="log_level", 157 | ) 158 | 159 | assert expected_log_level == log_level 160 | 161 | 162 | def test_get_log_level_incorrect_log_level_field(): 163 | session_key = context.get_session_key() 164 | expected_log_level = "INFO" 165 | 166 | log_level = conf_manager.get_log_level( 167 | logger=mock.MagicMock(), 168 | session_key=session_key, 169 | app_name="solnlib_demo", 170 | conf_name="splunk_ta_addon_settings", 171 | ) 172 | 173 | assert expected_log_level == log_level 174 | 175 | 176 | def test_get_proxy_dict(): 177 | session_key = context.get_session_key() 178 | expected_proxy_dict = VALID_PROXY_DICT 179 | proxy_dict = conf_manager.get_proxy_dict( 180 | logger=mock.MagicMock(), 181 | session_key=session_key, 182 | app_name="solnlib_demo", 183 | conf_name="splunk_ta_addon_settings", 184 | ) 185 | assert expected_proxy_dict == proxy_dict 186 | 187 | 188 | def test_invalid_proxy_port(): 189 | session_key = context.get_session_key() 190 | 191 | with pytest.raises(soln_exceptions.InvalidPortError): 192 | conf_manager.get_proxy_dict( 193 | logger=mock.MagicMock(), 194 | session_key=session_key, 195 | app_name="solnlib_demo", 196 | conf_name="splunk_ta_addon_settings_invalid", 197 | proxy_stanza="invalid_proxy", 198 | proxy_port="proxy_port", 199 | ) 200 | 201 | 202 | def test_invalid_proxy_host(): 203 | session_key = context.get_session_key() 204 | 205 | with pytest.raises(soln_exceptions.InvalidHostnameError): 206 | conf_manager.get_proxy_dict( 207 | logger=mock.MagicMock(), 208 | session_key=session_key, 209 | app_name="solnlib_demo", 210 | conf_name="splunk_ta_addon_settings_invalid", 211 | proxy_stanza="invalid_proxy", 212 | proxy_host="proxy_url", 213 | ) 214 | 215 | 216 | def test_conf_manager_exception(): 217 | session_key = context.get_session_key() 218 | 219 | with pytest.raises(soln_exceptions.ConfManagerException): 220 | conf_manager.get_proxy_dict( 221 | logger=mock.MagicMock(), 222 | session_key=session_key, 223 | app_name="solnlib_demo", 224 | conf_name="splunk_ta_addon_settings_not_valid", 225 | ) 226 | 227 | 228 | def test_conf_stanza_not_exist_exception(): 229 | session_key = context.get_session_key() 230 | 231 | with pytest.raises(soln_exceptions.ConfStanzaNotExistException): 232 | conf_manager.get_proxy_dict( 233 | logger=mock.MagicMock(), 234 | session_key=session_key, 235 | app_name="solnlib_demo", 236 | conf_name="splunk_ta_addon_settings", 237 | proxy_stanza="invalid_proxy", 238 | ) 239 | -------------------------------------------------------------------------------- /tests/integration/test_credentials.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import context 18 | import os.path as op 19 | import sys 20 | from typing import Optional 21 | import pytest 22 | from solnlib import credentials 23 | 24 | sys.path.insert(0, op.dirname(op.dirname(op.abspath(__file__)))) 25 | 26 | 27 | def _build_credential_manager( 28 | realm: Optional[str] = None, 29 | ) -> credentials.CredentialManager: 30 | session_key = credentials.get_session_key( 31 | context.username, 32 | context.password, 33 | scheme=context.scheme, 34 | host=context.host, 35 | port=context.port, 36 | ) 37 | return credentials.CredentialManager( 38 | session_key, 39 | context.app, 40 | owner=context.owner, 41 | realm=realm, 42 | scheme=context.scheme, 43 | host=context.host, 44 | port=context.port, 45 | ) 46 | 47 | 48 | def test_get_password(): 49 | cm = _build_credential_manager(realm=context.app) 50 | 51 | cm.set_password("user1", "password1") 52 | assert cm.get_password("user1") == "password1" 53 | 54 | 55 | def test_get_password_when_no_user_exists_then_throw_exception(): 56 | cm = _build_credential_manager(realm=context.app) 57 | 58 | with pytest.raises(credentials.CredentialNotExistException): 59 | cm.get_password("nonexistentuser") 60 | 61 | 62 | def test_delete_password(): 63 | cm = _build_credential_manager(realm=context.app) 64 | cm.set_password("user2", "password2") 65 | 66 | cm.delete_password("user2") 67 | 68 | with pytest.raises(credentials.CredentialNotExistException): 69 | cm.get_password("user2") 70 | 71 | 72 | def test_delete_password_when_no_user_exists_then_throw_exception(): 73 | cm = _build_credential_manager(realm=context.app) 74 | 75 | with pytest.raises(credentials.CredentialNotExistException): 76 | cm.delete_password("nonexistentuser") 77 | 78 | 79 | def test_get_clear_passwords_in_realm(): 80 | cm = _build_credential_manager(realm=context.app) 81 | cm.set_password("user3", "password3") 82 | 83 | expected_result = { 84 | "name": "solnlib_demo:user3", 85 | "realm": "solnlib_demo", 86 | "username": "user3", 87 | "clear_password": "password3", 88 | } 89 | results = cm.get_clear_passwords_in_realm() 90 | for result in results: 91 | if result["name"] == expected_result["name"]: 92 | assert result["realm"] == expected_result["realm"] 93 | assert result["username"] == expected_result["username"] 94 | assert result["clear_password"] == expected_result["clear_password"] 95 | break 96 | 97 | 98 | def test_get_clear_passwords(): 99 | cm = _build_credential_manager() 100 | cm.set_password("user3", "password3") 101 | 102 | expected_result = { 103 | "name": "solnlib_demo:user3", 104 | "realm": "solnlib_demo", 105 | "username": "user3", 106 | "clear_password": "password3", 107 | } 108 | results = cm.get_clear_passwords() 109 | for result in results: 110 | if result["name"] == expected_result["name"]: 111 | assert result["realm"] == expected_result["realm"] 112 | assert result["username"] == expected_result["username"] 113 | assert result["clear_password"] == expected_result["clear_password"] 114 | break 115 | -------------------------------------------------------------------------------- /tests/integration/test_hec_config.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import context 18 | import os.path as op 19 | import sys 20 | 21 | from solnlib import hec_config 22 | 23 | sys.path.insert(0, op.dirname(op.dirname(op.abspath(__file__)))) 24 | 25 | 26 | def test_hec_config(): 27 | session_key = context.get_session_key() 28 | config = hec_config.HECConfig(session_key) 29 | stanza = { 30 | "index": "main", 31 | "sourcetype": "akamai:cm:json2", 32 | "token": "A0-5800-406B-9224-8E1DC4E720B7", 33 | } 34 | 35 | assert config.delete_input("not_exists") is None 36 | name = "hec_config_testing" 37 | config.delete_input(name) 38 | assert config.get_input(name) is None 39 | 40 | config.create_input(name, stanza) 41 | res = config.get_input(name) 42 | for k in ["index", "sourcetype", "token"]: 43 | assert res[k] == stanza[k] 44 | 45 | config.delete_input(name) 46 | assert config.get_input(name) is None 47 | 48 | setting = { 49 | "enableSSL": "1", 50 | "disabled": "1", 51 | "useDeploymentServer": "0", 52 | "port": "8087", 53 | "output_mode": "json", 54 | } 55 | 56 | config.update_settings(setting) 57 | new_settings = config.get_settings() 58 | for k in ["enableSSL", "disabled", "useDeploymentServer", "port"]: 59 | assert new_settings[k] == setting[k] 60 | 61 | limits = {"max_content_length": "4000000"} 62 | 63 | config.set_limits(limits) 64 | new_limits = config.get_limits() 65 | assert new_limits["max_content_length"] == limits["max_content_length"] 66 | 67 | 68 | if __name__ == "__main__": 69 | test_hec_config() 70 | -------------------------------------------------------------------------------- /tests/integration/test_hec_event_writer.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | import context 17 | import os.path as op 18 | import sys 19 | import time 20 | 21 | from _search import search 22 | 23 | from solnlib.modular_input import event_writer as hew 24 | 25 | sys.path.insert(0, op.dirname(op.dirname(op.abspath(__file__)))) 26 | 27 | 28 | def test_hec_event_writer(): 29 | session_key = context.get_session_key() 30 | 31 | ew = hew.HECEventWriter("test", session_key) 32 | m1 = {} 33 | for i in range(100): 34 | m1[i] = "test1 data %s" % i 35 | e1 = ew.create_event(m1, index="main", host="testing", sourcetype="hec") 36 | m2 = {} 37 | for i in range(100): 38 | m2[i] = "test2 data %s" % i 39 | e2 = ew.create_event(m2, index="main", host="testing", sourcetype="hec") 40 | ew.write_events([e1, e2]) 41 | 42 | 43 | def test_hec_event_writes_with_non_utf_8(): 44 | # To test scenario listed in https://github.com/splunk/addonfactory-solutions-library-python/pull/112. 45 | test_name = "test_hec_event_writes_with_non_utf_8" 46 | session_key = context.get_session_key() 47 | ew = hew.HECEventWriter("test", session_key) 48 | event = ew.create_event( 49 | [ 50 | { 51 | "test_name": test_name, 52 | "field_a": "Üü_Öö_Ää_some_text", 53 | "field_b": "some_text_Üü_Öö_Ää", 54 | }, 55 | ], 56 | index="main", 57 | host="testing", 58 | sourcetype="hec", 59 | ) 60 | ew.write_events([event]) 61 | time.sleep(2) 62 | 63 | search_results = search( 64 | session_key, f"search index=main sourcetype=hec {test_name}" 65 | ) 66 | 67 | assert len(search_results) == 1 68 | _raw_event = search_results[0]["_raw"] 69 | assert "Üü_Öö_Ää_some_text" in _raw_event 70 | assert "some_text_Üü_Öö_Ää" in _raw_event 71 | assert "\\u00dc\\u00fc_\\u00d6\\u00f6_\\u00c4\\u00e4_some_text" not in _raw_event 72 | assert "some_text_\\u00dc\\u00fc_\\u00d6\\u00f6_\\u00c4\\u00e4" not in _raw_event 73 | -------------------------------------------------------------------------------- /tests/integration/test_logger.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | import context 17 | import os.path as op 18 | import sys 19 | import time 20 | from _search import search 21 | 22 | sys.path.insert(0, op.dirname(op.dirname(op.abspath(__file__)))) 23 | 24 | 25 | def test_CVE_2023_32712(): 26 | # CVE-2023-32712 27 | session_key = context.get_session_key() 28 | 29 | msg_prefix = "ASCII Table in one string: " 30 | time.sleep(30) 31 | search_results = search(session_key, f'search index=_internal "{msg_prefix}"') 32 | assert len(search_results) >= 1 33 | _raw_event = search_results[0]["_raw"] 34 | 35 | # test for nonwhite characters and white characters as they should be represented in fixed Splunk instance 36 | assert r"\x00" in _raw_event 37 | assert r"\x01\x02\x03\x04\x05\x06\x07\x08" in _raw_event 38 | # assert "\t\n" in _raw_event 39 | assert r"\x0b\x0c" in _raw_event 40 | # assert "\r" in _raw_event 41 | assert ( 42 | r"\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" 43 | in _raw_event 44 | ) 45 | assert ( 46 | " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" 47 | in _raw_event 48 | ) 49 | assert r"\x7f" in _raw_event 50 | 51 | # test for white characters as they shouldn't be represented in fixed Splunk instance 52 | def gen_ascii_chars_range(start: int = 0, stop: int = 128) -> str: 53 | chars_str = "" 54 | for i in range(start, stop): 55 | chars_str += chr(i) 56 | return chars_str 57 | 58 | ascii_chars_range_00_09 = gen_ascii_chars_range(start=0, stop=9) 59 | ascii_chars_range_0b_0d = gen_ascii_chars_range(start=11, stop=13) 60 | ascii_chars_range_0e_20 = gen_ascii_chars_range(start=14, stop=32) 61 | assert ascii_chars_range_00_09 not in _raw_event 62 | assert ascii_chars_range_0b_0d not in _raw_event 63 | assert ascii_chars_range_0e_20 not in _raw_event 64 | -------------------------------------------------------------------------------- /tests/integration/test_server_info.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | import context 17 | import pytest 18 | 19 | from solnlib import server_info 20 | 21 | 22 | def test_server_info_methods(): 23 | # This test does not check for SHC related methods. 24 | session_key = context.get_session_key() 25 | si = server_info.ServerInfo(session_key, context.scheme, context.host, context.port) 26 | assert "custom-servername" == si.server_name 27 | assert si.is_search_head() is False 28 | assert si.is_shc_member() is False 29 | with pytest.raises(server_info.ServerInfoException): 30 | si.get_shc_members() 31 | with pytest.raises(server_info.ServerInfoException): 32 | si.captain_info() 33 | with pytest.raises(server_info.ServerInfoException): 34 | si.is_captain_ready() 35 | assert si.is_captain() is False 36 | assert si.is_cloud_instance() is False 37 | assert "custom-servername" == si.to_dict()["serverName"] 38 | # Just call those properties. 39 | assert si.version 40 | assert si.guid 41 | 42 | 43 | def test_from_server_uri(): 44 | session_key = context.get_session_key() 45 | si = server_info.ServerInfo.from_server_uri( 46 | f"{context.scheme}://{context.host}:{context.port}", session_key 47 | ) 48 | # Run 1 small test to check that .from_server_uri is working. 49 | assert "custom-servername" == si.server_name 50 | -------------------------------------------------------------------------------- /tests/integration/test_splunk_rest_client.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import context 18 | import pytest 19 | import solnlib 20 | from time import sleep 21 | from splunklib import binding 22 | from solnlib import splunk_rest_client as rest_client 23 | 24 | from _search import search 25 | 26 | 27 | def test_rest_client_user_agent(): 28 | test_url = r'search index = _internal uri_path="*/servicesNS/nobody/test_app/some/unexisting/url"' 29 | user_agent = f"solnlib/{solnlib.__version__} rest-client linux" 30 | session_key = context.get_session_key() 31 | wrong_url = r"some/unexisting/url" 32 | rc = rest_client.SplunkRestClient( 33 | session_key, 34 | app="test_app", 35 | owner=context.owner, 36 | scheme=context.scheme, 37 | host=context.host, 38 | port=context.port, 39 | ) 40 | with pytest.raises(binding.HTTPError): 41 | rc.get(wrong_url) 42 | 43 | for i in range(50): 44 | search_results = search(session_key, test_url) 45 | if len(search_results) > 0: 46 | break 47 | sleep(0.5) 48 | 49 | assert user_agent in search_results[0]["_raw"] 50 | -------------------------------------------------------------------------------- /tests/integration/test_splunkenv.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import os 18 | import os.path as op 19 | import sys 20 | from solnlib import splunkenv 21 | 22 | sys.path.insert(0, op.dirname(op.dirname(op.abspath(__file__)))) 23 | 24 | 25 | def test_splunkenv(): 26 | assert "SPLUNK_HOME" in os.environ 27 | 28 | splunkhome_path = splunkenv.make_splunkhome_path(["etc", "apps"]) 29 | assert splunkhome_path == op.join(os.environ["SPLUNK_HOME"], "etc", "apps") 30 | 31 | server_name, host_name = splunkenv.get_splunk_host_info() 32 | assert server_name 33 | assert host_name 34 | 35 | splunk_bin = splunkenv.get_splunk_bin() 36 | assert splunk_bin in [ 37 | op.join(os.environ["SPLUNK_HOME"], "bin", "splunk"), 38 | op.join(os.environ["SPLUNK_HOME"], "bin", "splunk.exe"), 39 | ] 40 | 41 | scheme, host, port = splunkenv.get_splunkd_access_info() 42 | assert scheme 43 | assert host 44 | assert port 45 | 46 | uri = splunkenv.get_splunkd_uri() 47 | assert uri == f"{scheme}://{host}:{port}" 48 | -------------------------------------------------------------------------------- /tests/integration/test_time_parser.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import context 18 | import datetime 19 | import os.path as op 20 | import sys 21 | import pytest 22 | from solnlib import time_parser 23 | 24 | sys.path.insert(0, op.dirname(op.dirname(op.abspath(__file__)))) 25 | 26 | 27 | def test_time_parser(): 28 | session_key = context.get_session_key() 29 | tp = time_parser.TimeParser(session_key) 30 | 31 | assert tp.to_seconds("2011-07-06T21:54:23.000-07:00") == 1310014463.0 32 | assert tp.to_utc("2011-07-06T21:54:23.000-07:00") == datetime.datetime( 33 | 2011, 7, 7, 4, 54, 23 34 | ) 35 | assert ( 36 | tp.to_local("2011-07-06T21:54:23.000-07:00") == "2011-07-07T04:54:23.000+00:00" 37 | ) 38 | 39 | with pytest.raises(time_parser.InvalidTimeFormatException): 40 | tp.to_seconds("2011-07-06T21:54:23.000-07;00") 41 | with pytest.raises(time_parser.InvalidTimeFormatException): 42 | tp.to_utc("2011-07-06T21:54:23.000-07;00") 43 | with pytest.raises(time_parser.InvalidTimeFormatException): 44 | tp.to_local("2011-07-06T21:54:23.000-07;00") 45 | -------------------------------------------------------------------------------- /tests/integration/test_user_access.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import context 18 | import os.path as op 19 | import sys 20 | 21 | import pytest 22 | 23 | from solnlib import user_access 24 | 25 | sys.path.insert(0, op.dirname(op.dirname(op.abspath(__file__)))) 26 | 27 | 28 | def test_object_acl_manager(): 29 | session_key = context.get_session_key() 30 | oaclm = user_access.ObjectACLManager( 31 | "object_acls_collection", 32 | session_key, 33 | context.app, 34 | owner=context.owner, 35 | scheme=context.scheme, 36 | host=context.host, 37 | port=context.port, 38 | ) 39 | 40 | obj_collection = "test_object_collection" 41 | obj_id1 = "281a6d3310e711e6b2c9a45e60e34295" 42 | obj_id2 = "281a6d3310e711e6b2c9a45e60e34296" 43 | obj_id3 = "281a6d3310e711e6b2c9a45e60e34297" 44 | obj_id4 = "281a6d3310e711e6b2c9a45e60e34294" 45 | obj_type = "test_object_type" 46 | obj_perms1 = {"read": ["admin"], "write": ["admin"], "delete": ["admin"]} 47 | obj_perms2 = {"read": ["user1"], "write": ["admin"], "delete": ["admin"]} 48 | oaclm.update_acl( 49 | obj_collection, obj_id1, obj_type, context.app, context.owner, obj_perms1, True 50 | ) 51 | oaclm.update_acl( 52 | obj_collection, 53 | obj_id1, 54 | obj_type, 55 | context.app, 56 | context.owner, 57 | obj_perms2, 58 | True, 59 | replace_existing=False, 60 | ) 61 | obj_acl = oaclm.get_acl(obj_collection, obj_id1) 62 | assert set(obj_acl.obj_perms["read"]) == {"admin", "user1"} 63 | 64 | oaclm.update_acls( 65 | obj_collection, 66 | [obj_id2, obj_id3], 67 | obj_type, 68 | context.app, 69 | context.owner, 70 | obj_perms1, 71 | True, 72 | ) 73 | oaclm.get_acl(obj_collection, obj_id2) 74 | oaclm.get_acl(obj_collection, obj_id3) 75 | obj_acls = oaclm.get_acls(obj_collection, [obj_id2, obj_id3, obj_id4]) 76 | assert len(obj_acls) == 2 77 | 78 | assert oaclm.get_accessible_object_ids( 79 | "user1", "read", obj_collection, [obj_id1, obj_id2, obj_id3] 80 | ) == [obj_id1] 81 | 82 | oaclm.delete_acl(obj_collection, obj_id1) 83 | oaclm.delete_acls(obj_collection, [obj_id2, obj_id3]) 84 | 85 | 86 | def test_app_capability_manager(): 87 | session_key = context.get_session_key() 88 | acm = user_access.AppCapabilityManager( 89 | "app_capabilities_collection", 90 | session_key, 91 | context.app, 92 | owner=context.owner, 93 | scheme=context.scheme, 94 | host=context.host, 95 | port=context.port, 96 | ) 97 | 98 | app_capabilities = { 99 | "object_type1": { 100 | "read": "read_app_object_type1", 101 | "write": "write_app_object_type1", 102 | "delete": "delete_app_object_type1", 103 | }, 104 | "object_type2": { 105 | "read": "read_app_object_type2", 106 | "write": "write_app_object_type2", 107 | "delete": "delete_app_object_type2", 108 | }, 109 | } 110 | acm.register_capabilities(app_capabilities) 111 | assert acm.capabilities_are_registered() 112 | assert acm.get_capabilities() == app_capabilities 113 | acm.unregister_capabilities() 114 | assert not acm.capabilities_are_registered() 115 | 116 | 117 | def test_check_user_access(): 118 | session_key = context.get_session_key() 119 | app_capabilities = { 120 | "object_type1": { 121 | "read": "read_app_object_type1", 122 | "write": "write_app_object_type1", 123 | "delete": "delete_app_object_type1", 124 | }, 125 | "object_type2": { 126 | "read": "read_app_object_type2", 127 | "write": "write_app_object_type2", 128 | "delete": "delete_app_object_type2", 129 | }, 130 | } 131 | 132 | with pytest.raises(user_access.UserAccessException): 133 | user_access.check_user_access( 134 | session_key, app_capabilities, "object_type1", "read" 135 | ) 136 | 137 | 138 | def test_get_current_username(): 139 | session_key = context.get_session_key() 140 | assert ( 141 | user_access.get_current_username( 142 | session_key, scheme=context.scheme, host=context.host, port=context.port 143 | ) 144 | == context.username 145 | ) 146 | 147 | 148 | def test_get_user_capabilities(): 149 | session_key = context.get_session_key() 150 | user_access.get_user_capabilities( 151 | session_key, 152 | context.username, 153 | scheme=context.scheme, 154 | host=context.host, 155 | port=context.port, 156 | ) 157 | 158 | 159 | def test_user_is_capable(): 160 | session_key = context.get_session_key() 161 | assert not user_access.user_is_capable( 162 | session_key, 163 | context.username, 164 | "test_capability", 165 | scheme=context.scheme, 166 | host=context.host, 167 | port=context.port, 168 | ) 169 | 170 | 171 | def test_get_user_roles(): 172 | session_key = context.get_session_key() 173 | user_access.get_user_roles( 174 | session_key, 175 | context.username, 176 | scheme=context.scheme, 177 | host=context.host, 178 | port=context.port, 179 | ) 180 | 181 | 182 | def test_user_access(): 183 | test_object_acl_manager() 184 | test_app_capability_manager() 185 | test_check_user_access() 186 | test_get_current_username() 187 | test_get_user_capabilities() 188 | test_user_is_capable() 189 | test_get_user_roles() 190 | -------------------------------------------------------------------------------- /tests/unit/common.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import os.path as op 18 | import socket 19 | import subprocess 20 | 21 | from splunklib import binding, client 22 | from splunklib.data import record 23 | 24 | cur_dir = op.dirname(op.abspath(__file__)) 25 | 26 | # Namespace 27 | app = "unittest" 28 | owner = "nobody" 29 | 30 | # Session key sample 31 | SESSION_KEY = "nU1aB6BntzwREOnGowa7pN6avV3B6JefliAZIzCX9" 32 | 33 | 34 | def mock_splunkhome(monkeypatch): 35 | class MockPopen: 36 | def __init__( 37 | self, 38 | args, 39 | bufsize=0, 40 | executable=None, 41 | stdin=None, 42 | stdout=None, 43 | stderr=None, 44 | preexec_fn=None, 45 | close_fds=False, 46 | shell=False, 47 | cwd=None, 48 | env=None, 49 | universal_newlines=False, 50 | startupinfo=None, 51 | creationflags=0, 52 | ): 53 | self._conf = args[3] 54 | 55 | def communicate(self, input=None): 56 | if self._conf == "server": 57 | file_path = op.sep.join( 58 | [cur_dir, "data/mock_splunk/etc/system/default/server.conf"] 59 | ) 60 | elif self._conf == "inputs": 61 | file_path = op.sep.join( 62 | [ 63 | cur_dir, 64 | "data/mock_splunk/etc/apps/splunk_httpinput/local/inputs.conf", 65 | ] 66 | ) 67 | else: 68 | file_path = op.sep.join( 69 | [cur_dir, "data/mock_splunk/etc/system/default/web.conf"] 70 | ) 71 | 72 | with open(file_path) as fp: 73 | return fp.read(), None 74 | 75 | splunk_home = op.join(cur_dir, "data/mock_splunk/") 76 | monkeypatch.setenv("SPLUNK_HOME", splunk_home) 77 | monkeypatch.setenv("SPLUNK_ETC", op.join(splunk_home, "etc")) 78 | monkeypatch.setattr(subprocess, "Popen", MockPopen) 79 | 80 | 81 | def mock_serverinfo(monkeypatch): 82 | mock_server_info_property = { 83 | "server_roles": [ 84 | "cluster_search_head", 85 | "search_head", 86 | "kv_store", 87 | "shc_captain", 88 | ], 89 | "version": "6.3.1511.2", 90 | "serverName": "unittestServer", 91 | } 92 | 93 | monkeypatch.setattr(client.Service, "info", mock_server_info_property) 94 | 95 | 96 | def mock_gethostname(monkeypatch): 97 | def mock_gethostname(): 98 | return "unittestServer" 99 | 100 | monkeypatch.setattr(socket, "gethostname", mock_gethostname) 101 | 102 | 103 | def make_response_record(body, status=200): 104 | class _MocBufReader: 105 | def __init__(self, buf): 106 | if isinstance(buf, str): 107 | self._buf = buf.encode("utf-8") 108 | else: 109 | self._buf = buf 110 | 111 | def read(self, size=None): 112 | return self._buf 113 | 114 | return record( 115 | { 116 | "body": binding.ResponseReader(_MocBufReader(body)), 117 | "status": status, 118 | "reason": "", 119 | "headers": None, 120 | } 121 | ) 122 | -------------------------------------------------------------------------------- /tests/unit/data/mock_splunk/etc/apps/splunk_httpinput/local/inputs.conf: -------------------------------------------------------------------------------- 1 | [http] 2 | disabled = 0 3 | enableSSL = 0 4 | -------------------------------------------------------------------------------- /tests/unit/data/mock_splunk/etc/apps/unittest/metadata/default.meta: -------------------------------------------------------------------------------- 1 | 2 | [] 3 | access = read : [ * ], write : [ admin ] 4 | export = system 5 | -------------------------------------------------------------------------------- /tests/unit/data/mock_splunk/etc/apps/unittest/metadata/local.meta: -------------------------------------------------------------------------------- 1 | [sessions/test] 2 | version = 6.3.0 3 | modtime = 1453272423.443622000 4 | -------------------------------------------------------------------------------- /tests/unit/fakes/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | -------------------------------------------------------------------------------- /tests/unit/fakes/fake_kv_store_collection_data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | import json 17 | 18 | import common 19 | from splunklib import client 20 | 21 | 22 | class FakeKVStoreCollectionDataThrowingExceptions: 23 | """Fake implementation of KVStoreCollectionData for 24 | splunklib.client.KVStoreCollectionData which always throws 25 | splunklib.client.HTTPError.""" 26 | 27 | def query_by_id(self, key): 28 | raise client.HTTPError( 29 | common.make_response_record( 30 | b"", 31 | status=503, 32 | ) 33 | ) 34 | 35 | def delete_by_id(self, key): 36 | raise client.HTTPError( 37 | common.make_response_record( 38 | b"", 39 | status=503, 40 | ) 41 | ) 42 | 43 | 44 | class FakeKVStoreCollectionData: 45 | """Fake implementation of KVStoreCollectionData for 46 | splunklib.client.KVStoreCollectionData.""" 47 | 48 | def __init__(self, documents=None): 49 | self._documents = {} 50 | if documents is not None: 51 | for document in documents: 52 | self._documents[document["_key"]] = { 53 | "_key": document["_key"], 54 | "state": json.dumps(document["state"]), 55 | } 56 | 57 | def get(self, _id): 58 | return self._documents.get(_id) 59 | 60 | def id_exists(self, _id): 61 | return True if _id in self._documents else False 62 | 63 | def batch_save(self, *documents): 64 | for document in documents: 65 | self._documents[document["_key"]] = document 66 | 67 | def delete_by_id(self, _id): 68 | if _id in self._documents: 69 | del self._documents[_id] 70 | else: 71 | raise client.HTTPError( 72 | common.make_response_record( 73 | b"", 74 | status=404, 75 | ) 76 | ) 77 | 78 | def query_by_id(self, _id): 79 | if _id in self._documents: 80 | return self._documents[_id] 81 | else: 82 | raise client.HTTPError( 83 | common.make_response_record( 84 | b"", 85 | status=404, 86 | ) 87 | ) 88 | -------------------------------------------------------------------------------- /tests/unit/test_acl.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import json 18 | 19 | import common 20 | import pytest 21 | from splunklib import binding 22 | 23 | from solnlib import acl 24 | 25 | _old_acl = ( 26 | '{"entry": [{"author": "nobody", "name": "transforms", ' 27 | '"acl": {"sharing": "global", "perms": {"read": ["*"], "write": ["*"]}, ' 28 | '"app": "unittest", "modifiable": true, "owner": "nobody", "can_change_perms": true, ' 29 | '"can_share_global": true, "can_list": true, "can_share_user": false, "can_share_app": true, ' 30 | '"removable": false, "can_write": true}}]}' 31 | ) 32 | 33 | _new_acl1 = ( 34 | '{"entry": [{"author": "nobody", "name": "transforms", ' 35 | '"acl": {"sharing": "global", "perms": {"read": ["admin"], "write": ["admin"]}, ' 36 | '"app": "unittest", "modifiable": true, "owner": "nobody", "can_change_perms": true, ' 37 | '"can_share_global": true, "can_list": true, "can_share_user": false, "can_share_app": true, ' 38 | '"removable": false, "can_write": true}}]}' 39 | ) 40 | 41 | _new_acl2 = ( 42 | '{"entry": [{"author": "nobody", "name": "transforms", ' 43 | '"acl": {"sharing": "global", "perms": {"read": ["admin"], "write": ["*"]}, ' 44 | '"app": "unittest", "modifiable": true, "owner": "nobody", "can_change_perms": true, ' 45 | '"can_share_global": true, "can_list": true, "can_share_user": false, "can_share_app": true, ' 46 | '"removable": false, "can_write": true}}]}' 47 | ) 48 | 49 | _new_acl3 = ( 50 | '{"entry": [{"author": "nobody", "name": "transforms", ' 51 | '"acl": {"sharing": "global", "perms": {"read": ["*"], "write": ["admin"]}, ' 52 | '"app": "unittest", "modifiable": true, "owner": "nobody", "can_change_perms": true, ' 53 | '"can_share_global": true, "can_list": true, "can_share_user": false, "can_share_app": true, ' 54 | '"removable": false, "can_write": true}}]}' 55 | ) 56 | 57 | 58 | def _mock_get(self, path_segment, owner=None, app=None, sharing=None, **query): 59 | return common.make_response_record(_old_acl) 60 | 61 | 62 | def _mock_post( 63 | self, path_segment, owner=None, app=None, sharing=None, headers=None, **query 64 | ): 65 | if "perms.read=admin" in query["body"] and "perms.write=admin" in query["body"]: 66 | return common.make_response_record(_new_acl1) 67 | elif "perms.read=admin" in query["body"]: 68 | return common.make_response_record(_new_acl2) 69 | elif "perms.write=admin" in query["body"]: 70 | return common.make_response_record(_new_acl3) 71 | else: 72 | return common.make_response_record(_old_acl) 73 | 74 | 75 | class TestACLManager: 76 | def test_get(self, monkeypatch): 77 | common.mock_splunkhome(monkeypatch) 78 | monkeypatch.setattr(binding.Context, "get", _mock_get) 79 | 80 | aclm = acl.ACLManager(common.SESSION_KEY, common.app) 81 | perms = aclm.get("data/transforms/extractions/_acl") 82 | assert perms == json.loads(_old_acl)["entry"][0]["acl"] 83 | 84 | def test_update(self, monkeypatch): 85 | common.mock_splunkhome(monkeypatch) 86 | monkeypatch.setattr(binding.Context, "get", _mock_get) 87 | monkeypatch.setattr(binding.Context, "post", _mock_post) 88 | 89 | aclm = acl.ACLManager(common.SESSION_KEY, common.app) 90 | 91 | perms = aclm.update( 92 | "data/transforms/extractions/_acl", 93 | perms_read=["admin"], 94 | perms_write=["admin"], 95 | ) 96 | assert perms == json.loads(_new_acl1)["entry"][0]["acl"] 97 | 98 | perms = aclm.update("data/transforms/extractions/_acl", perms_read=["admin"]) 99 | assert perms == json.loads(_new_acl2)["entry"][0]["acl"] 100 | 101 | perms = aclm.update("data/transforms/extractions/_acl", perms_write=["admin"]) 102 | assert perms == json.loads(_new_acl3)["entry"][0]["acl"] 103 | 104 | perms = aclm.update("data/transforms/extractions/_acl") 105 | assert perms == json.loads(_old_acl)["entry"][0]["acl"] 106 | 107 | with pytest.raises(acl.ACLException): 108 | aclm.update("data/transforms/extractions", perms_write=["admin"]) 109 | -------------------------------------------------------------------------------- /tests/unit/test_bulletin_rest_client.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2025 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import pytest 18 | from solnlib.bulletin_rest_client import BulletinRestClient 19 | 20 | 21 | context = {"owner": "nobody", "scheme": "https", "host": "localhost", "port": 8089} 22 | 23 | 24 | def test_create_message(monkeypatch): 25 | session_key = "123" 26 | bulletin_client = BulletinRestClient( 27 | "msg_name_1", 28 | session_key, 29 | "_", 30 | **context, 31 | ) 32 | 33 | def new_post(*args, **kwargs) -> str: 34 | return "ok" 35 | 36 | monkeypatch.setattr(bulletin_client._rest_client, "post", new_post) 37 | 38 | bulletin_client.create_message( 39 | "new message to bulletin", 40 | capabilities=["apps_restore", "delete_messages"], 41 | roles=["admin"], 42 | ) 43 | 44 | with pytest.raises(ValueError, match="Severity must be one of"): 45 | bulletin_client.create_message( 46 | "new message to bulletin", 47 | severity="debug", 48 | capabilities=["apps_restore", "delete_messages", 1], 49 | roles=["admin"], 50 | ) 51 | 52 | with pytest.raises(ValueError, match="Capabilities must be a list of strings."): 53 | bulletin_client.create_message( 54 | "new message to bulletin", 55 | capabilities=["apps_restore", "delete_messages", 1], 56 | roles=["admin"], 57 | ) 58 | 59 | with pytest.raises(ValueError, match="Roles must be a list of strings."): 60 | bulletin_client.create_message( 61 | "new message to bulletin", 62 | capabilities=["apps_restore", "delete_messages"], 63 | roles=["admin", 1], 64 | ) 65 | -------------------------------------------------------------------------------- /tests/unit/test_credentials.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | import common 17 | import pytest 18 | from splunklib import binding 19 | 20 | from solnlib import credentials 21 | 22 | 23 | def test_get_session_key(monkeypatch): 24 | def _mock_session_key_post(self, url, headers=None, **kwargs): 25 | return common.make_response_record( 26 | '{"sessionKey":"' + common.SESSION_KEY + '"}' 27 | ) 28 | 29 | common.mock_splunkhome(monkeypatch) 30 | monkeypatch.setattr(binding.HttpLib, "post", _mock_session_key_post) 31 | 32 | assert credentials.get_session_key("user", "password") == common.SESSION_KEY 33 | 34 | with pytest.raises(ValueError): 35 | credentials.get_session_key("user", "password", scheme="non-http") 36 | credentials.get_session_key("user", "password", scheme="http") 37 | credentials.get_session_key("user", "password", scheme="https") 38 | with pytest.raises(ValueError): 39 | credentials.get_session_key("user", "password", scheme="http", host="==") 40 | credentials.get_session_key("user", "password", scheme="http", host="localhost") 41 | with pytest.raises(ValueError): 42 | credentials.get_session_key( 43 | "user", "password", scheme="http", host="localhost", port=-10 44 | ) 45 | credentials.get_session_key( 46 | "user", "password", scheme="http", host="localhost", port=10 47 | ) 48 | credentials.get_session_key("user", "password", scheme="HTTP") 49 | credentials.get_session_key("user", "password", scheme="HTTPS") 50 | -------------------------------------------------------------------------------- /tests/unit/test_file_monitor.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import os 18 | import tempfile 19 | import time 20 | from unittest import mock 21 | 22 | from solnlib import file_monitor 23 | 24 | 25 | class TestFileChangesChecker: 26 | def test_check_changes(self): 27 | with tempfile.NamedTemporaryFile() as tmpfile: 28 | self._called = False 29 | 30 | def _callback_when_file_changes(changed): 31 | self._called = True 32 | 33 | checker = file_monitor.FileChangesChecker( 34 | _callback_when_file_changes, [tmpfile.name] 35 | ) 36 | res = checker.check_changes() 37 | assert res is False 38 | assert self._called is False 39 | 40 | time.sleep(1) 41 | with open(tmpfile.name, "a") as fp: 42 | fp.write("some changes") 43 | res = checker.check_changes() 44 | assert res is True 45 | assert self._called is True 46 | 47 | @mock.patch("os.path.getmtime") 48 | def test_check_changes_when_os_errors(self, mock_os_path_getmtime): 49 | with tempfile.TemporaryDirectory() as tmpdirname: 50 | file_1 = os.path.join(tmpdirname, "file_1") 51 | file_2 = os.path.join(tmpdirname, "file_2") 52 | with open(file_1, "w") as f1: 53 | f1.write("content 1") 54 | with open(file_2, "w") as f2: 55 | f2.write("content 2") 56 | 57 | mock_os_path_getmtime.side_effect = [ 58 | OSError, 59 | OSError, 60 | ] 61 | checker = file_monitor.FileChangesChecker( 62 | lambda _: _, 63 | [ 64 | file_1, 65 | file_2, 66 | ], 67 | ) 68 | assert {file_1: None, file_2: None} == checker.file_mtimes 69 | 70 | @mock.patch("os.path.getmtime") 71 | def test_check_changes_when_os_errors_for_one_file(self, mock_os_path_getmtime): 72 | with tempfile.TemporaryDirectory() as tmpdirname: 73 | self._changed = None 74 | 75 | def _callback_when_file_changes(changed): 76 | self._changed = changed 77 | 78 | file_1 = os.path.join(tmpdirname, "file_1") 79 | file_2 = os.path.join(tmpdirname, "file_2") 80 | with open(file_1, "w") as f1: 81 | f1.write("content 1") 82 | with open(file_2, "w") as f2: 83 | f2.write("content 2") 84 | 85 | def _side_effect(file): 86 | if file == file_1: 87 | return time.time() 88 | else: 89 | raise OSError 90 | 91 | mock_os_path_getmtime.side_effect = _side_effect 92 | checker = file_monitor.FileChangesChecker( 93 | _callback_when_file_changes, 94 | [ 95 | file_1, 96 | file_2, 97 | ], 98 | ) 99 | with open(file_1, "a") as f1: 100 | f1.write("append 1") 101 | assert checker.check_changes() 102 | assert [file_1] == self._changed 103 | 104 | 105 | class TestFileMonitor: 106 | def test_check_monitor(self): 107 | with tempfile.NamedTemporaryFile() as tmpfile: 108 | self._called = False 109 | 110 | def _callback_when_file_changes(changed): 111 | self._called = True 112 | 113 | monitor = file_monitor.FileMonitor( 114 | _callback_when_file_changes, [tmpfile.name], interval=1 115 | ) 116 | monitor.start() 117 | assert self._called is False 118 | 119 | time.sleep(1) 120 | with open(tmpfile.name, "w") as fp: 121 | fp.write("some changes") 122 | time.sleep(2) 123 | assert self._called is True 124 | 125 | monitor.stop() 126 | 127 | @mock.patch("threading.Thread") 128 | def test_two_times_start_calls_check_changes_only_once(self, mock_thread_class): 129 | mock_thread = mock.MagicMock() 130 | mock_thread_class.return_value = mock_thread 131 | with tempfile.NamedTemporaryFile() as tmpfile: 132 | monitor = file_monitor.FileMonitor(lambda _: _, [tmpfile.name]) 133 | monitor.start() 134 | time.sleep(1) 135 | monitor.start() 136 | mock_thread.start.assert_called_once() 137 | monitor.stop() 138 | -------------------------------------------------------------------------------- /tests/unit/test_modular_input_event.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import json 18 | 19 | from solnlib.modular_input import HECEvent, XMLEvent 20 | 21 | 22 | def to_sorted_json_string(obj): 23 | return json.dumps(json.loads(str(obj)), sort_keys=True) 24 | 25 | 26 | class TestXMLEvent: 27 | @classmethod 28 | def setup_class(cls): 29 | cls.xe1 = XMLEvent( 30 | data="This is a test data1.", 31 | time=1372274622.493, 32 | index="main", 33 | host="localhost", 34 | source="Splunk", 35 | sourcetype="misc", 36 | stanza="test_scheme://test", 37 | unbroken=True, 38 | done=False, 39 | ) 40 | 41 | cls.xe2 = XMLEvent( 42 | data="This is a test data2.", 43 | time=1372274622.493, 44 | index="main", 45 | host="localhost", 46 | source="Splunk", 47 | sourcetype="misc", 48 | stanza="test_scheme://test", 49 | unbroken=True, 50 | done=True, 51 | ) 52 | 53 | cls.xe3 = XMLEvent( 54 | data="This is a test data3.", 55 | time=1372274622.493, 56 | index="main", 57 | host="localhost", 58 | source="Splunk", 59 | sourcetype="misc", 60 | stanza="test_scheme://test", 61 | ) 62 | 63 | cls.xe4 = XMLEvent( 64 | data="This is utf-8 \u2603 data4.", 65 | time=1372274622.493, 66 | index="main", 67 | host="localhost", 68 | source="Splunk", 69 | sourcetype="misc", 70 | stanza="test_scheme://test", 71 | ) 72 | 73 | def test_str(self, monkeypatch): 74 | assert ( 75 | to_sorted_json_string(self.xe1) 76 | == '{"data": "This is a test data1.", "done": false, "host": "localhost", "index": "main", ' 77 | '"source": "Splunk", "sourcetype": "misc", "stanza": "test_scheme://test", ' 78 | '"time": 1372274622.493, "unbroken": true}' 79 | ) 80 | assert ( 81 | to_sorted_json_string(self.xe2) 82 | == '{"data": "This is a test data2.", "done": true, "host": "localhost", "index": "main", ' 83 | '"source": "Splunk", "sourcetype": "misc", "stanza": "test_scheme://test", ' 84 | '"time": 1372274622.493, "unbroken": true}' 85 | ) 86 | assert ( 87 | to_sorted_json_string(self.xe3) 88 | == '{"data": "This is a test data3.", "done": false, "host": "localhost", "index": "main", ' 89 | '"source": "Splunk", "sourcetype": "misc", "stanza": "test_scheme://test", ' 90 | '"time": 1372274622.493, "unbroken": false}' 91 | ) 92 | 93 | def test_format_events(self, monkeypatch): 94 | assert XMLEvent.format_events([self.xe1, self.xe2]) == [ 95 | '' 96 | "mainlocalhostSplunk" 97 | "miscThis is a test data1." 98 | '' 99 | "mainlocalhostSplunkmisc" 100 | "This is a test data2." 101 | ] 102 | assert XMLEvent.format_events([self.xe3]) == [ 103 | '' 104 | "mainlocalhostSplunkmisc" 105 | "This is a test data3." 106 | ] 107 | assert XMLEvent.format_events([self.xe4]) == [ 108 | '' 109 | "mainlocalhostSplunkmisc" 110 | "This is utf-8 \u2603 data4." 111 | ] 112 | 113 | 114 | class TestHECEvent: 115 | @classmethod 116 | def setup_class(cls): 117 | cls.he1 = HECEvent( 118 | data="This is a test data1.", 119 | time=1372274622.493, 120 | index="main", 121 | host="localhost", 122 | source="Splunk", 123 | sourcetype="misc", 124 | stanza="test_scheme://test", 125 | unbroken=True, 126 | done=False, 127 | ) 128 | 129 | cls.he2 = HECEvent( 130 | data="This is a test data2.", 131 | time=1372274622.493, 132 | index="main", 133 | host="localhost", 134 | source="Splunk", 135 | sourcetype="misc", 136 | stanza="test_scheme://test", 137 | unbroken=True, 138 | done=True, 139 | ) 140 | 141 | cls.he3 = HECEvent( 142 | data="This is a test data3.", 143 | time=1372274622.493, 144 | index="main", 145 | host="localhost", 146 | source="Splunk", 147 | sourcetype="misc", 148 | stanza="test_scheme://test", 149 | ) 150 | 151 | def test_str(self, monkeypatch): 152 | assert ( 153 | to_sorted_json_string(self.he1) 154 | == '{"data": "This is a test data1.", "done": false, "host": "localhost", "index": "main", ' 155 | '"source": "Splunk", "sourcetype": "misc", "stanza": "test_scheme://test", ' 156 | '"time": 1372274622.493, "unbroken": true}' 157 | ) 158 | assert ( 159 | to_sorted_json_string(self.he2) 160 | == '{"data": "This is a test data2.", "done": true, "host": "localhost", "index": "main", ' 161 | '"source": "Splunk", "sourcetype": "misc", "stanza": "test_scheme://test", ' 162 | '"time": 1372274622.493, "unbroken": true}' 163 | ) 164 | assert ( 165 | to_sorted_json_string(self.he3) 166 | == '{"data": "This is a test data3.", "done": false, "host": "localhost", "index": "main", ' 167 | '"source": "Splunk", "sourcetype": "misc", "stanza": "test_scheme://test", ' 168 | '"time": 1372274622.493, "unbroken": false}' 169 | ) 170 | 171 | def test_format_events(self, monkeypatch): 172 | formatted_events = HECEvent.format_events([self.he1, self.he2]) 173 | assert len(formatted_events) == 1 174 | 175 | event_strings = [ 176 | to_sorted_json_string(e) for e in formatted_events[0].split("\n") 177 | ] 178 | assert len(event_strings) == 2 179 | assert ( 180 | event_strings[0] 181 | == '{"event": "This is a test data1.", "host": "localhost", "index": "main", ' 182 | '"source": "Splunk", "sourcetype": "misc", "time": 1372274622.493}' 183 | ) 184 | assert ( 185 | event_strings[1] 186 | == '{"event": "This is a test data2.", "host": "localhost", "index": "main", ' 187 | '"source": "Splunk", "sourcetype": "misc", "time": 1372274622.493}' 188 | ) 189 | 190 | formatted_events = HECEvent.format_events([self.he3]) 191 | assert len(formatted_events) == 1 192 | 193 | event_strings = [ 194 | to_sorted_json_string(e) for e in formatted_events[0].split("\n") 195 | ] 196 | assert len(event_strings) == 1 197 | assert ( 198 | event_strings[0] 199 | == '{"event": "This is a test data3.", "host": "localhost", "index": "main", ' 200 | '"source": "Splunk", "sourcetype": "misc", "time": 1372274622.493}' 201 | ) 202 | -------------------------------------------------------------------------------- /tests/unit/test_net_utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import socket 18 | 19 | import pytest 20 | 21 | from solnlib import net_utils 22 | 23 | 24 | def test_resolve_hostname(monkeypatch): 25 | invalid_ip = "192.1.1" 26 | resolvable_ip = "192.168.0.1" 27 | unresolvable_ip1 = "192.168.1.1" 28 | unresolvable_ip2 = "192.168.1.2" 29 | unresolvable_ip3 = "192.168.1.3" 30 | 31 | def mock_gethostbyaddr(addr): 32 | if addr == resolvable_ip: 33 | return "unittestServer", None, None 34 | elif addr == unresolvable_ip1: 35 | raise socket.gaierror() 36 | elif addr == unresolvable_ip2: 37 | raise socket.herror() 38 | else: 39 | raise socket.timeout() 40 | 41 | monkeypatch.setattr(socket, "gethostbyaddr", mock_gethostbyaddr) 42 | 43 | with pytest.raises(ValueError): 44 | net_utils.resolve_hostname(invalid_ip) 45 | with pytest.raises(ValueError): 46 | assert net_utils.resolve_hostname(1234567) 47 | assert net_utils.resolve_hostname(resolvable_ip) 48 | assert net_utils.resolve_hostname(unresolvable_ip1) is None 49 | assert net_utils.resolve_hostname(unresolvable_ip2) is None 50 | assert net_utils.resolve_hostname(unresolvable_ip3) is None 51 | 52 | 53 | @pytest.mark.parametrize( 54 | "hostname,expected_result", 55 | [ 56 | ("splunk", True), 57 | ("splunk.", True), 58 | ("splunk.com", True), 59 | ("localhost", True), 60 | ("::1", True), 61 | ("", False), 62 | ("localhost:8000", False), 63 | ("http://localhost:8000", False), 64 | ("a" * 999, False), 65 | ], 66 | ) 67 | def test_is_valid_hostname(hostname, expected_result): 68 | assert net_utils.is_valid_hostname(hostname) is expected_result 69 | 70 | 71 | @pytest.mark.parametrize( 72 | "port,expected_result", 73 | [ 74 | ("0.0", False), 75 | (0, False), 76 | ("0", False), 77 | ("65536", False), 78 | (65536, False), 79 | ("1", True), 80 | (1, True), 81 | (8080, True), 82 | ("8080", True), 83 | ("0808", True), 84 | ("65535", True), 85 | (65535, True), 86 | ], 87 | ) 88 | def test_is_valid_port(port, expected_result): 89 | assert net_utils.is_valid_port(port) is expected_result 90 | 91 | 92 | @pytest.mark.parametrize( 93 | "scheme,expected_result", 94 | [ 95 | ("http", True), 96 | ("https", True), 97 | ("HTTP", True), 98 | ("HTTPS", True), 99 | ("HTTp", True), 100 | ("non-http", False), 101 | ], 102 | ) 103 | def test_is_valid_scheme(scheme, expected_result): 104 | assert net_utils.is_valid_scheme(scheme) is expected_result 105 | 106 | 107 | def test_validate_scheme_host_port(): 108 | net_utils.validate_scheme_host_port("http", "localhost", 8080) 109 | net_utils.validate_scheme_host_port("https", "::1", 8089) 110 | with pytest.raises(ValueError): 111 | net_utils.validate_scheme_host_port("scheme", "localhost:8000", 8080) 112 | with pytest.raises(ValueError): 113 | net_utils.validate_scheme_host_port("http", "localhost:8000", 8080) 114 | with pytest.raises(ValueError): 115 | net_utils.validate_scheme_host_port("http", "localhost", 99999) 116 | -------------------------------------------------------------------------------- /tests/unit/test_orphan_process_monitor.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import os 18 | import random 19 | import time 20 | 21 | from solnlib import orphan_process_monitor as opm 22 | 23 | 24 | def _mock_getppid(): 25 | return random.randint(1, 65535) 26 | 27 | 28 | class TestOrphanProcessChecker: 29 | def setup(self): 30 | self._called = False 31 | 32 | def test_is_orphan(self, monkeypatch): 33 | monkeypatch.setattr(os, "getppid", _mock_getppid) 34 | 35 | def orphan_callback(): 36 | self._called = True 37 | 38 | checker = opm.OrphanProcessChecker(callback=orphan_callback) 39 | res = checker.is_orphan() 40 | assert res 41 | res = checker.check_orphan() 42 | assert res 43 | assert self._called 44 | 45 | 46 | class TestOrphanProcessMonitor: 47 | def setup(self): 48 | self._called = False 49 | 50 | def test_monitor(self, monkeypatch): 51 | monkeypatch.setattr(os, "getppid", _mock_getppid) 52 | 53 | def orphan_callback(): 54 | self._called = True 55 | 56 | monitor = opm.OrphanProcessMonitor(callback=orphan_callback) 57 | monitor.start() 58 | 59 | time.sleep(1) 60 | assert self._called 61 | 62 | monitor.stop() 63 | -------------------------------------------------------------------------------- /tests/unit/test_splunk_rest_client.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | import os 17 | from unittest import mock 18 | 19 | import pytest 20 | from solnlib.splunk_rest_client import MAX_REQUEST_RETRIES 21 | 22 | from requests.exceptions import ConnectionError 23 | from solnlib import splunk_rest_client 24 | from solnlib.splunk_rest_client import SplunkRestClient 25 | 26 | 27 | @mock.patch.dict(os.environ, {"SPLUNK_HOME": "/opt/splunk"}, clear=True) 28 | @mock.patch("solnlib.splunk_rest_client.get_splunkd_access_info") 29 | def test_init_with_only_required_fields_when_splunk_env(mock_get_splunkd_access_info): 30 | mock_get_splunkd_access_info.return_value = "https", "localhost", 8089 31 | splunk_rest_client.SplunkRestClient( 32 | "session_key", 33 | "app", 34 | "owner", 35 | ) 36 | 37 | 38 | @mock.patch.dict(os.environ, {"SPLUNK_HOME": "/opt/splunk"}, clear=True) 39 | @mock.patch("solnlib.splunk_rest_client.get_splunkd_access_info") 40 | def test_init_with_only_host_when_splunk_env(mock_get_splunkd_access_info): 41 | mock_get_splunkd_access_info.return_value = "https", "localhost", 8089 42 | splunk_rest_client.SplunkRestClient("session_key", "app", "owner", host="localhost") 43 | 44 | 45 | def test_init_with_only_required_fields_when_not_in_splunk_env(): 46 | with pytest.raises(ValueError): 47 | splunk_rest_client.SplunkRestClient( 48 | "session_key", 49 | "app", 50 | "owner", 51 | ) 52 | 53 | 54 | def test_init_with_only_host_and_port(): 55 | with pytest.raises(ValueError): 56 | splunk_rest_client.SplunkRestClient( 57 | "session_key", 58 | "app", 59 | "nobody", 60 | host="localhost", 61 | port=8089, 62 | ) 63 | 64 | 65 | def test_init_with_all_fields(): 66 | splunk_rest_client.SplunkRestClient( 67 | "session_key", 68 | "app", 69 | "nobody", 70 | scheme="https", 71 | host="localhost", 72 | port=8089, 73 | ) 74 | 75 | 76 | def test_init_with_invalid_port(): 77 | with pytest.raises(ValueError): 78 | splunk_rest_client.SplunkRestClient( 79 | "session_key", 80 | "app", 81 | "nobody", 82 | scheme="https", 83 | host="localhost", 84 | port=99999, 85 | ) 86 | 87 | 88 | @mock.patch.dict(os.environ, {"SPLUNK_HOME": "/opt/splunk"}, clear=True) 89 | @mock.patch("solnlib.splunk_rest_client.get_splunkd_access_info") 90 | @mock.patch("http.client.HTTPResponse") 91 | @mock.patch("urllib3.HTTPConnectionPool._make_request") 92 | def test_request_retry(http_conn_pool, http_resp, mock_get_splunkd_access_info): 93 | mock_get_splunkd_access_info.return_value = "https", "localhost", 8089 94 | session_key = "123" 95 | context = {"pool_connections": 5} 96 | rest_client = SplunkRestClient("msg_name_1", session_key, "_", **context) 97 | 98 | mock_resp = http_resp() 99 | mock_resp.status = 200 100 | mock_resp.reason = "TEST OK" 101 | 102 | side_effects = [ConnectionError(), ConnectionError(), ConnectionError(), mock_resp] 103 | http_conn_pool.side_effect = side_effects 104 | res = rest_client.get("test") 105 | assert http_conn_pool.call_count == len(side_effects) 106 | assert res.reason == mock_resp.reason 107 | 108 | side_effects = [ConnectionError()] * (MAX_REQUEST_RETRIES + 1) + [mock_resp] 109 | http_conn_pool.side_effect = side_effects 110 | with pytest.raises(ConnectionError): 111 | rest_client.get("test") 112 | -------------------------------------------------------------------------------- /tests/unit/test_splunkenv.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import os 18 | from unittest import mock 19 | 20 | import common 21 | import pytest 22 | 23 | from solnlib import splunkenv 24 | 25 | 26 | def test_splunkhome_path(monkeypatch): 27 | common.mock_splunkhome(monkeypatch) 28 | 29 | splunkhome_path = splunkenv.make_splunkhome_path(["etc", "apps"]) 30 | assert splunkhome_path == os.environ["SPLUNK_HOME"] + "etc/apps" 31 | 32 | 33 | def test_get_splunk_host_info(monkeypatch): 34 | common.mock_splunkhome(monkeypatch) 35 | common.mock_gethostname(monkeypatch) 36 | 37 | server_name, host_name = splunkenv.get_splunk_host_info() 38 | assert server_name == "unittestServer" 39 | assert host_name == "unittestServer" 40 | 41 | 42 | def test_splunk_bin(monkeypatch): 43 | common.mock_splunkhome(monkeypatch) 44 | 45 | splunk_bin = splunkenv.get_splunk_bin() 46 | assert splunk_bin in ( 47 | os.environ["SPLUNK_HOME"] + "bin/splunk", 48 | os.environ["SPLUNK_HOME"] + "bin/splunk.exe", 49 | ) 50 | 51 | 52 | @mock.patch.object(splunkenv, "get_conf_key_value") 53 | @pytest.mark.parametrize( 54 | "enable_splunkd_ssl,mgmt_host_port,expected_scheme,expected_host,expected_port", 55 | [ 56 | ( 57 | "true", 58 | "127.0.0.1:8089", 59 | "https", 60 | "127.0.0.1", 61 | 8089, 62 | ), 63 | ( 64 | "true", 65 | "localhost:8089", 66 | "https", 67 | "localhost", 68 | 8089, 69 | ), 70 | ( 71 | "false", 72 | "127.0.0.1:8089", 73 | "http", 74 | "127.0.0.1", 75 | 8089, 76 | ), 77 | ( 78 | "false", 79 | "localhost:8089", 80 | "http", 81 | "localhost", 82 | 8089, 83 | ), 84 | ( 85 | "false", 86 | "1.2.3.4:5678", 87 | "http", 88 | "1.2.3.4", 89 | 5678, 90 | ), 91 | ( 92 | "true", 93 | "[::1]:8089", 94 | "https", 95 | "[::1]", 96 | 8089, 97 | ), 98 | ( 99 | "false", 100 | "[::1]:8089", 101 | "http", 102 | "[::1]", 103 | 8089, 104 | ), 105 | ], 106 | ) 107 | def test_get_splunkd_access_info( 108 | mock_get_conf_key_value, 109 | enable_splunkd_ssl, 110 | mgmt_host_port, 111 | expected_scheme, 112 | expected_host, 113 | expected_port, 114 | ): 115 | mock_get_conf_key_value.side_effect = [ 116 | enable_splunkd_ssl, 117 | mgmt_host_port, 118 | ] 119 | 120 | scheme, host, port = splunkenv.get_splunkd_access_info() 121 | 122 | assert expected_scheme == scheme 123 | assert expected_host == host 124 | assert expected_port == port 125 | 126 | 127 | def test_splunkd_uri(monkeypatch): 128 | common.mock_splunkhome(monkeypatch) 129 | 130 | uri = splunkenv.get_splunkd_uri() 131 | assert uri == "https://127.0.0.1:8089" 132 | 133 | monkeypatch.setenv("SPLUNK_BINDIP", "10.0.0.2:7080") 134 | uri = splunkenv.get_splunkd_uri() 135 | assert uri == "https://10.0.0.2:8089" 136 | 137 | monkeypatch.setenv("SPLUNK_BINDIP", "10.0.0.3") 138 | uri = splunkenv.get_splunkd_uri() 139 | assert uri == "https://10.0.0.3:8089" 140 | 141 | monkeypatch.setenv("SPLUNKD_URI", "https://10.0.0.1:8089") 142 | uri = splunkenv.get_splunkd_uri() 143 | assert uri == "https://10.0.0.1:8089" 144 | -------------------------------------------------------------------------------- /tests/unit/test_time_parser.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | from unittest import mock 17 | 18 | import common 19 | import pytest 20 | from splunklib import binding 21 | 22 | from solnlib import time_parser 23 | 24 | 25 | @mock.patch("solnlib.splunk_rest_client.SplunkRestClient") 26 | def test_to_seconds_raises_error_when_rest_client_responds_with_503_status( 27 | mock_splunk_rest_client_class, 28 | ): 29 | mock_splunk_rest_client_object = mock_splunk_rest_client_class.return_value 30 | mock_splunk_rest_client_object.get.side_effect = binding.HTTPError( 31 | common.make_response_record(b"", status=503) 32 | ) 33 | tp = time_parser.TimeParser("session_key") 34 | with pytest.raises(binding.HTTPError): 35 | tp.to_seconds("2011-07-06T21:54:23.000-07:00") 36 | 37 | 38 | @mock.patch("solnlib.splunk_rest_client.SplunkRestClient") 39 | def test_to_utc_raises_error_when_rest_client_responds_with_503_status( 40 | mock_splunk_rest_client_class, 41 | ): 42 | mock_splunk_rest_client_object = mock_splunk_rest_client_class.return_value 43 | mock_splunk_rest_client_object.get.side_effect = binding.HTTPError( 44 | common.make_response_record(b"", status=503) 45 | ) 46 | tp = time_parser.TimeParser("session_key") 47 | with pytest.raises(binding.HTTPError): 48 | tp.to_utc("2011-07-06T21:54:23.000-07:00") 49 | 50 | 51 | @mock.patch("solnlib.splunk_rest_client.SplunkRestClient") 52 | def test_to_local_raises_error_when_rest_client_responds_with_503_status( 53 | mock_splunk_rest_client_class, 54 | ): 55 | mock_splunk_rest_client_object = mock_splunk_rest_client_class.return_value 56 | mock_splunk_rest_client_object.get.side_effect = binding.HTTPError( 57 | common.make_response_record(b"", status=503) 58 | ) 59 | tp = time_parser.TimeParser("session_key") 60 | with pytest.raises(binding.HTTPError): 61 | tp.to_local("2011-07-06T21:54:23.000-07:00") 62 | -------------------------------------------------------------------------------- /tests/unit/test_timer_queue.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import math 18 | import numbers 19 | import random 20 | import time 21 | 22 | from solnlib import timer_queue 23 | 24 | n = 100 25 | t = 5 26 | 27 | # [start, end, interval, count] 28 | count = [] 29 | for i in range(n): 30 | count.append([0, 0, 0, 0]) 31 | 32 | 33 | def fun(i, interval): 34 | count[i][0] = time.time() + interval 35 | count[i][2] = interval 36 | 37 | def do_fun(): 38 | count[i][1] = time.time() 39 | count[i][-1] += 1 40 | 41 | return do_fun 42 | 43 | 44 | def test_timer_queue(): 45 | tq = timer_queue.TimerQueue() 46 | tq.start() 47 | timers = [] 48 | r = random.Random() 49 | for i in range(n): 50 | interval = r.randint(1, t) 51 | timer = tq.add_timer(fun(i, interval), time.time() + interval, interval) 52 | timers.append(timer) 53 | 54 | time.sleep(t * 2) 55 | tq.stop() 56 | 57 | for start, end, interval, c in count: 58 | if isinstance((end - start), numbers.Integral) and isinstance( 59 | interval, numbers.Integral 60 | ): 61 | diff = int(math.fabs(c - (end - start) // interval - 1)) 62 | else: 63 | diff = int(math.fabs(c - (end - start) / interval - 1)) 64 | assert 0 <= diff <= 1 65 | -------------------------------------------------------------------------------- /tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Splunk Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import datetime 18 | import logging 19 | import os 20 | import signal 21 | import time 22 | from unittest import mock 23 | 24 | import pytest 25 | 26 | from solnlib import utils 27 | 28 | 29 | def test_handle_teardown_signals(monkeypatch): 30 | test_handle_teardown_signals.should_teardown = False 31 | 32 | def sig_handler(signum, frame): 33 | test_handle_teardown_signals.should_teardown = True 34 | 35 | utils.handle_teardown_signals(sig_handler) 36 | os.kill(os.getpid(), signal.SIGINT) 37 | assert test_handle_teardown_signals.should_teardown 38 | 39 | 40 | def test_datatime_to_seconds(monkeypatch): 41 | total_seconds = 1456755646.0 42 | dt = datetime.datetime(2016, 2, 29, 14, 20, 46, 0) 43 | assert total_seconds == utils.datetime_to_seconds(dt) 44 | 45 | 46 | def test_is_false(monkeypatch): 47 | for val in ("0", "FALSE", "F", "N", "NO", "NONE", "", None): 48 | assert utils.is_false(val) 49 | 50 | for val in ("1", "TRUE", "T", "Y", "YES"): 51 | assert not utils.is_false(val) 52 | 53 | for val in ("00", "FF", "NN", "NONO", "434324"): 54 | assert not utils.is_false(val) 55 | 56 | 57 | def test_is_true(monkeypatch): 58 | for val in ("1", "TRUE", "T", "Y", "YES"): 59 | assert utils.is_true(val) 60 | 61 | for val in ("0", "FALSE", "F", "N", "NO", "NONE", "", None): 62 | assert not utils.is_true(val) 63 | 64 | for val in ("00", "FF", "NN", "NONO", "434324"): 65 | assert not utils.is_true(val) 66 | 67 | 68 | def test_retry(monkeypatch): 69 | def _old_func(): 70 | raise ValueError("Exception for test.") 71 | 72 | _new_func = utils.retry(retries=1)(_old_func) 73 | with pytest.raises(ValueError): 74 | _new_func() 75 | _new_func = utils.retry(retries=1, exceptions=[TypeError])(_old_func) 76 | with pytest.raises(ValueError): 77 | _new_func() 78 | 79 | mock_sleep_time = [0] 80 | 81 | def mock_sleep(seconds): 82 | mock_sleep_time[0] += seconds 83 | 84 | monkeypatch.setattr(time, "sleep", mock_sleep) 85 | 86 | retries = 3 87 | tried = [0] 88 | 89 | @utils.retry(retries=retries, reraise=False) 90 | def mock_func(): 91 | tried[0] += 1 92 | raise ValueError() 93 | 94 | mock_func() 95 | assert tried[0] == retries + 1 96 | assert mock_sleep_time[0] == sum(2**i for i in range(retries)) 97 | 98 | record = [0, 0] 99 | 100 | def mock_warning(msg, *args, **kwargs): 101 | record[0] += 1 102 | 103 | def mock_error(msg, *args, **kwargs): 104 | record[1] += 1 105 | 106 | monkeypatch.setattr(logging, "warning", mock_warning) 107 | monkeypatch.setattr(logging, "error", mock_error) 108 | mock_func() 109 | 110 | assert record[0] == 4 111 | assert record[1] == 0 112 | 113 | 114 | @pytest.mark.parametrize( 115 | "url,expected_scheme,expected_host,expected_port", 116 | [ 117 | ( 118 | "https://localhost:8089", 119 | "https", 120 | "localhost", 121 | 8089, 122 | ), 123 | ( 124 | "https://localhost:8089/", 125 | "https", 126 | "localhost", 127 | 8089, 128 | ), 129 | ( 130 | "https://localhost:8089/servicesNS/", 131 | "https", 132 | "localhost", 133 | 8089, 134 | ), 135 | ( 136 | "http://localhost:8089", 137 | "http", 138 | "localhost", 139 | 8089, 140 | ), 141 | ( 142 | "http://localhost:8089/", 143 | "http", 144 | "localhost", 145 | 8089, 146 | ), 147 | ( 148 | "http://localhost:8089/servicesNS/", 149 | "http", 150 | "localhost", 151 | 8089, 152 | ), 153 | ( 154 | "https://[::1]:8089", 155 | "https", 156 | "::1", 157 | 8089, 158 | ), 159 | ], 160 | ) 161 | def test_extract_http_scheme_host_port_when_success( 162 | url, expected_scheme, expected_host, expected_port 163 | ): 164 | scheme, host, port = utils.extract_http_scheme_host_port(url) 165 | 166 | assert expected_scheme == scheme 167 | assert expected_host == host 168 | assert expected_port == port 169 | 170 | 171 | def test_extract_http_scheme_host_port_when_invalid(): 172 | invalid = "localhost:8089" 173 | with pytest.raises(ValueError): 174 | _, _, _ = utils.extract_http_scheme_host_port(invalid) 175 | 176 | 177 | @mock.patch.dict(os.environ, {"SPLUNK_HOME": "/opt/splunk"}, clear=True) 178 | def test_remove_http_proxy_env_vars_preserves_non_http_env_vars(): 179 | utils.remove_http_proxy_env_vars() 180 | 181 | assert "/opt/splunk" == os.getenv("SPLUNK_HOME") 182 | 183 | 184 | @mock.patch.dict(os.environ, {"HTTP_PROXY": "proxy:80"}, clear=True) 185 | def test_remove_http_proxy_env_vars_removes_proxy_related_env_vars(): 186 | utils.remove_http_proxy_env_vars() 187 | 188 | assert None is os.getenv("HTTP_PROXY") 189 | 190 | 191 | @mock.patch.dict( 192 | os.environ, 193 | { 194 | "SPLUNK_HOME": "/opt/splunk", 195 | "HTTP_PROXY": "proxy", 196 | "https_proxy": "proxy", 197 | }, 198 | clear=True, 199 | ) 200 | def test_remove_http_proxy_env_vars(): 201 | utils.remove_http_proxy_env_vars() 202 | 203 | assert None is os.getenv("HTTP_PROXY") 204 | assert None is os.getenv("https_proxy") 205 | assert "/opt/splunk" == os.getenv("SPLUNK_HOME") 206 | --------------------------------------------------------------------------------