diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7b96286 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# Check http://editorconfig.org for more information +# This is the main config file for this project: +root = true + +[*] +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +insert_final_newline = true + +[*.py] +indent_size = 4 + +[*.{bes,bes.mustache}] +# bes files are XML, but the `actionscript` tag text must use crlf +end_of_line = crlf +indent_style = tab +indent_size = 3 + +[*.{bat,cmd}] +end_of_line = crlf diff --git a/.flake8 b/.flake8 index d1de6cc..79e952b 100644 --- a/.flake8 +++ b/.flake8 @@ -18,3 +18,4 @@ ignore = E127, E128, E203, E265, E266, E402, E501, E722, P207, P208, W503 exclude = .git __pycache__ + examples diff --git a/.gitconfig b/.gitconfig new file mode 100644 index 0000000..17948d3 --- /dev/null +++ b/.gitconfig @@ -0,0 +1,6 @@ +[core] + hideDotFiles = true +[rebase] + autoStash = true +[pull] + rebase = true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..24d901f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Set update schedule for GitHub Actions +version: 2 + +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + # Add assignees + assignees: + - "jgstew" diff --git a/.github/workflows/action-python-version b/.github/workflows/action-python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.github/workflows/action-python-version @@ -0,0 +1 @@ +3.12 diff --git a/.github/workflows/black.yaml b/.github/workflows/black.yaml deleted file mode 100644 index b8b38b6..0000000 --- a/.github/workflows/black.yaml +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: black - -on: - push: - paths: - - "**.py" - - "requirements.txt" - - ".github/workflows/black.yaml" - pull_request: - paths: - - "**.py" - - "requirements.txt" - - ".github/workflows/black.yaml" - -jobs: - black: - name: runner / black formatter - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Run black formatter checks - # https://github.com/rickstaa/action-black - uses: rickstaa/action-black@v1 - id: action_black - with: - black_args: ". --check --diff --color" diff --git a/.github/workflows/build_baseline_plugin_sync.yaml b/.github/workflows/build_baseline_plugin_sync.yaml new file mode 100644 index 0000000..e85877e --- /dev/null +++ b/.github/workflows/build_baseline_plugin_sync.yaml @@ -0,0 +1,213 @@ +--- +name: build_baseline_plugin_sync + +on: + push: + paths: + - ".github/workflows/build_baseline_plugin_sync.yaml" + - "examples/baseline_sync_plugin.py" + branches: + - master + +env: + script_name: baseline_sync_plugin + +jobs: + build_baseline_plugin_sync: + # needs: build_baseline_plugin_rhel + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-2022, windows-11-arm, ubuntu-24.04-arm] + # https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: update pip + run: python -m pip install --upgrade pip + + - name: Install build tools + run: pip install --upgrade setuptools wheel build pyinstaller besapi + + # - name: Install requirements + # shell: bash + # run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Read VERSION file + id: getversion + shell: bash + run: echo "$(python ./setup.py --version)" + + - name: Run Tests - Source + run: python tests/tests.py + + - name: Test pyinstaller build ${{ env.script_name }} + run: pyinstaller --clean --noconfirm --collect-all besapi ./examples/${{ env.script_name }}.py + + - name: set executable + if: ${{ runner.os == 'Linux' }} + run: chmod +x ./dist/${{ env.script_name }}/${{ env.script_name }} + + - name: test plugin help + run: ./dist/${{ env.script_name }}/${{ env.script_name }} --help + + # - name: copy example config + # shell: bash + # run: cp ./examples/baseline_plugin.config.yaml ./dist/baseline_plugin/baseline_plugin.config.yaml + + - name: create dist zip linux + if: ${{ runner.os == 'Linux' }} + shell: bash + run: cd dist/${{ env.script_name }} && zip -r -o ${{ env.script_name }}_dist_`uname -s`-`uname -m`.zip * + + - name: create dist zip win + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + cd dist/${{ env.script_name }} && Compress-Archive -Path * -DestinationPath ${{ env.script_name }}_dist_Windows-$( ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLower() ).zip + + - name: get zip name linux + if: ${{ runner.os == 'Linux' }} + id: get_zip_name_linux + shell: bash + run: echo "ZIP_NAME=${{ env.script_name }}_dist_`uname -s`-`uname -m`" >> $GITHUB_ENV + + - name: get zip name windows + if: ${{ runner.os == 'Windows' }} + id: get_zip_name_win + shell: pwsh + run: | + $arch = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLower() + $outputString = "ZIP_NAME=${{ env.script_name }}_dist_Windows-${arch}" + Add-Content -Path $env:GITHUB_ENV -Value $outputString + + - name: upload built ${{ env.script_name }} + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ZIP_NAME }} + path: dist/${{ env.script_name }}/${{ env.script_name }}_dist_*.zip + if-no-files-found: error + + build_baseline_plugin_rhel: + runs-on: ubuntu-24.04 + container: + image: "redhat/ubi8:latest" + steps: + - uses: actions/checkout@v4 + + - name: Install Python3 and pyinstaller deps + run: dnf --assumeyes install python3.12 python3.12-pip binutils zip + + - name: get python version + run: python3.12 --version + + - name: update pip + run: python3.12 -m pip install --upgrade pip + + - name: Install build tools + run: python3.12 -m pip install --upgrade setuptools wheel build pyinstaller besapi + + # - name: Install requirements + # shell: bash + # run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Read VERSION file + id: getversion + shell: bash + run: echo "$(python3.12 ./setup.py --version)" + + - name: Run Tests - Source + run: python3.12 tests/tests.py + + - name: Test pyinstaller build baseline_sync_plugin + run: pyinstaller --clean --noconfirm --collect-all besapi ./examples/${{ env.script_name }}.py + + - name: set executable + if: ${{ runner.os == 'Linux' }} + run: chmod +x ./dist/baseline_sync_plugin/baseline_sync_plugin + + - name: test plugin help + run: ./dist/baseline_sync_plugin/baseline_sync_plugin --help + + # - name: copy example config + # shell: bash + # run: cp ./examples/baseline_plugin.config.yaml ./dist/baseline_plugin/baseline_plugin.config.yaml + + - name: Install zip command + run: dnf --assumeyes install zip + + - name: create dist zip linux + if: ${{ runner.os == 'Linux' }} + shell: bash + run: cd dist/${{ env.script_name }} && zip -r -o ${{ env.script_name }}_dist_`uname -s`-`uname -m`.zip * + + - name: create dist zip win + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + cd dist/${{ env.script_name }} && Compress-Archive -Path * -DestinationPath ${{ env.script_name }}_dist_Windows-$( ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLower() ).zip + + - name: get zip name linux + if: ${{ runner.os == 'Linux' }} + id: get_zip_name_linux + shell: bash + run: echo "ZIP_NAME=${{ env.script_name }}_dist_`uname -s`-`uname -m`" >> $GITHUB_ENV + + - name: get zip name windows + if: ${{ runner.os == 'Windows' }} + id: get_zip_name_win + shell: pwsh + run: | + $arch = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLower() + $outputString = "ZIP_NAME=${{ env.script_name }}_dist_Windows-${arch}" + Add-Content -Path $env:GITHUB_ENV -Value $outputString + + - name: upload built ${{ env.script_name }} + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ZIP_NAME }} + path: dist/${{ env.script_name }}/${{ env.script_name }}_dist_*.zip + if-no-files-found: error + + test_baseline_plugin_rhel: + needs: build_baseline_plugin_rhel + runs-on: ubuntu-latest + strategy: + matrix: + container-image: + ["redhat/ubi8:latest", "redhat/ubi9:latest", "ubuntu:latest"] + container: + image: ${{ matrix.container-image }} + steps: + - name: Download ${{ env.script_name }} artifact + uses: actions/download-artifact@v4 + with: + name: ${{ env.script_name }}_dist_Linux-x86_64 + path: ./downloaded_artifact + + - name: List downloaded files + run: ls -l ./downloaded_artifact + + - name: Install unzip command + if: ${{ contains (matrix.container-image, 'redhat/ubi') }} + run: dnf --assumeyes install unzip + + - name: Install unzip command + if: ${{ contains (matrix.container-image, 'ubuntu') }} + run: apt-get update && apt-get install -y unzip + + # Example: Unzip and check contents + - name: Unzip artifact + run: unzip ./downloaded_artifact/${{ env.script_name }}_dist_Linux-x86_64.zip + + - name: List files + run: ls -l . + + - name: Test plugin help + run: ./${{ env.script_name }} --help diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f91c030..d4c32ae 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/flake8.yaml b/.github/workflows/flake8.yaml deleted file mode 100644 index 8c12047..0000000 --- a/.github/workflows/flake8.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: flake8 - -on: - push: - paths: - - "**.py" - - ".flake8" - - "requirements.txt" - - ".github/workflows/flake8.yaml" - pull_request: - paths: - - "**.py" - - ".flake8" - - "requirements.txt" - - ".github/workflows/flake8.yaml" - -jobs: - flake8: - name: Python Lint Flake8 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: "3.8" - - name: Install flake8 - run: pip install flake8 - - name: Install requirements - run: pip install -r requirements.txt - - name: Run flake8 - run: flake8 --show-source --statistics . diff --git a/.github/workflows/grammar-check.yaml b/.github/workflows/grammar-check.yaml new file mode 100644 index 0000000..dc56151 --- /dev/null +++ b/.github/workflows/grammar-check.yaml @@ -0,0 +1,26 @@ +--- +name: grammar-check + +on: workflow_dispatch + +jobs: + grammar-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: x86_64-unknown-linux-gnu + + - name: install harper grammar checker + run: cargo install --locked --git https://github.com/Automattic/harper.git --branch master --tag v0.23.0 harper-cli + + - name: run harper-cli config + run: harper-cli config + + - name: run harper-cli lint help + run: harper-cli lint --help + + - name: run harper grammar checker + run: harper-cli lint src/besapi/besapi.py diff --git a/.github/workflows/isort.yaml b/.github/workflows/isort.yaml deleted file mode 100644 index 3fb5ea7..0000000 --- a/.github/workflows/isort.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: isort - -on: - push: - paths: - - "**.py" - - ".isort.cfg" - - "requirements.txt" - - ".github/workflows/isort.yaml" - pull_request: - paths: - - "**.py" - - ".isort.cfg" - - "requirements.txt" - - ".github/workflows/isort.yaml" - -jobs: - isort: - name: runner / isort - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: "3.8" - - name: Install isort - run: pip install isort - # - name: Install requirements - # run: pip install -r requirements.txt - - name: Run isort - run: isort . --check --diff diff --git a/.github/workflows/misspell.yaml b/.github/workflows/misspell.yaml deleted file mode 100644 index c116120..0000000 --- a/.github/workflows/misspell.yaml +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: misspell - -on: [push, pull_request] - -jobs: - misspell: - name: runner / misspell - runs-on: ubuntu-latest - steps: - - name: Check out code. - uses: actions/checkout@v1 - - name: misspell - if: ${{ !env.ACT }} - uses: reviewdog/action-misspell@v1 - with: - github_token: ${{ secrets.github_token }} - locale: "US" - reporter: github-check # Change reporter. diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..57368a0 --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,30 @@ +--- +name: pre-commit + +on: + pull_request: + push: + branches: + - master + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Required to grab the full history for proper pre-commit checks + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version-file: ".github/workflows/action-python-version" + + - name: Run pre-commit + uses: pre-commit/action@v3.0.1 + with: + extra_args: >- + --color=always + --from-ref ${{ github.event.pull_request.base.sha || github.event.before }} + --to-ref ${{ github.event.pull_request.head.sha || github.sha }} + --hook-stage manual diff --git a/.github/workflows/tag_and_release.yaml b/.github/workflows/tag_and_release.yaml index fb55cb6..e6b608c 100644 --- a/.github/workflows/tag_and_release.yaml +++ b/.github/workflows/tag_and_release.yaml @@ -7,6 +7,7 @@ on: - master paths: - "src/besapi/__init__.py" + - "src/besapi/besapi.py" - ".github/workflows/tag_and_release.yaml" jobs: @@ -15,46 +16,77 @@ jobs: name: Tag and Release runs-on: ubuntu-latest steps: - - name: "Checkout source code" - uses: "actions/checkout@v1" + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-tags: true + + - name: git pull tags + run: git pull --tags + - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version-file: ".github/workflows/action-python-version" + + - name: Install requirements + run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Read VERSION file id: getversion - run: echo "::set-output name=version::$(python ./setup.py --version)" + run: echo "version=$(python ./setup.py --version | grep -o -e "[0-9]*\.[0-9]*\.[0-9]*" )" >> $GITHUB_OUTPUT + # only make release if there is NOT a git tag for this version - name: "Check: package version has corresponding git tag" # this will prevent this from doing anything when run through ACT - if: ${{ !env.ACT }} + if: ${{ !env.ACT }} && contains(steps.getversion.outputs.version, '.') id: tagged shell: bash - run: git show-ref --tags --verify --quiet -- "refs/tags/v${{ steps.getversion.outputs.version }}" && echo "::set-output name=tagged::0" || echo "::set-output name=tagged::1" + run: | + if git show-ref --tags --verify --quiet -- "refs/tags/v${{ steps.getversion.outputs.version }}"; then + echo "tagged=0" >> $GITHUB_OUTPUT + else + echo "tagged=1" >> $GITHUB_OUTPUT + fi + + - name: echo tagged value + run: | + echo ${{ steps.tagged.outputs.tagged }} + + - name: Run pre-commit all-files + if: steps.tagged.outputs.tagged == 1 + uses: pre-commit/action@v3.0.1 + with: + extra_args: >- + --color=always + --all-files + --hook-stage manual + # wait for all other tests to succeed # what if no other tests? - name: Wait for tests to succeed if: steps.tagged.outputs.tagged == 1 - uses: lewagon/wait-on-check-action@v0.2 + uses: lewagon/wait-on-check-action@v1.3.1 with: ref: master running-workflow-name: "Tag and Release" repo-token: ${{ secrets.GITHUB_TOKEN }} wait-interval: 30 - - name: Install requirements - if: steps.tagged.outputs.tagged == 1 - run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Install build tools if: steps.tagged.outputs.tagged == 1 run: pip install setuptools wheel build + - name: Run build if: steps.tagged.outputs.tagged == 1 run: python3 -m build + - name: Get Wheel File if: steps.tagged.outputs.tagged == 1 id: getwheelfile shell: bash - run: echo "::set-output name=wheelfile::$(find "dist" -type f -name "*.whl")" + run: echo "wheelfile=$(find dist -type f -name '*.whl')" >> $GITHUB_OUTPUT + - name: Automatically create github release if: steps.tagged.outputs.tagged == 1 uses: "marvinpinto/action-automatic-releases@latest" @@ -64,8 +96,9 @@ jobs: prerelease: false files: | ${{ steps.getwheelfile.outputs.wheelfile }} + - name: Publish distribution to PyPI if: steps.tagged.outputs.tagged == 1 - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/test_build.yaml b/.github/workflows/test_build.yaml index 2d973a9..dd8066a 100644 --- a/.github/workflows/test_build.yaml +++ b/.github/workflows/test_build.yaml @@ -4,16 +4,20 @@ name: test_build on: push: paths: - - "**.py" + - "src/**.py" + - "tests/**.py" - "setup.cfg" - "MANIFEST.in" - "pyproject.toml" - "requirements.txt" - ".github/workflows/test_build.yaml" - ".github/workflows/tag_and_release.yaml" + branches: + - master pull_request: paths: - - "**.py" + - "src/**.py" + - "tests/**.py" - "setup.cfg" - "MANIFEST.in" - "pyproject.toml" @@ -26,42 +30,76 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, windows-latest, macos-13, ubuntu-24.04-arm] # https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json - python-version: ["3.6", "3"] + python-version: ["3.9", "3"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Install build tools - run: pip install setuptools wheel build pyinstaller + run: pip install setuptools wheel build pyinstaller pytest + - name: Install requirements + shell: bash run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Read VERSION file + id: getversion + shell: bash + run: echo "$(python ./setup.py --version)" + - name: Run Tests - Source run: python tests/tests.py + + - name: Run PyTest + run: python -m pytest + + - name: Test invoke directly src/bescli/bescli.py + run: python src/bescli/bescli.py ls logout clear error_count version exit + + - name: Test invoke directly src/besapi/besapi.py + run: python src/besapi/besapi.py ls logout clear error_count version exit + + - name: Test invoke directly -m besapi + run: cd src && python -m besapi ls logout clear error_count version exit + + - name: Test invoke directly -m bescli + run: cd src && python -m bescli ls logout clear error_count version exit + - name: Run build run: python3 -m build + - name: Get Wheel File Path id: getwheelfile shell: bash - run: echo "::set-output name=wheelfile::$(find "dist" -type f -name "*.whl")" + run: echo "wheelfile=$(find "dist" -type f -name "*.whl")" >> $GITHUB_OUTPUT + - name: Test pip install of wheel shell: bash run: pip install $(find "dist" -type f -name "*.whl") + - name: Test python import besapi shell: bash - run: python -c "import besapi" + run: python -c "import besapi;print(besapi.besapi.__version__)" + - name: Test python import bescli shell: bash - run: python -c "import bescli" + run: python -c "import bescli;bescli.bescli.BESCLInterface().do_version()" + - name: Test python bescli shell: bash run: python -m bescli ls logout clear error_count version exit + - name: Run Tests - Pip run: python tests/tests.py --test_pip - - name: Test pyinstaller + + - name: Test pyinstaller build run: pyinstaller --clean --collect-all besapi --onefile ./src/bescli/bescli.py + - name: Test bescli binary run: ./dist/bescli ls logout clear error_count version exit diff --git a/.github/workflows/test_build_baseline_plugin.yaml b/.github/workflows/test_build_baseline_plugin.yaml new file mode 100644 index 0000000..ff86946 --- /dev/null +++ b/.github/workflows/test_build_baseline_plugin.yaml @@ -0,0 +1,210 @@ +--- +name: test_build_baseline_plugin + +on: + push: + paths: + - ".github/workflows/test_build_baseline_plugin.yaml" + - "examples/baseline_plugin.py" + branches: + - master + +jobs: + test_build_baseline_plugin: + # needs: build_baseline_plugin_rhel + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-2022, windows-11-arm, ubuntu-24.04-arm] + # https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: update pip + run: python -m pip install --upgrade pip + + - name: Install build tools + run: pip install --upgrade setuptools wheel build pyinstaller ruamel.yaml besapi + + # - name: Install requirements + # shell: bash + # run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Read VERSION file + id: getversion + shell: bash + run: echo "$(python ./setup.py --version)" + + - name: Run Tests - Source + run: python tests/tests.py + + - name: Test pyinstaller build baseline_plugin + run: pyinstaller --clean --noconfirm --collect-all besapi --collect-all ruamel.yaml ./examples/baseline_plugin.py + + - name: set executable + if: ${{ runner.os == 'Linux' }} + run: chmod +x ./dist/baseline_plugin/baseline_plugin + + - name: test plugin help + run: ./dist/baseline_plugin/baseline_plugin --help + + - name: copy example config + shell: bash + run: cp ./examples/baseline_plugin.config.yaml ./dist/baseline_plugin/baseline_plugin.config.yaml + + - name: create dist zip linux + if: ${{ runner.os == 'Linux' }} + shell: bash + run: cd dist/baseline_plugin && zip -r -o baseline_plugin_dist_`uname -s`-`uname -m`.zip * + + - name: create dist zip win + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + cd dist/baseline_plugin && Compress-Archive -Path * -DestinationPath baseline_plugin_dist_Windows-$( ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLower() ).zip + + - name: get zip name linux + if: ${{ runner.os == 'Linux' }} + id: get_zip_name_linux + shell: bash + run: echo "ZIP_NAME=baseline_plugin_dist_`uname -s`-`uname -m`" >> $GITHUB_ENV + + - name: get zip name windows + if: ${{ runner.os == 'Windows' }} + id: get_zip_name_win + shell: pwsh + run: | + $arch = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLower() + $outputString = "ZIP_NAME=baseline_plugin_dist_Windows-${arch}" + Add-Content -Path $env:GITHUB_ENV -Value $outputString + + - name: upload built baseline_plugin + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ZIP_NAME }} + path: dist/baseline_plugin/baseline_plugin_dist_*.zip + if-no-files-found: error + + build_baseline_plugin_rhel: + runs-on: ubuntu-24.04 + container: + image: "redhat/ubi8:latest" + steps: + - uses: actions/checkout@v4 + + - name: Install Python3 and pyinstaller deps + run: dnf --assumeyes install python3.12 python3.12-pip binutils zip + + - name: get python version + run: python3.12 --version + + - name: update pip + run: python3.12 -m pip install --upgrade pip + + - name: Install build tools + run: python3.12 -m pip install --upgrade setuptools wheel build pyinstaller ruamel.yaml besapi + + # - name: Install requirements + # shell: bash + # run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Read VERSION file + id: getversion + shell: bash + run: echo "$(python3.12 ./setup.py --version)" + + - name: Run Tests - Source + run: python3.12 tests/tests.py + + - name: Test pyinstaller build baseline_plugin + run: pyinstaller --clean --noconfirm --collect-all besapi --collect-all ruamel.yaml ./examples/baseline_plugin.py + + - name: set executable + if: ${{ runner.os == 'Linux' }} + run: chmod +x ./dist/baseline_plugin/baseline_plugin + + - name: test plugin help + run: ./dist/baseline_plugin/baseline_plugin --help + + - name: copy example config + shell: bash + run: cp ./examples/baseline_plugin.config.yaml ./dist/baseline_plugin/baseline_plugin.config.yaml + + - name: Install zip command + run: dnf --assumeyes install zip + + - name: create dist zip linux + if: ${{ runner.os == 'Linux' }} + shell: bash + run: cd dist/baseline_plugin && zip -r -o baseline_plugin_dist_`uname -s`-`uname -m`.zip * + + - name: create dist zip win + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + cd dist/baseline_plugin && Compress-Archive -Path * -DestinationPath baseline_plugin_dist_Windows-$( ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLower() ).zip + + - name: get zip name linux + if: ${{ runner.os == 'Linux' }} + id: get_zip_name_linux + shell: bash + run: echo "ZIP_NAME=baseline_plugin_dist_`uname -s`-`uname -m`" >> $GITHUB_ENV + + - name: get zip name windows + if: ${{ runner.os == 'Windows' }} + id: get_zip_name_win + shell: pwsh + run: | + $arch = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLower() + $outputString = "ZIP_NAME=baseline_plugin_dist_Windows-${arch}" + Add-Content -Path $env:GITHUB_ENV -Value $outputString + + - name: upload built baseline_plugin + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ZIP_NAME }} + path: dist/baseline_plugin/baseline_plugin_dist_*.zip + if-no-files-found: error + + test_baseline_plugin_rhel: + needs: build_baseline_plugin_rhel + runs-on: ubuntu-latest + strategy: + matrix: + container-image: + ["redhat/ubi8:latest", "redhat/ubi9:latest", "ubuntu:latest"] + container: + image: ${{ matrix.container-image }} + steps: + - name: Download baseline_plugin artifact + uses: actions/download-artifact@v4 + with: + name: baseline_plugin_dist_Linux-x86_64 + path: ./downloaded_artifact + + - name: List downloaded files + run: ls -l ./downloaded_artifact + + - name: Install unzip command + if: ${{ contains (matrix.container-image, 'redhat/ubi') }} + run: dnf --assumeyes install unzip + + - name: Install unzip command + if: ${{ contains (matrix.container-image, 'ubuntu') }} + run: apt-get update && apt-get install -y unzip + + # Example: Unzip and check contents + - name: Unzip artifact + run: unzip ./downloaded_artifact/baseline_plugin_dist_Linux-x86_64.zip + + - name: List files + run: ls -l . + + - name: Test plugin help + run: ./baseline_plugin --help diff --git a/.github/workflows/yamllint.yaml b/.github/workflows/yamllint.yaml deleted file mode 100644 index 28557d0..0000000 --- a/.github/workflows/yamllint.yaml +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: yamllint - -on: - push: - paths: - - "**.yaml" - - "**.yml" - pull_request: - paths: - - "**.yaml" - - "**.yml" - -jobs: - yamllint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.8 - - - name: Install yamllint - run: pip install yamllint - - - name: Lint YAML files - run: yamllint . -f parsable diff --git a/.gitignore b/.gitignore index 71bc149..91247af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ besapi.conf +tmp/ + +examples/session_relevance_query_from_file_output.json + .DS_Store # Byte-compiled / optimized / DLL files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 272f339..5aecef3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,11 +2,12 @@ # run on only items staged in git: pre-commit # automatically run on commit: pre-commit install # check all files in repo: pre-commit run --all-files +# check all files manual stage: pre-commit run --all-files --hook-stage manual # update all checks to latest: pre-commit autoupdate # https://github.com/pre-commit/pre-commit-hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.6.0 hooks: - id: check-yaml - id: check-json @@ -26,20 +27,170 @@ repos: - id: detect-private-key # - id: no-commit-to-branch # args: [--branch, main] + - repo: https://github.com/adrienverge/yamllint.git - rev: v1.26.3 + rev: v1.36.0 hooks: - id: yamllint args: [-c=.yamllint.yaml] + - repo: https://github.com/pre-commit/mirrors-isort rev: v5.10.1 hooks: - id: isort - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.2 - hooks: - - id: flake8 + - repo: https://github.com/psf/black - rev: 22.1.0 + rev: 25.1.0 hooks: - id: black + + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.31.3 + hooks: + - id: check-github-workflows + args: ["--verbose"] + - id: check-dependabot + + - repo: meta + hooks: + - id: check-useless-excludes + - id: check-hooks-apply + + - repo: https://github.com/asottile/pyupgrade + rev: v3.19.1 + hooks: + - id: pyupgrade + name: Upgrade Python syntax + args: [--py38-plus] + + - repo: https://github.com/pycqa/flake8 + rev: 7.1.2 + hooks: + - id: flake8 + args: ['--ignore=W191,E101,E501,E402 tests/tests.py'] + + - repo: https://github.com/DanielNoord/pydocstringformatter + rev: v0.7.3 + hooks: + - id: pydocstringformatter + args: ["--max-summary-lines=2", "--linewrap-full-docstring"] + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.23 + hooks: + - id: validate-pyproject + stages: [manual] + + # - repo: https://github.com/crate-ci/typos + # rev: v1.30.0 + # hooks: + # - id: typos + + - repo: https://github.com/adamchainz/blacken-docs + rev: "1.19.1" + hooks: + - id: blacken-docs + additional_dependencies: + - black>=22.12.0 + + # - repo: https://github.com/RobertCraigie/pyright-python + # rev: v1.1.396 + # hooks: + # - id: pyright # pylance + # exclude: ^examples/.*$|^tests/.*$ + # additional_dependencies: + # - cmd2 + # - lxml + # - requests + # - setuptools + + # - repo: https://github.com/woodruffw/zizmor-pre-commit + # # Find security issues in GitHub Actions CI/CD setups. + # rev: v1.5.1 + # hooks: + # # Run the linter. + # - id: zizmor + + - repo: https://github.com/regebro/pyroma + rev: "4.2" + hooks: + - id: pyroma + # Must be specified because of the default value in pyroma + always_run: false + files: | + (?x)^( + README.md| + pyproject.toml| + src/besapi/__init__.py| + src/besapi/besapi.py| + setup.cfg| + setup.py + )$ + stages: [manual] + + - repo: https://github.com/Pierre-Sassoulas/black-disable-checker + rev: v1.1.3 + hooks: + - id: black-disable-checker + stages: [manual] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.15.0 + hooks: + - id: mypy + args: [--ignore-missing-imports, --install-types, --non-interactive] + stages: [manual] + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-check-blanket-noqa + stages: [manual] + - id: python-no-log-warn + stages: [manual] + - id: python-use-type-annotations + stages: [manual] + # - id: python-no-eval + - id: python-check-blanket-type-ignore + stages: [manual] + + - repo: https://github.com/christophmeissner/pytest-pre-commit + rev: 1.0.0 + hooks: + - id: pytest + pass_filenames: false + always_run: true + stages: [manual] + additional_dependencies: + - cmd2 + - lxml + - requests + - setuptools + + # - repo: https://github.com/pycqa/pylint + # rev: v3.3.5 + # hooks: + # - id: pylint + # args: [--rcfile=.pylintrc] + + # - repo: https://github.com/astral-sh/ruff-pre-commit + # rev: v0.9.10 + # hooks: + # - id: ruff + # args: [--fix, --exit-non-zero-on-fix] + + # - repo: https://github.com/PyCQA/bandit + # rev: 1.8.3 + # hooks: + # - id: bandit + # args: ["-r", "-lll"] + + # - repo: https://github.com/sirosen/slyp + # rev: 0.8.2 + # hooks: + # - id: slyp diff --git a/.pylintrc b/.pylintrc index d2ec702..1a9f1f2 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,5 +1,5 @@ [MESSAGES CONTROL] -disable = C0330, C0326, C0103, c-extension-no-member, cyclic-import, no-self-use, unused-argument +disable = invalid-name, c-extension-no-member, cyclic-import, unused-argument, line-too-long, R0801, import-error [format] max-line-length = 88 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..e4611ec --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "EditorConfig.EditorConfig", + "github.vscode-github-actions", + "ms-python.black-formatter", + "ms-python.python" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 6290b00..2796a37 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,10 +3,17 @@ "python.analysis.diagnosticSeverityOverrides": { "reportMissingImports": "information" }, - "python.formatting.provider": "black", + "python.formatting.provider": "none", "python.autoComplete.addBrackets": true, "editor.formatOnSave": true, "git.autofetch": true, "python.analysis.completeFunctionParens": true, - "python.linting.enabled": true + "python.linting.enabled": true, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "python.testing.pytestArgs": ["tests"], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.defaultInterpreterPath": ".venv/bin/python" } diff --git a/README.md b/README.md index 171aabd..986b086 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Usage: ``` import besapi -b = besapi.BESConnection('my_username', 'my_password', 'https://rootserver.domain.org:52311') +b = besapi.besapi.BESConnection('my_username', 'my_password', 'https://rootserver.domain.org:52311') rr = b.get('sites') # rr.request contains the original request object @@ -91,27 +91,27 @@ OR >>> import bescli >>> bescli.main() -BES> login +BigFix> login User [mah60]: mah60 Root Server (ex. https://server.institution.edu:52311): https://my.company.org:52311 Password: Login Successful! -BES> get help +BigFix> get help ... -BES> get sites +BigFix> get sites ... -BES> get sites.OperatorSite.Name +BigFix> get sites.OperatorSite.Name mah60 -BES> get help/fixlets +BigFix> get help/fixlets GET: /api/fixlets/{site} POST: /api/fixlets/{site} -BES> get fixlets/operator/mah60 +BigFix> get fixlets/operator/mah60 ... ``` -# REST API Help +# BigFix REST API Documentation - https://developer.bigfix.com/rest-api/ - http://bigfix.me/restapi @@ -124,6 +124,14 @@ BES> get fixlets/operator/mah60 - requests - cmd2 +# Examples using BESAPI + +- https://github.com/jgstew/besapi/tree/master/examples +- https://github.com/jgstew/generate_bes_from_template/blob/master/examples/generate_uninstallers.py +- https://github.com/jgstew/jgstew-recipes/blob/main/SharedProcessors/BESImport.py +- https://github.com/jgstew/jgstew-recipes/blob/main/SharedProcessors/BigFixActioner.py +- https://github.com/jgstew/jgstew-recipes/blob/main/SharedProcessors/BigFixSessionRelevance.py + # Pyinstaller - `pyinstaller --clean --collect-all besapi --onefile .\src\bescli\bescli.py` diff --git a/examples/action_and_monitor.py b/examples/action_and_monitor.py new file mode 100644 index 0000000..fde4e99 --- /dev/null +++ b/examples/action_and_monitor.py @@ -0,0 +1,385 @@ +""" +Create an action from a fixlet or task xml bes file +and monitor its results for ~300 seconds. + +requires `besapi`, install with command `pip install besapi` + +NOTE: this script requires besapi v3.3.3+ due to use of besapi.plugin_utilities + +Example Usage: +python3 examples/action_and_monitor.py -c -vv --file './examples/content/TestEcho-Universal.bes' + +Inspect examples/action_and_monitor.log for results +""" + +import logging +import ntpath +import os +import platform +import sys +import time + +import lxml.etree + +import besapi +import besapi.plugin_utilities + +__version__ = "1.2.1" +verbose = 0 +invoke_folder = None + + +def get_invoke_folder(verbose=0): + """Get the folder the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +def get_invoke_file_name(verbose=0): + """Get the filename the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_file_path = sys.executable + else: + if verbose: + print("running in a normal Python process") + invoke_file_path = __file__ + + if verbose: + print(f"invoke_file_path = {invoke_file_path}") + + # get just the file name, return without file extension: + return os.path.splitext(ntpath.basename(invoke_file_path))[0] + + +def validate_xml_bes_file(file_path): + """Take a file path as input, read as binary data, validate against xml schema.""" + with open(file_path, "rb") as file: + file_data = file.read() + + return besapi.besapi.validate_xsd(file_data) + + +def get_action_combined_relevance(relevances: list[str]): + """Take array of ordered relevance clauses and return relevance string for + action. + """ + + relevance_combined = "" + + if not relevances: + return "False" + if len(relevances) == 0: + return "False" + if len(relevances) == 1: + return relevances[0] + if len(relevances) > 0: + for clause in relevances: + if len(relevance_combined) == 0: + relevance_combined = clause + else: + relevance_combined = ( + "( " + relevance_combined + " ) AND ( " + clause + " )" + ) + + return relevance_combined + + +def get_target_xml(targets=""): + """Get target xml based upon input. + + Input can be a single string: + - starts with "" if all computers should be targeted + - Otherwise will be interpreted as custom relevance + + Input can be a single int: + - Single Computer ID Target + + Input can be an array: + - Array of Strings: ComputerName + - Array of Integers: ComputerID + """ + if targets is None or not targets: + logging.warning("No valid targeting found, will target no computers.") + # default if invalid: + return "False" + + # if targets is int: + if isinstance(targets, int): + if targets == 0: + raise ValueError( + "Int 0 is not valid Computer ID, set targets to an array of strings of computer names or an array of ints of computer ids or custom relevance string or " + ) + return f"{targets}" + + # if targets is str: + if isinstance(targets, str): + # if targets string starts with "": + if targets.startswith(""): + if "false" in targets.lower(): + # In my testing, false does not work correctly + return "False" + # return "false" + return "true" + # treat as custom relevance: + return f"" + + # if targets is array: + if isinstance(targets, list): + element_type = type(targets[0]) + if element_type is int: + # array of computer ids + return ( + "" + + "".join(map(str, targets)) + + "" + ) + if element_type is str: + # array of computer names + return ( + "" + + "".join(targets) + + "" + ) + + logging.warning("No valid targeting found, will target no computers.") + + # default if invalid: + return "False" + + +def action_from_bes_file(bes_conn, file_path, targets=""): + """Create action from bes file with fixlet or task.""" + # default to empty string: + custom_relevance_xml = "" + + if not validate_xml_bes_file(file_path): + err_msg = "bes file is not valid according to XML Schema!" + logging.error(err_msg) + raise ValueError(err_msg) + + tree = lxml.etree.parse(file_path) + + # //BES/*[self::Task or self::Fixlet]/*[@id='elid']/name() + bes_type = str( + tree.xpath("//BES/*[self::Task or self::Fixlet or self::SingleAction]")[0].tag + ) + + logging.debug("BES Type: %s", bes_type) + + title = tree.xpath(f"//BES/{bes_type}/Title/text()")[0] + + logging.debug("Title: %s", title) + + try: + actionscript = tree.xpath( + f"//BES/{bes_type}/DefaultAction/ActionScript/text()" + )[0] + except IndexError: + # handle SingleAction case: + actionscript = tree.xpath(f"//BES/{bes_type}/ActionScript/text()")[0] + + logging.debug("ActionScript: %s", actionscript) + + try: + if bes_type != "SingleAction": + success_criteria = tree.xpath( + f"//BES/{bes_type}/DefaultAction/SuccessCriteria/@Option" + )[0] + else: + success_criteria = tree.xpath(f"//BES/{bes_type}/SuccessCriteria/@Option")[ + 0 + ] + except IndexError: + # set success criteria if missing: (default) + success_criteria = "RunToCompletion" + if bes_type == "Fixlet": + # set success criteria if missing: (Fixlet) + success_criteria = "OriginalRelevance" + + if success_criteria == "CustomRelevance": + if bes_type != "SingleAction": + custom_relevance = tree.xpath( + f"//BES/{bes_type}/DefaultAction/SuccessCriteria/text()" + )[0] + else: + custom_relevance = tree.xpath(f"//BES/{bes_type}/SuccessCriteria/text()")[0] + + custom_relevance_xml = f"" + + logging.debug("success_criteria: %s", success_criteria) + + relevance_clauses = tree.xpath(f"//BES/{bes_type}/Relevance/text()") + + logging.debug("Relevances: %s", relevance_clauses) + + relevance_clauses_combined = get_action_combined_relevance(relevance_clauses) + + logging.debug("Relevance Combined: %s", relevance_clauses_combined) + + action_xml = f""" + + + {title} + + + {custom_relevance_xml} + + {get_target_xml(targets)} + + + +""" + + logging.debug("Action XML:\n%s", action_xml) + + if not besapi.besapi.validate_xsd(action_xml): + err_msg = "Action XML is not valid!" + logging.error(err_msg) + raise ValueError(err_msg) + + action_result = bes_conn.post(bes_conn.url("actions"), data=action_xml) + + logging.info("Action Result:/n%s", action_result) + + action_id = action_result.besobj.Action.ID + + logging.info("Action ID: %s", action_id) + + return action_id + + +def action_monitor_results(bes_conn, action_id, iterations=30, sleep_time=15): + """Monitor the results of an action if interactive.""" + previous_result = "" + i = 0 + try: + # loop ~300 second for results + while i < iterations: + print("... waiting for results ... Ctrl+C to quit loop") + + time.sleep(sleep_time) + + # get the actual results: + # api/action/ACTION_ID/status?fields=ActionID,Status,DateIssued,DateStopped,StoppedBy,Computer(Status,State,StartTime) + # NOTE: this might not return anything if no clients have returned results + # this can be checked again and again for more results: + action_status_result = bes_conn.get( + bes_conn.url( + f"action/{action_id}/status?fields=ActionID,Status,DateIssued,DateStopped,StoppedBy,Computer(Status,State,StartTime)" + ) + ) + + if previous_result != str(action_status_result): + logging.info(action_status_result) + previous_result = str(action_status_result) + + i += 1 + + if action_status_result.besobj.ActionResults.Status == "Stopped": + logging.info("Action is stopped, halting monitoring loop") + break + + # if not running interactively: + # https://stackoverflow.com/questions/2356399/tell-if-python-is-in-interactive-mode + if not sys.__stdin__.isatty(): + logging.warning("not interactive, stopping loop") + break + except KeyboardInterrupt: + print("\nloop interrupted by user") + + return previous_result + + +def action_and_monitor(bes_conn, file_path, targets=""): + """Take action from bes xml file + monitor results of action. + """ + + action_id = action_from_bes_file(bes_conn, file_path, targets) + + logging.info("Start monitoring action results:") + + results_action = action_monitor_results(bes_conn, action_id) + + logging.info("End monitoring, Last Result:\n%s", results_action) + + +def main(): + """Execution starts here.""" + print("main()") + + print("NOTE: this script requires besapi v3.3.3+ due to besapi.plugin_utilities") + + parser = besapi.plugin_utilities.setup_plugin_argparse() + + # add additional arg specific to this script: + parser.add_argument( + "-f", + "--file", + help="xml bes file to create an action from", + required=False, + type=str, + ) + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + # allow set global scoped vars + global verbose, invoke_folder + verbose = args.verbose + + # get folder the script was invoked from: + invoke_folder = get_invoke_folder() + + log_file_path = os.path.join( + get_invoke_folder(verbose), get_invoke_file_name(verbose) + ".log" + ) + + print(log_file_path) + + logging_config = besapi.plugin_utilities.get_plugin_logging_config( + log_file_path, verbose, args.console + ) + + logging.basicConfig(**logging_config) + + logging.log(99, "---------- Starting New Session -----------") + logging.debug("invoke folder: %s", invoke_folder) + logging.debug("%s's version: %s", get_invoke_file_name(verbose), __version__) + logging.debug("BESAPI Module version: %s", besapi.besapi.__version__) + logging.debug("Python version: %s", platform.sys.version) + + try: + bes_conn = besapi.plugin_utilities.get_besapi_connection(args) + + # set targeting criteria to computer id int or "" or array + targets = 0 + + action_and_monitor(bes_conn, args.file, targets) + + logging.log(99, "---------- END -----------") + except Exception as err: + logging.error("An error occurred: %s", err) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/baseline_by_relevance.py b/examples/baseline_by_relevance.py new file mode 100644 index 0000000..f3dd748 --- /dev/null +++ b/examples/baseline_by_relevance.py @@ -0,0 +1,121 @@ +""" +Create baseline by session relevance result. + +requires `besapi`, install with command `pip install besapi` +""" + +import datetime +import os + +import besapi + +# This relevance string must start with `fixlets` and return the set of fixlets you wish to turn into a baseline +FIXLET_RELEVANCE = 'fixlets whose(name of it starts with "Update:") of bes sites whose( external site flag of it AND name of it = "Updates for Windows Applications Extended" )' + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + print(bes_conn.last_connected) + + # change the relevance here to adjust which content gets put in a baseline: + fixlets_rel = FIXLET_RELEVANCE + + # this gets the info needed from the items to make the baseline: + session_relevance = f"""(it as string) of (url of site of it, ids of it, content id of default action of it | "Action1") of it whose(exists default action of it AND globally visible flag of it AND name of it does not contain "(Superseded)" AND exists applicable computers whose(now - last report time of it < 60 * day) of it) of {fixlets_rel}""" + + print("getting items to add to baseline...") + result = bes_conn.session_relevance_array(session_relevance) + print(f"{len(result)} items found") + + # print(result) + + baseline_components = "" + + for item in result: + # print(item) + tuple_items = item.split(", ") + try: + baseline_components += f""" + """ + except IndexError: + print("ERROR: a component was missing a key item.") + continue + + # print(baseline_components) + + # generate XML for baseline with template: + baseline = f""" + + + Custom Patching Baseline {datetime.datetime.today().strftime('%Y-%m-%d')} + + true + + {baseline_components} + + + +""" + + # print(baseline) + + file_path = "tmp_baseline.bes" + site_name = "Demo" + site_path = f"custom/{site_name}" + + # Does not work through console import: + with open(file_path, "w") as f: + f.write(baseline) + + print("Importing generated baseline...") + import_result = bes_conn.import_bes_to_site(file_path, site_path) + + print(import_result) + + os.remove(file_path) + + # to automatically create an offer action, comment out the next line: + return True + + baseline_id = import_result.besobj.Baseline.ID + + print("creating baseline offer action...") + + BES_SourcedFixletAction = f"""\ + + + + {site_name} + {baseline_id} + Action1 + + + true + + + true + P10D + true + + true + false + Testing + + + + +""" + + action_result = bes_conn.post("actions", BES_SourcedFixletAction) + + print(action_result) + + print("Finished!") + + +if __name__ == "__main__": + main() diff --git a/examples/baseline_plugin.config.yaml b/examples/baseline_plugin.config.yaml new file mode 100644 index 0000000..1dd3515 --- /dev/null +++ b/examples/baseline_plugin.config.yaml @@ -0,0 +1,15 @@ +--- +bigfix: + content: + Baselines: + automation: + trigger_file_path: baseline_plugin_run_now + sites: + - name: Updates for Windows Applications Extended + auto_remediate: true + offer_action: true + superseded_eval: false + - name: Updates for Windows Applications + auto_remediate: true + offer_action: true + superseded_eval: false diff --git a/examples/baseline_plugin.py b/examples/baseline_plugin.py new file mode 100644 index 0000000..7a97bae --- /dev/null +++ b/examples/baseline_plugin.py @@ -0,0 +1,304 @@ +""" +Generate patching baselines from sites. + +requires `besapi`, install with command `pip install besapi` + +Example Usage: +python baseline_plugin.py -r https://localhost:52311/api -u API_USER -p API_PASSWORD + +References: +- https://github.com/jgstew/besapi/blob/master/examples/rest_cmd_args.py +- https://github.com/jgstew/besapi/blob/master/examples/baseline_by_relevance.py +- https://github.com/jgstew/tools/blob/master/Python/locate_self.py +""" + +import datetime +import logging +import os +import platform +import sys + +import ruamel.yaml + +import besapi +import besapi.plugin_utilities + +__version__ = "1.2.1" +verbose = 0 +bes_conn = None +invoke_folder = None +config_yaml = None + + +def get_invoke_folder(): + """Get the folder the script was invoked from. + + References: + - https://github.com/jgstew/tools/blob/master/Python/locate_self.py + """ + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +def get_config(path="baseline_plugin.config.yaml"): + """Load config from yaml file.""" + + if not (os.path.isfile(path) and os.access(path, os.R_OK)): + path = os.path.join(invoke_folder, path) + + logging.info("loading config from: `%s`", path) + + if not (os.path.isfile(path) and os.access(path, os.R_OK)): + raise FileNotFoundError(path) + + with open(path, encoding="utf-8") as stream: + yaml = ruamel.yaml.YAML(typ="safe", pure=True) + config_yaml = yaml.load(stream) + + if verbose > 1: + logging.debug(config_yaml["bigfix"]) + + return config_yaml + + +def test_file_exists(path): + """Return true if file exists.""" + + if not (os.path.isfile(path) and os.access(path, os.R_OK)): + path = os.path.join(invoke_folder, path) + + logging.info("testing if exists: `%s`", path) + + if os.path.isfile(path) and os.access(path, os.R_OK) and os.access(path, os.W_OK): + return path + + return False + + +def create_baseline_from_site(site): + """Create a patching baseline from a site config. + + References: + - https://github.com/jgstew/besapi/blob/master/examples/baseline_by_relevance.py + """ + + site_name = site["name"] + + # create action automatically? + auto_remediate = site["auto_remediate"] if "auto_remediate" in site else False + + # eval old baselines? + superseded_eval = site["superseded_eval"] if "superseded_eval" in site else False + + logging.info("Create patching baseline for site: %s", site_name) + + # Example: + # fixlets of bes sites whose(exists (it as trimmed string as lowercase) whose(it = "Updates for Windows Applications Extended" as trimmed string as lowercase) of (display names of it; names of it)) + fixlets_rel = f'fixlets of bes sites whose(exists (it as trimmed string as lowercase) whose(it = "{site_name}" as trimmed string as lowercase) of (display names of it; names of it))' + + session_relevance = f"""(it as string) of (url of site of it, ids of it, content id of default action of it | "Action1") of it whose(exists default action of it AND globally visible flag of it AND name of it does not contain "(Superseded)" AND exists applicable computers whose(now - last report time of it < 60 * day) of it) of {fixlets_rel}""" + + result = bes_conn.session_relevance_array(session_relevance) + + num_items = len(result) + + if num_items > 1: + logging.info("Number of items to add to baseline: %s", num_items) + + baseline_components = "" + + IncludeInRelevance = "true" + + fixlet_ids_str = "0" + + if num_items > 100: + IncludeInRelevance = "false" + + for item in result: + tuple_items = item.split(", ") + fixlet_ids_str += " ; " + tuple_items[1] + baseline_components += f""" + """ + + logging.debug(baseline_components) + + superseded_eval_rel = "" + + if superseded_eval: + superseded_eval_rel = ' OR ( exists (it as string as integer) whose(it = 1) of values of settings whose(name of it ends with "_EnableSupersededEval" AND name of it contains "BESClient_") of client )' + + # only have the baseline be relevant for 60 days after creation: + baseline_rel = f'( exists absolute values whose(it < 60 * day) of (current date - "{datetime.datetime.today().strftime("%d %b %Y")}" as date) ){superseded_eval_rel}' + + if num_items > 100: + site_rel_query = f"""unique value of site level relevances of bes sites whose(exists (it as trimmed string as lowercase) whose(it = "{site_name}" as trimmed string as lowercase) of (display names of it; names of it))""" + site_rel = bes_conn.session_relevance_string(site_rel_query) + + baseline_rel = f"""( {baseline_rel} ) AND ( {site_rel} )""" + + # # This does not appear to work as expected: + # # create baseline relevance such that only relevant if 1+ fixlet is relevant + # if num_items > 100: + # baseline_rel = f"""exists relevant fixlets whose(id of it is contained by set of ({ fixlet_ids_str })) of sites whose("Fixlet Site" = type of it AND "{ site_name }" = name of it)""" + + # generate XML for baseline with template: + baseline_xml = f""" + + + Remediations from {site_name} - {datetime.datetime.today().strftime('%Y-%m-%d')} + + + PT12H + + {baseline_components} + + + + """ + + logging.debug("Baseline XML:\n%s", baseline_xml) + + file_path = "tmp_baseline.bes" + + # the custom site to import the baseline into: + import_site_name = "Demo" + site_path = f"custom/{import_site_name}" + + # Does not work through console import: + with open(file_path, "w", encoding="utf-8") as f: + f.write(baseline_xml) + + logging.info("Importing generated baseline for %s ...", site_name) + import_result = bes_conn.import_bes_to_site(file_path, site_path) + + logging.info("Result: Import XML:\n%s", import_result) + + os.remove(file_path) + + if auto_remediate: + baseline_id = import_result.besobj.Baseline.ID + + # get targeting xml with relevance + # target only machines currently relevant + target_rel = f'("" & it & "") of concatenations "" of (it as string) of ids of elements of unions of applicable computer sets of it whose(exists default action of it AND globally visible flag of it AND name of it does not contain "(Superseded)") of {fixlets_rel}' + + targeting_result = bes_conn.session_relevance_json_string(target_rel) + + offer_xml = "" + + offer_action = site["offer_action"] if "offer_action" in site else True + + if offer_action: + logging.info("creating baseline offer action...") + offer_xml = """true + false + Remediation + """ + else: + logging.info("creating baseline action...") + + BES_SourcedFixletAction = f"""\ + + + + {import_site_name} + {baseline_id} + Action1 + + + {targeting_result} + + + true + P10D + true + {offer_xml} + + + + """ + + logging.debug("Action XML:\n%s", BES_SourcedFixletAction) + + action_result = bes_conn.post("actions", BES_SourcedFixletAction) + + logging.info("Result: Action XML:\n%s", action_result) + + +def process_baselines(config): + """Generate baselines for each site in config.""" + + for site in config: + create_baseline_from_site(site) + + +def main(): + """Execution starts here.""" + print("main() start") + + parser = besapi.plugin_utilities.setup_plugin_argparse() + + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + # allow set global scoped vars + global bes_conn, verbose, config_yaml, invoke_folder + verbose = args.verbose + + # get folder the script was invoked from: + invoke_folder = get_invoke_folder() + + # get path to put log file in: + log_filename = os.path.join(invoke_folder, "baseline_plugin.log") + + logging_config = besapi.plugin_utilities.get_plugin_logging_config( + log_filename, verbose, args.console + ) + + logging.basicConfig(**logging_config) + + logging.log(99, "----- Starting New Session ------") + logging.debug("invoke folder: %s", invoke_folder) + logging.debug("Python version: %s", platform.sys.version) + logging.debug("BESAPI Module version: %s", besapi.besapi.__version__) + logging.debug("this plugin's version: %s", __version__) + + bes_conn = besapi.plugin_utilities.get_besapi_connection(args) + + # get config: + config_yaml = get_config() + + trigger_path = config_yaml["bigfix"]["content"]["Baselines"]["automation"][ + "trigger_file_path" + ] + + # check if file exists, if so, return path, else return false: + trigger_path = test_file_exists(trigger_path) + + if trigger_path: + process_baselines( + config_yaml["bigfix"]["content"]["Baselines"]["automation"]["sites"] + ) + # delete trigger file + os.remove(trigger_path) + else: + logging.info("Trigger File Does Not Exists, skipping execution!") + + logging.log(99, "----- Ending Session ------") + + +if __name__ == "__main__": + main() diff --git a/examples/baseline_sync_plugin.py b/examples/baseline_sync_plugin.py new file mode 100644 index 0000000..0ed1409 --- /dev/null +++ b/examples/baseline_sync_plugin.py @@ -0,0 +1,190 @@ +""" +This will sync baselines that are not in sync. + +requires `besapi`, install with command `pip install besapi` + +LIMITATION: This does not work with baselines in the actionsite +- Only works on baselines in custom sites + +Example Usage: +python baseline_sync_plugin.py -r https://localhost:52311/api -u API_USER -p API_PASSWORD + +Example Usage with config file: +python baseline_sync_plugin.py + +This can also be run as a BigFix Server Plugin Service. + +References: +- https://developer.bigfix.com/rest-api/api/admin.html +- https://github.com/jgstew/besapi/blob/master/examples/rest_cmd_args.py +- https://github.com/jgstew/tools/blob/master/Python/locate_self.py +""" + +import logging +import ntpath +import os +import platform +import sys + +import besapi +import besapi.plugin_utilities + +__version__ = "1.1.2" +verbose = 0 +bes_conn = None +invoke_folder = None + + +def get_invoke_folder(verbose=0): + """Get the folder the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +def get_invoke_file_name(verbose=0): + """Get the filename the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_file_path = sys.executable + else: + if verbose: + print("running in a normal Python process") + invoke_file_path = __file__ + + if verbose: + print(f"invoke_file_path = {invoke_file_path}") + + # get just the file name, return without file extension: + return os.path.splitext(ntpath.basename(invoke_file_path))[0] + + +def baseline_sync(baseline_id, site_path): + """Sync a baseline.""" + logging.info("Syncing baseline: %s/%s", site_path, baseline_id) + + # get baseline sync xml: + results = bes_conn.get(f"baseline/{site_path}/{baseline_id}/sync") + + baseline_xml_sync = results.text + + results = bes_conn.put( + f"baseline/{site_path}/{baseline_id}", data=baseline_xml_sync + ) + + logging.debug("Sync results: %s", results.text) + + logging.info("Baseline %s/%s synced successfully", site_path, baseline_id) + return results.text + + +def process_baseline(baseline_id, site_path): + """Check a single baseline if it needs syncing.""" + logging.info("Processing baseline: %s/%s", site_path, baseline_id) + + # get baseline xml: + results = bes_conn.get(f"baseline/{site_path}/{baseline_id}") + + baseline_xml = results.text + + if 'SyncStatus="source fixlet differs"' in baseline_xml: + logging.info("Baseline %s/%s is out of sync", site_path, baseline_id) + return baseline_sync(baseline_id, site_path) + else: + logging.info("Baseline %s/%s is in sync", site_path, baseline_id) + return baseline_xml + + +def process_site(site_path): + """Process a single site to find baselines to check.""" + logging.info("Processing site: %s", site_path) + + # get site name from end of path: + # if site_path does not have / then use site_path as site_name + site_name = site_path.split("/")[-1] + + # get baselines in site: + session_relevance = f"""ids of fixlets whose(baseline flag of it) of bes custom sites whose(name of it = "{site_name}")""" + + logging.debug("Getting baselines in site: %s", site_name) + results = bes_conn.session_relevance_json(session_relevance) + + logging.info("Found %i baselines in site: %s", len(results["result"]), site_name) + + for baseline_id in results["result"]: + process_baseline(baseline_id, site_path) + + +def main(): + """Execution starts here.""" + print("main() start") + + parser = besapi.plugin_utilities.setup_plugin_argparse() + + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + # allow set global scoped vars + global bes_conn, verbose, invoke_folder + verbose = args.verbose + + # get folder the script was invoked from: + invoke_folder = get_invoke_folder(verbose) + + log_file_path = os.path.join(invoke_folder, get_invoke_file_name(verbose) + ".log") + + logging_config = besapi.plugin_utilities.get_plugin_logging_config( + log_file_path, verbose, args.console + ) + + logging.basicConfig(**logging_config) + + logging.log(99, "---------- Starting New Session -----------") + logging.debug("invoke folder: %s", invoke_folder) + logging.debug("%s's version: %s", get_invoke_file_name(verbose), __version__) + logging.debug("BESAPI Module version: %s", besapi.besapi.__version__) + logging.debug("Python version: %s", platform.sys.version) + logging.warning( + "Results may be incorrect if not run as a MO or an account without scope of all computers" + ) + + bes_conn = besapi.plugin_utilities.get_besapi_connection(args) + + session_relevance = """names of bes custom sites whose(exists fixlets whose(baseline flag of it) of it)""" + + logging.info("Getting custom sites with baselines") + results = bes_conn.session_relevance_json(session_relevance) + + logging.info("Processing %i custom sites with baselines", len(results["result"])) + + logging.debug("Custom sites with baselines:\n%s", results["result"]) + + for site in results["result"]: + try: + process_site("custom/" + site) + except PermissionError: + logging.error( + "Error processing site %s: Permission Denied, skipping site.", site + ) + continue + + logging.log(99, "---------- Ending Session -----------") + + +if __name__ == "__main__": + main() diff --git a/examples/client_query_from_string.py b/examples/client_query_from_string.py new file mode 100644 index 0000000..5a8c1cc --- /dev/null +++ b/examples/client_query_from_string.py @@ -0,0 +1,198 @@ +""" +Example session relevance results from a string. + +requires `besapi`, install with command `pip install besapi` +""" + +import json +import logging +import ntpath +import os +import platform +import sys +import time + +import besapi +import besapi.plugin_utilities + +CLIENT_RELEVANCE = "(computer names, model name of main processor, (it as string) of (it / (1024 * 1024 * 1024)) of total amount of ram)" +__version__ = "1.0.1" +verbose = 0 +bes_conn = None +invoke_folder = None + + +def get_invoke_folder(verbose=0): + """Get the folder the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +def get_invoke_file_name(verbose=0): + """Get the filename the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_file_path = sys.executable + else: + if verbose: + print("running in a normal Python process") + invoke_file_path = __file__ + + if verbose: + print(f"invoke_file_path = {invoke_file_path}") + + # get just the file name, return without file extension: + return os.path.splitext(ntpath.basename(invoke_file_path))[0] + + +def main(): + """Execution starts here.""" + print("main()") + + print("NOTE: this script requires besapi v3.3.3+ due to besapi.plugin_utilities") + + parser = besapi.plugin_utilities.setup_plugin_argparse() + + # add additional arg specific to this script: + parser.add_argument( + "-q", + "--query", + help="client query relevance", + required=False, + type=str, + default=CLIENT_RELEVANCE, + ) + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + # allow set global scoped vars + global bes_conn, verbose, invoke_folder + verbose = args.verbose + + # get folder the script was invoked from: + invoke_folder = get_invoke_folder() + + log_file_path = os.path.join( + get_invoke_folder(verbose), get_invoke_file_name(verbose) + ".log" + ) + + print(log_file_path) + + if not verbose or verbose == 0: + verbose = 1 + + # always print to console: + logging_config = besapi.plugin_utilities.get_plugin_logging_config( + log_file_path, verbose, True + ) + + logging.basicConfig(**logging_config) + + logging.log(99, "---------- Starting New Session -----------") + logging.debug("invoke folder: %s", invoke_folder) + logging.debug("Python version: %s", platform.sys.version) + logging.debug("BESAPI Module version: %s", besapi.besapi.__version__) + logging.debug("this plugin's version: %s", __version__) + + bes_conn = besapi.plugin_utilities.get_besapi_connection(args) + + # get the ~10 most recent computers to report into BigFix: + session_relevance = 'tuple string items (integers in (0,9)) of concatenations ", " of (it as string) of ids of bes computers whose(now - last report time of it < 90 * minute)' + + data = {"output": "json", "relevance": session_relevance} + + # submitting session relevance query using POST to reduce problems: + result = bes_conn.post(bes_conn.url("query"), data) + + json_result = json.loads(str(result)) + + logging.debug("computer ids to target with query: %s", json_result["result"]) + + # this is the client relevance we are going to get the results of: + client_relevance = args.query + + # generate target XML substring from list of computer ids: + target_xml = ( + "" + + "".join(json_result["result"]) + + "" + ) + + # python template for ClientQuery BESAPI XML: + query_payload = f""" + + true + {client_relevance} + + {target_xml} + + +""" + + logging.debug("query_payload: %s", query_payload) + + # send the client query: (need it's ID to get results) + query_submit_result = bes_conn.post(bes_conn.url("clientquery"), data=query_payload) + + client_query_id = query_submit_result.besobj.ClientQuery.ID + + logging.debug("query_submit_result: %s", query_submit_result) + + logging.debug("client_query_id: %s", client_query_id) + + previous_result = "" + i = 0 + try: + # loop ~90 second for results + while i < 9: + print("... waiting for results ... Ctrl+C to quit loop") + + # TODO: loop this to keep getting more results until all return or any key pressed + time.sleep(20) + + # get the actual results: + # NOTE: this might not return anything if no clients have returned results + # this can be checked again and again for more results: + query_result = bes_conn.get( + bes_conn.url(f"clientqueryresults/{client_query_id}?output=json") + ) + + if previous_result != str(query_result): + json_result = json.loads(str(query_result)) + print(json.dumps(json_result["results"], indent=2)) + previous_result = str(query_result) + + i += 1 + + # if not running interactively: + # https://stackoverflow.com/questions/2356399/tell-if-python-is-in-interactive-mode + if not sys.__stdin__.isatty(): + logging.info("not interactive, stopping loop") + break + except KeyboardInterrupt: + logging.info(" ** loop interrupted by user **") + + # log only final result: + logging.info(" -- final results:\n%s", json.dumps(json_result["results"], indent=2)) + + logging.log(99, "---------- Ended Session -----------") + + +if __name__ == "__main__": + main() diff --git a/examples/computer_group_output.py b/examples/computer_group_output.py new file mode 100644 index 0000000..35638cc --- /dev/null +++ b/examples/computer_group_output.py @@ -0,0 +1,140 @@ +""" +This will output members of computer groups to files. + +requires `besapi`, install with command `pip install besapi` + +Example Usage: +python computer_group_output.py -r https://localhost:52311/api -u API_USER --days 90 -p API_PASSWORD + +References: +- https://developer.bigfix.com/rest-api/api/admin.html +- https://github.com/jgstew/besapi/blob/master/examples/rest_cmd_args.py +- https://github.com/jgstew/tools/blob/master/Python/locate_self.py +""" + +import logging +import ntpath +import os +import platform +import sys + +import besapi +import besapi.plugin_utilities + +__version__ = "1.1.2" +verbose = 0 +bes_conn = None +invoke_folder = None + + +def get_invoke_folder(verbose=0): + """Get the folder the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +def get_invoke_file_name(verbose=0): + """Get the filename the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_file_path = sys.executable + else: + if verbose: + print("running in a normal Python process") + invoke_file_path = __file__ + + if verbose: + print(f"invoke_file_path = {invoke_file_path}") + + # get just the file name, return without file extension: + return os.path.splitext(ntpath.basename(invoke_file_path))[0] + + +def string_truncate(text, max_length=70): + """Truncate a string to a maximum length and append ellipsis if truncated.""" + if len(text) > max_length: + return text[:max_length] + "..." + return text + + +def main(): + """Execution starts here.""" + print("main() start") + + parser = besapi.plugin_utilities.setup_plugin_argparse() + + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + # allow set global scoped vars + global bes_conn, verbose, invoke_folder + verbose = args.verbose + + # get folder the script was invoked from: + invoke_folder = get_invoke_folder(verbose) + + log_file_path = os.path.join(invoke_folder, get_invoke_file_name(verbose) + ".log") + + logging_config = besapi.plugin_utilities.get_plugin_logging_config( + log_file_path, verbose, args.console + ) + + logging.basicConfig(**logging_config) + logging.log(99, "---------- Starting New Session -----------") + logging.debug("invoke folder: %s", invoke_folder) + logging.debug("%s's version: %s", get_invoke_file_name(verbose), __version__) + logging.debug("BESAPI Module version: %s", besapi.besapi.__version__) + logging.debug("Python version: %s", platform.sys.version) + logging.warning( + "Results may be incorrect if not run as a MO or an account without scope of all computers" + ) + + bes_conn = besapi.plugin_utilities.get_besapi_connection(args) + + member_join_str = ";;;" + session_relevance = f"""(item 0 of it & " - " & item 1 of it, item 2 of it) of ( ( (if automatic flag of it then "Automatic" else NOTHING) ; (if manual flag of it then "Manual" else NOTHING) ; (if server based flag of it then "Server" else NOTHING) ), names of it, concatenations "{member_join_str}" of names of members of it ) of bes computer groups""" + + logging.info("Getting computer group membership information") + results = bes_conn.session_relevance_json(session_relevance) + + logging.info("Writing computer group membership to files") + + for result in results["result"]: + group_members_str = result[1].strip() + group_name = result[0].strip() + + if group_members_str == "": + logging.warning("Group '%s' has no members, skipping it.", group_name) + continue + + # split group_members_str on member_join_str + group_members = group_members_str.split(member_join_str) + + logging.debug("GroupName: %s > %d members", group_name, len(group_members)) + logging.debug("GroupMembers: %s", string_truncate(group_members_str)) + + # write group members to file + with open(f"{group_name}.txt", "w", encoding="utf-8") as f: + f.writelines("\n".join(group_members)) + + logging.log(99, "---------- Ending Session -----------") + + +if __name__ == "__main__": + main() diff --git a/examples/computers_delete_by_file.py b/examples/computers_delete_by_file.py new file mode 100644 index 0000000..5595a00 --- /dev/null +++ b/examples/computers_delete_by_file.py @@ -0,0 +1,58 @@ +""" +Delete computers in file. + +requires `besapi`, install with command `pip install besapi` +""" + +import os + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + # get the directory this script is running within: + script_dir = os.path.dirname(os.path.realpath(__file__)) + # get the file "computers_delete_by_file.txt" within the folder of the script: + comp_file_path = os.path.join(script_dir, "computers_delete_by_file.txt") + + comp_file_lines = [] + with open(comp_file_path) as comp_file: + for line in comp_file: + line = line.strip() + if line != "": + comp_file_lines.append(line) + + # print(comp_file_lines) + + computers = '"' + '";"'.join(comp_file_lines) + '"' + + # by default, this will only return computers that have not reported in >90 days: + session_relevance = f"unique values of ids of bes computers whose(now - last report time of it > 90 * day AND exists elements of intersections of (it; sets of ({computers})) of sets of (name of it; id of it as string))" + + # get session relevance result of computer ids from list of computer ids or computer names: + results = bes_conn.session_relevance_array(session_relevance) + + # print(results) + + if "Nothing returned, but no error." in results[0]: + print("WARNING: No computers found to delete!") + return None + + # delete computers: + for item in results: + if item.strip() != "": + computer_id = str(int(item)) + print(f"INFO: Attempting to delete Computer ID: {computer_id}") + result_del = bes_conn.delete(bes_conn.url(f"computer/{computer_id}")) + if "ok" not in result_del.text: + print(f"ERROR: {result_del} for id: {computer_id}") + continue + + +if __name__ == "__main__": + main() diff --git a/examples/computers_delete_by_file.txt b/examples/computers_delete_by_file.txt new file mode 100644 index 0000000..c59cc26 --- /dev/null +++ b/examples/computers_delete_by_file.txt @@ -0,0 +1,2 @@ +7cf29da417f9 +15083644 diff --git a/examples/content/PluginConfigTask.bes b/examples/content/PluginConfigTask.bes new file mode 100644 index 0000000..f95a5df --- /dev/null +++ b/examples/content/PluginConfigTask.bes @@ -0,0 +1,84 @@ + + + + Change BigFix Plugin Run Period - Linux + + exists main gather service + unix of operating system + exists folders "/var/opt/BESServer/Applications" + exists files "/var/opt/BESServer/Applications/Config/plugin_def_linux.xml" + 1800") of files "/var/opt/BESServer/Applications/Config/plugin_def_linux.xml"]]> + Configuration + 0 + Internal + jgstew + 2025-03-25 + + + + + x-fixlet-modification-time + Tue, 25 Mar 2025 19:50:34 +0000 + + BESC + + + Click + here + to deploy this action. + + +# Example: ./edit_wait_period.sh schedule.xml 3600 + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " + exit 1 +fi + +FILE_PATH="$1" +NEW_VALUE="$2" + +if [[ ! -f "$FILE_PATH" ]]; then + echo "Error: File '$FILE_PATH' does not exist." + exit 2 +fi + +if ! [[ "$NEW_VALUE" =~ ^[0-9]+$ ]]; then + echo "Error: New value must be an integer." + exit 3 +fi + +# Use sed to update the WaitPeriodSeconds value +sed --in-place -E "s|()[0-9]+()|\1$NEW_VALUE\2|" "$FILE_PATH" + +if [[ $? -eq 0 ]]; then + echo "Successfully updated WaitPeriodSeconds to $NEW_VALUE in '$FILE_PATH'." +else + echo "Error: Failed to update WaitPeriodSeconds." + exit 9 +fi +_END_OF_FILE_ + +// delete destination of __createfile to be sure it doesn't already exist +delete /tmp/run.sh + +// put file in place to run: +copy __createfile /tmp/run.sh + +// run it, waiting a maximum of 30 minutes: +override wait +timeout_seconds=1800 +wait bash /tmp/run.sh "/var/opt/BESServer/Applications/Config/saas_plugin_def_linux.xml" 1800]]> + + + + diff --git a/examples/content/RelaySelectAction.bes b/examples/content/RelaySelectAction.bes new file mode 100644 index 0000000..e202889 --- /dev/null +++ b/examples/content/RelaySelectAction.bes @@ -0,0 +1,10 @@ + + + + Relay Select + + relay select + + false + + diff --git a/examples/content/RelaySelectTask.bes b/examples/content/RelaySelectTask.bes new file mode 100644 index 0000000..78d2117 --- /dev/null +++ b/examples/content/RelaySelectTask.bes @@ -0,0 +1,31 @@ + + + + RelaySelect + + not exists relay service + not exists main gather service + + + Internal + jgstew + 2021-08-03 + + + + + x-fixlet-modification-time + Tue, 03 Aug 2021 15:18:27 +0000 + + BESC + + + Click + here + to deploy this action. + + + + + diff --git a/examples/content/RelaySetAffiliationGroup.bes b/examples/content/RelaySetAffiliationGroup.bes new file mode 100644 index 0000000..7d91058 --- /dev/null +++ b/examples/content/RelaySetAffiliationGroup.bes @@ -0,0 +1,32 @@ + + + + set relay affiliation group + + exists relay service + not exists main gather service + not exists settings "_BESRelay_Register_Affiliation_AdvertisementList" of client + + Internal + jgstew + + + + + + x-fixlet-modification-time + Tue, 03 Aug 2021 15:18:27 +0000 + + BESC + + + Click + here + to deploy this action. + + + + + diff --git a/examples/content/RelaySetNameOverride.bes b/examples/content/RelaySetNameOverride.bes new file mode 100644 index 0000000..b9ab958 --- /dev/null +++ b/examples/content/RelaySetNameOverride.bes @@ -0,0 +1,32 @@ + + + + set relay name override + + exists relay service + not exists main gather service + not exists settings "_BESClient_Relay_NameOverride" of client + + Internal + jgstew + + + + + + x-fixlet-modification-time + Tue, 03 Aug 2021 15:18:27 +0000 + + BESC + + + Click + here + to deploy this action. + + + + + diff --git a/examples/content/TestEcho-Universal.bes b/examples/content/TestEcho-Universal.bes new file mode 100644 index 0000000..21e94a1 --- /dev/null +++ b/examples/content/TestEcho-Universal.bes @@ -0,0 +1,30 @@ + + + + test echo - all + + + + Internal + + 2021-08-03 + + + + + x-fixlet-modification-time + Tue, 03 Aug 2021 15:18:27 +0000 + + BESC + + + Click + here + to deploy this action. + + {(concatenations (if windows of operating system then "^ " else "\ ") of substrings separated by " " of it) of pathname of folders "Logs" of folders "__Global" of data folders of client}{if windows of operating system then "\" else "/"}test_echo.log" +]]> + + + diff --git a/examples/dashboard_variable_get_value.py b/examples/dashboard_variable_get_value.py new file mode 100644 index 0000000..3e768fd --- /dev/null +++ b/examples/dashboard_variable_get_value.py @@ -0,0 +1,38 @@ +""" +Get dashboard variable value. + +requires `besapi` v3.2.6+ + +install with command `pip install -U besapi` +""" + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + print(bes_conn.last_connected) + + print(bes_conn.get_dashboard_variable_value("WebUIAppAdmin", "Current_Sites")) + + # dashboard_name = "PyBESAPITest" + # var_name = "TestVar" + + # print( + # bes_conn.set_dashboard_variable_value( + # dashboard_name, var_name, "dashboard_variable_get_value.py 12345678" + # ) + # ) + + # print(bes_conn.get_dashboard_variable_value(dashboard_name, var_name)) + + # print(bes_conn.delete(f"dashboardvariable/{dashboard_name}/{var_name}")) + + +if __name__ == "__main__": + main() diff --git a/examples/delete_content_by_id.py b/examples/delete_content_by_id.py new file mode 100644 index 0000000..e28e31a --- /dev/null +++ b/examples/delete_content_by_id.py @@ -0,0 +1,35 @@ +""" +Delete tasks by id. + +- https://developer.bigfix.com/rest-api/api/task.html + +requires `besapi`, install with command `pip install besapi` +""" + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + ids = [0, "0"] + + # https://developer.bigfix.com/rest-api/api/task.html + # task/{site type}/{site name}/{task id} + + site_type = "custom" + site_name = "Demo" + content_type = "task" + + for content_id in ids: + rest_url = f"{content_type}/{site_type}/{site_name}/{int(content_id)}" + print(f"Deleting: {rest_url}") + result = bes_conn.delete(rest_url) + print(result.text) + + +if __name__ == "__main__": + main() diff --git a/examples/export_all_sites.py b/examples/export_all_sites.py new file mode 100644 index 0000000..1f9ed9b --- /dev/null +++ b/examples/export_all_sites.py @@ -0,0 +1,32 @@ +""" +This will export all bigfix sites to a folder called `export`. + +This is equivalent of running `python -m besapi export_all_sites` +""" + +import os + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + # Create export folder if it doesn't exist + try: + os.mkdir("export") + except FileExistsError: + pass + + # Change working directory to export folder + os.chdir("export") + + # Export all sites + bes_conn.export_all_sites() + + +if __name__ == "__main__": + main() diff --git a/examples/export_all_sites_plugin.py b/examples/export_all_sites_plugin.py new file mode 100644 index 0000000..0378ceb --- /dev/null +++ b/examples/export_all_sites_plugin.py @@ -0,0 +1,10 @@ +""" +This will export all custom bigfix sites to a folder called `export`. + +This has been moved to: https://github.com/jgstew/bigfix_plugin_export +""" + +import sys + +print("this has been moved to: https://github.com/jgstew/bigfix_plugin_export") +sys.exit(1) diff --git a/examples/export_bes_by_relevance.py b/examples/export_bes_by_relevance.py new file mode 100644 index 0000000..6f65083 --- /dev/null +++ b/examples/export_bes_by_relevance.py @@ -0,0 +1,40 @@ +""" +Example export bes files by session relevance result. + +requires `besapi`, install with command `pip install besapi` +""" + +import time + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + print(bes_conn.last_connected) + + # change the relevance here to adjust which content gets exported: + fixlets_rel = 'custom bes fixlets whose(name of it as lowercase contains "oracle")' + + # this does not currently work with things in the actionsite: + session_relevance = f'(type of it as lowercase & "/custom/" & name of site of it & "/" & id of it as string) of {fixlets_rel}' + + result = bes_conn.session_relevance_array(session_relevance) + + for item in result: + print(item) + # export bes file: + print(bes_conn.export_item_by_resource(item, "./tmp/")) + + +if __name__ == "__main__": + # Start the timer + start_time = time.time() + main() + # Calculate the elapsed time + elapsed_time = time.time() - start_time + print(f"Execution time: {elapsed_time:.2f} seconds") diff --git a/examples/export_bes_by_relevance_async.py b/examples/export_bes_by_relevance_async.py new file mode 100644 index 0000000..9278dc6 --- /dev/null +++ b/examples/export_bes_by_relevance_async.py @@ -0,0 +1,131 @@ +""" +Example export bes files by session relevance result. + +requires `besapi`, install with command `pip install besapi` +""" + +import asyncio +import configparser +import os +import time + +import aiofiles +import aiohttp + +import besapi + + +def get_bes_pass_using_config_file(conf_file=None): + """ + Read connection values from config file + return besapi connection. + """ + config_paths = [ + "/etc/besapi.conf", + os.path.expanduser("~/besapi.conf"), + os.path.expanduser("~/.besapi.conf"), + "besapi.conf", + ] + # if conf_file specified, then only use that: + if conf_file: + config_paths = [conf_file] + + configparser_instance = configparser.ConfigParser() + + found_config_files = configparser_instance.read(config_paths) + + if found_config_files and configparser_instance: + print("Attempting BESAPI Connection using config file:", found_config_files) + + try: + BES_PASSWORD = configparser_instance.get("besapi", "BES_PASSWORD") + except BaseException: # pylint: disable=broad-except + BES_PASSWORD = None + + return BES_PASSWORD + + +async def fetch(session, url): + """Get items async.""" + async with session.get(url) as response: + response_text = await response.text() + + # Extract the filename from the URL + url_parts = url.split("/") + + file_dir = "./tmp/" + url_parts[-2] + "/" + url_parts[-4] + + os.makedirs(file_dir, exist_ok=True) + + filename = file_dir + "/" + url_parts[-1] + ".bes" + + # Write the response to a file asynchronously + async with aiofiles.open(filename, "w") as file: + await file.write(response_text) + + print(f"{filename} downloaded and saved.") + + +async def main(): + """Execution starts here.""" + print("main()") + + # Create a semaphore with a maximum concurrent requests + semaphore = asyncio.Semaphore(3) + + # TODO: get max mod time of existing bes files: + # https://github.com/jgstew/tools/blob/master/Python/get_max_time_bes_files.py + + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + print(bes_conn.last_connected) + + # change the relevance here to adjust which content gets exported: + fixlets_rel = 'custom bes fixlets whose(name of it as lowercase contains "oracle")' + + # this does not currently work with things in the actionsite: + session_relevance = f'(type of it as lowercase & "/custom/" & name of site of it & "/" & id of it as string) of {fixlets_rel}' + + result = bes_conn.session_relevance_array(session_relevance) + + print(f"{len(result)} items to export...") + + absolute_urls = [] + + for item in result: + absolute_urls.append(bes_conn.url(item)) + + # Create a session for making HTTP requests + async with aiohttp.ClientSession( + auth=aiohttp.BasicAuth(bes_conn.username, get_bes_pass_using_config_file()), + connector=aiohttp.TCPConnector(ssl=False), + ) as session: + # Define a list of URLs to fetch + urls = absolute_urls + + # Create a list to store the coroutines for fetching the URLs + tasks = [] + + # Create coroutines for fetching each URL + for url in urls: + # Acquire the semaphore before starting the request + async with semaphore: + task = asyncio.ensure_future(fetch(session, url)) + tasks.append(task) + + # Wait for all the coroutines to complete + await asyncio.gather(*tasks) + + +if __name__ == "__main__": + # Start the timer + start_time = time.time() + + # Run the main function + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) + + # Calculate the elapsed time + elapsed_time = time.time() - start_time + print(f"Execution time: {elapsed_time:.2f} seconds") diff --git a/examples/export_bes_by_relevance_threads.py b/examples/export_bes_by_relevance_threads.py new file mode 100644 index 0000000..473fcc7 --- /dev/null +++ b/examples/export_bes_by_relevance_threads.py @@ -0,0 +1,44 @@ +""" +Example export bes files by session relevance result. + +requires `besapi`, install with command `pip install besapi` +""" + +import concurrent.futures +import time + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + print(bes_conn.last_connected) + + # change the relevance here to adjust which content gets exported: + fixlets_rel = 'custom bes fixlets whose(name of it as lowercase contains "oracle")' + + # this does not currently work with things in the actionsite: + session_relevance = f'(type of it as lowercase & "/custom/" & name of site of it & "/" & id of it as string) of {fixlets_rel}' + + result = bes_conn.session_relevance_array(session_relevance) + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + futures = [ + executor.submit(bes_conn.export_item_by_resource, item, "./tmp/") + for item in result + ] + # Wait for all tasks to complete + concurrent.futures.wait(futures) + + +if __name__ == "__main__": + # Start the timer + start_time = time.time() + main() + # Calculate the elapsed time + elapsed_time = time.time() - start_time + print(f"Execution time: {elapsed_time:.2f} seconds") diff --git a/examples/fixlet_add_mime_field.py b/examples/fixlet_add_mime_field.py new file mode 100644 index 0000000..3ec287f --- /dev/null +++ b/examples/fixlet_add_mime_field.py @@ -0,0 +1,286 @@ +r""" +Add a mime field to custom content returned by session relevance. + +This example adds a mime field to custom fixlets, tasks, baselines, and analyses that +contain the slower WMI or descendant inspector calls in their relevance, and do not already have +the mime field. + +Other candidates for eval mime field addition due to slow relevance: +- anything examining log files + - (it as lowercase contains ".log%22" AND it as lowercase contains " lines ") +- anything examining large files +- things enumerating `of active device` or `of smbios` +- things enumerating the PATH environment variable or other environment variables with many entries + - ` substrings separated by (";";":") of values of (variables "PATH" of it` +- complicated xpaths of many files +- getting maximum or maxima of modification times of files +- ` of scheduled tasks` +- ` of folders of folders ` +- ` image files of processes ` +- `(now - modification time of it) < ` +- ` of active director` +- ` of folders "Logs" of folders "__Global" of ` +- complicated package relevance: rpm or debian package or winrt package +- event log relevance: `exists matches (case insensitive regex "records? of[a-z0-9]* event log") of it` +- hashing: `exists matches (case insensitive regex "(md5|sha1|sha2?_?\d{3,4})s? +of +") of it` + +Use this session relevance to find fixlets missing the mime field: +- https://bigfix.me/relevance/details/3023816 +""" + +import logging +import ntpath +import os +import platform +import sys +import urllib.parse + +import lxml.etree + +import besapi +import besapi.plugin_utilities + +MIME_FIELD_NAME = "x-relevance-evaluation-period" +MIME_FIELD_VALUE = "06:00:00" # 6 hours + +# Must return fixlet / task / baseline / analysis objects: +session_relevance_multiple_fixlets = r"""custom bes fixlets whose(exists (it as lowercase) whose(it contains " wmi" OR it contains " descendant" OR it contains " of scheduled task" OR it contains "image files of processes" OR it contains " of active director" OR it contains "of active device" OR it contains "of smbios" OR exists first matches (case insensitive regex "records? of[a-z0-9]* event log") of it OR exists first matches (case insensitive regex "(md5|sha1|sha2?_?\d{3,4})s? +of +") of it OR (it as lowercase contains ".log%22" AND it as lowercase contains " lines ")) of relevance of it AND not exists mime fields "x-relevance-evaluation-period" of it)""" +# custom bes fixlets whose(not exists mime fields "x-relevance-evaluation-period" of it) whose(exists (it as lowercase) whose(exists matches (case insensitive regex "records? of[a-z0-9]* event log") of it OR (it contains ".log%22" AND it contains " lines ") OR (it contains " substrings separated by (%22;%22;%22:%22) of values of") OR it contains " wmi" OR it contains " descendant") of relevance of it) + +__version__ = "0.1.1" +verbose = 0 +bes_conn = None +invoke_folder = None +sites_no_permissions = [] + + +def get_invoke_folder(verbose=0): + """Get the folder the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +def get_invoke_file_name(verbose=0): + """Get the filename the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_file_path = sys.executable + else: + if verbose: + print("running in a normal Python process") + invoke_file_path = __file__ + + if verbose: + print(f"invoke_file_path = {invoke_file_path}") + + # get just the file name, return without file extension: + return os.path.splitext(ntpath.basename(invoke_file_path))[0] + + +def fixlet_xml_add_mime( + fixlet_xml: str, mime_field_name: str, mime_field_value: str +) -> str | None: + """Update fixlet XML to add mime field.""" + new_mime = f""" + {mime_field_name} + {mime_field_value} + """ + + # need to check if mime field already exists in case session relevance is behind + if mime_field_name in str(fixlet_xml).lower(): + logging.warning("Skipping item, it already has mime field") + return None + + root_xml = lxml.etree.fromstring(fixlet_xml) + + # get first MIMEField + xml_first_mime = root_xml.find(".//*/MIMEField") + + xml_container = xml_first_mime.getparent() + + # new mime to set relevance eval to once an hour: + new_mime_lxml = lxml.etree.XML(new_mime) + + # insert new mime BEFORE first MIME + # https://stackoverflow.com/questions/7474972/append-element-after-another-element-using-lxml + xml_container.insert(xml_container.index(xml_first_mime), new_mime_lxml) + + # validate against XSD + besapi.besapi.validate_xsd( + lxml.etree.tostring(root_xml, encoding="utf-8", xml_declaration=False) + ) + + return lxml.etree.tostring(root_xml, encoding="utf-8", xml_declaration=True).decode( + "utf-8" + ) + + +def get_content_restresult( + bes_conn: besapi.besapi.BESConnection, fixlet_site_name: str, fixlet_id: int +) -> besapi.besapi.RESTResult | None: + """Get fixlet content by ID and site name. + + This works with fixlets, tasks, baselines, and analyses. + Might work with other content types too. + """ + # URL encode the site name to handle special characters + fixlet_site_name = urllib.parse.quote(fixlet_site_name, safe="") + + site_path = "custom/" + + # site path must be empty string for ActionSite + if fixlet_site_name == "ActionSite": + site_path = "" + # site name must be "master" for ActionSite + fixlet_site_name = "master" + + fixlet_content = bes_conn.get_content_by_resource( + f"fixlet/{site_path}{fixlet_site_name}/{fixlet_id}" + ) + return fixlet_content + + +def put_updated_xml( + bes_conn: besapi.besapi.BESConnection, + fixlet_site_name: str, + fixlet_id: int, + updated_xml: str, +) -> besapi.besapi.RESTResult | None: + """PUT updated XML back to RESTAPI resource to modify. + + This works with fixlets, tasks, baselines, and analyses. + Might work with other content types too. + """ + # URL encode the site name to handle special characters + fixlet_site_name = urllib.parse.quote(fixlet_site_name, safe="") + + # this type works for fixlets, tasks, and baselines + fixlet_type = "fixlet" + + if "" in updated_xml: + fixlet_type = "analysis" + + site_path = "custom/" + + # site path must be empty string for ActionSite + if fixlet_site_name == "ActionSite": + site_path = "" + # site name must be "master" for ActionSite + fixlet_site_name = "master" + + try: + # PUT changed XML back to RESTAPI resource to modify + update_result = bes_conn.put( + f"{fixlet_type}/{site_path}{fixlet_site_name}/{fixlet_id}", + updated_xml, + headers={"Content-Type": "application/xml"}, + ) + return update_result + except PermissionError as exc: + logging.error( + "PermissionError updating fixlet %s/%d:%s", fixlet_site_name, fixlet_id, exc + ) + sites_no_permissions.append(fixlet_site_name) + + return None + + +def main(): + """Execution starts here. + + This is designed to be run as a plugin, but can also be run as a standalone script. + """ + print("fixlet_add_mime_field main()") + + parser = besapi.plugin_utilities.setup_plugin_argparse() + + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + # allow set global scoped vars + global bes_conn, verbose, invoke_folder + verbose = args.verbose + + # get folder the script was invoked from: + invoke_folder = get_invoke_folder(verbose) + + log_file_path = os.path.join(invoke_folder, get_invoke_file_name(verbose) + ".log") + + logging_config = besapi.plugin_utilities.get_plugin_logging_config( + log_file_path, verbose, args.console + ) + + logging.basicConfig(**logging_config) + + logging.log(99, "---------- Starting New Session -----------") + logging.debug("invoke folder: %s", invoke_folder) + logging.debug("%s's version: %s", get_invoke_file_name(verbose), __version__) + logging.debug("BESAPI Module version: %s", besapi.besapi.__version__) + logging.debug("Python version: %s", platform.sys.version) + logging.warning( + "Might get permissions error if not run as a MO or an account with write access to all affected custom content" + ) + + bes_conn = besapi.plugin_utilities.get_besapi_connection(args) + + results = bes_conn.session_relevance_json_array( + "(id of it, name of site of it) of " + session_relevance_multiple_fixlets + ) + + logging.debug(results) + + for result in results: + fixlet_id = result[0] + fixlet_site_name = result[1] + fixlet_site_name_safe = urllib.parse.quote(fixlet_site_name, safe="") + + if fixlet_site_name_safe in sites_no_permissions: + logging.warning( + "Skipping item %d, no permissions to update content in site '%s'", + fixlet_id, + fixlet_site_name, + ) + continue + + logging.debug(fixlet_id, fixlet_site_name) + + fixlet_content = get_content_restresult(bes_conn, fixlet_site_name, fixlet_id) + + updated_xml = fixlet_xml_add_mime( + fixlet_content.besxml, MIME_FIELD_NAME, MIME_FIELD_VALUE + ) + + if updated_xml is None: + # skip, already has mime field + continue + + logging.debug("updated_xml:\n%s", updated_xml) + + update_result = put_updated_xml( + bes_conn, fixlet_site_name, fixlet_id, updated_xml + ) + + if update_result is not None: + logging.info("Updated fixlet %d in site %s", fixlet_id, fixlet_site_name) + + logging.log(99, "---------- Ending Session -----------") + + +if __name__ == "__main__": + main() diff --git a/examples/get_upload.py b/examples/get_upload.py new file mode 100644 index 0000000..4dd6736 --- /dev/null +++ b/examples/get_upload.py @@ -0,0 +1,30 @@ +""" +Get existing upload info by sha1 and filename. + +requires `besapi`, install with command `pip install besapi` +""" + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + result = bes_conn.get_upload( + "test_besapi_upload.txt", "092bd8ef7b91507bb3848640ef47bb392e7d95b1" + ) + + print(result) + + if result: + print(bes_conn.parse_upload_result_to_prefetch(result)) + print("Info: Upload found.") + else: + print("ERROR: Upload not found!") + + +if __name__ == "__main__": + main() diff --git a/examples/import_bes_file.py b/examples/import_bes_file.py new file mode 100644 index 0000000..c8e393d --- /dev/null +++ b/examples/import_bes_file.py @@ -0,0 +1,35 @@ +""" +Import bes file into site. + +- https://developer.bigfix.com/rest-api/api/import.html + +requires `besapi`, install with command `pip install besapi` +""" + +import besapi + +SITE_PATH = "custom/demo" +BES_FILE_PATH = "examples/example.bes" + + +def main(): + """Execution starts here.""" + print("main()") + + print(f"besapi version: {besapi.__version__}") + + if not hasattr(besapi.besapi.BESConnection, "import_bes_to_site"): + print("version of besapi is too old, must be >= 3.1.6") + return None + + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + # requires besapi 3.1.6 + result = bes_conn.import_bes_to_site(BES_FILE_PATH, SITE_PATH) + + print(result) + + +if __name__ == "__main__": + main() diff --git a/examples/import_bes_files.py b/examples/import_bes_files.py new file mode 100644 index 0000000..27b9701 --- /dev/null +++ b/examples/import_bes_files.py @@ -0,0 +1,47 @@ +""" +Import bes file into site. + +- https://developer.bigfix.com/rest-api/api/import.html + +requires `besapi`, install with command `pip install besapi` +""" + +import glob + +import besapi + +SITE_PATH = "custom/demo" + +# by default, get all BES files in examples folder: +BES_FOLDER_GLOB = "./examples/*.bes" + + +def main(): + """Execution starts here.""" + print("main()") + + print(f"besapi version: {besapi.__version__}") + + if not hasattr(besapi.besapi.BESConnection, "import_bes_to_site"): + print("version of besapi is too old, must be >= 3.1.6") + return None + + files = glob.glob(BES_FOLDER_GLOB) + + if len(files) > 0: + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + else: + print(f"No BES Files found using glob: {BES_FOLDER_GLOB}") + return None + + # import all found BES files into site: + for f in files: + print(f"Importing file: {f}") + # requires besapi 3.1.6 + result = bes_conn.import_bes_to_site(f, SITE_PATH) + print(result) + + +if __name__ == "__main__": + print(main()) diff --git a/examples/mailbox_files_create.py b/examples/mailbox_files_create.py new file mode 100644 index 0000000..f43149f --- /dev/null +++ b/examples/mailbox_files_create.py @@ -0,0 +1,41 @@ +""" +Get set of mailbox files. + +requires `besapi`, install with command `pip install besapi` +""" + +import os + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + session_rel = 'tuple string items 0 of concatenations ", " of (it as string) of ids of bes computers whose(root server flag of it AND now - last report time of it < 30 * day)' + + # get root server computer id: + root_id = int(bes_conn.session_relevance_string(session_rel).strip()) + + print(root_id) + + file_path = "examples/mailbox_files_create.py" + file_name = os.path.basename(file_path) + + # https://developer.bigfix.com/rest-api/api/mailbox.html + + # Example Header:: Content-Disposition: attachment; filename="file.xml" + headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} + with open(file_path, "rb") as f: + result = bes_conn.post( + bes_conn.url(f"mailbox/{root_id}"), data=f, headers=headers + ) + + print(result) + + +if __name__ == "__main__": + main() diff --git a/examples/mailbox_files_list.py b/examples/mailbox_files_list.py new file mode 100644 index 0000000..bf7e9b8 --- /dev/null +++ b/examples/mailbox_files_list.py @@ -0,0 +1,30 @@ +""" +Get set of mailbox files. + +requires `besapi`, install with command `pip install besapi` +""" + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + session_rel = 'tuple string items 0 of concatenations ", " of (it as string) of ids of bes computers whose(root server flag of it AND now - last report time of it < 30 * day)' + + # get root server computer id: + root_id = int(bes_conn.session_relevance_string(session_rel).strip()) + + print(root_id) + + # list mailbox files: + result = bes_conn.get(bes_conn.url(f"mailbox/{root_id}")) + + print(result) + + +if __name__ == "__main__": + main() diff --git a/examples/parameters_secure_sourced_fixlet_action.py b/examples/parameters_secure_sourced_fixlet_action.py new file mode 100644 index 0000000..6161098 --- /dev/null +++ b/examples/parameters_secure_sourced_fixlet_action.py @@ -0,0 +1,56 @@ +""" +Example sourced fixlet action with parameters. + +requires `besapi`, install with command `pip install besapi` +""" + +import besapi + +# reference: https://software.bigfix.com/download/bes/100/util/BES10.0.7.52.xsd +# https://forum.bigfix.com/t/api-sourcedfixletaction-including-end-time/37117/2 +# https://forum.bigfix.com/t/secret-parameter-actions/38847/13 + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + # SessionRelevance for root server id: + session_relevance = """ + maxima of ids of bes computers + whose(root server flag of it AND now - last report time of it < 1 * day) + """ + + root_server_id = int(bes_conn.session_relevance_string(session_relevance)) + + CONTENT_XML = ( + r""" + + + + BES Support + 15 + Action1 + + + """ + + str(root_server_id) + + r""" + + test_value + test_secure_value + Test parameters - secure - SourcedFixletAction - BES Clients Have Incorrect Clock Time + + +""" + ) + + result = bes_conn.post("actions", CONTENT_XML) + + print(result) + + +if __name__ == "__main__": + main() diff --git a/examples/parameters_sourced_fixlet_action.py b/examples/parameters_sourced_fixlet_action.py new file mode 100644 index 0000000..b738469 --- /dev/null +++ b/examples/parameters_sourced_fixlet_action.py @@ -0,0 +1,43 @@ +""" +Example sourced fixlet action with parameters. + +requires `besapi`, install with command `pip install besapi` +""" + +import besapi + +# reference: https://software.bigfix.com/download/bes/100/util/BES10.0.7.52.xsd +# https://forum.bigfix.com/t/api-sourcedfixletaction-including-end-time/37117/2 +# https://forum.bigfix.com/t/secret-parameter-actions/38847/13 + +CONTENT_XML = r""" + + + + BES Support + 15 + Action1 + + + BIGFIX + + test_value_demo + Test parameters - SourcedFixletAction - BES Clients Have Incorrect Clock Time + + +""" + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + result = bes_conn.post("actions", CONTENT_XML) + + print(result) + + +if __name__ == "__main__": + main() diff --git a/examples/relay_info.py b/examples/relay_info.py new file mode 100644 index 0000000..f42cde0 --- /dev/null +++ b/examples/relay_info.py @@ -0,0 +1,184 @@ +""" +This will get info about relays in the environment. + +requires `besapi`, install with command `pip install besapi` + +Example Usage: +python relay_info.py -r https://localhost:52311/api -u API_USER --days 90 -p API_PASSWORD + +References: +- https://developer.bigfix.com/rest-api/api/admin.html +- https://github.com/jgstew/besapi/blob/master/examples/rest_cmd_args.py +- https://github.com/jgstew/tools/blob/master/Python/locate_self.py +""" + +import json +import logging +import logging.handlers +import ntpath +import os +import platform +import sys + +import besapi +import besapi.plugin_utilities + +__version__ = "1.1.1" +verbose = 0 +bes_conn = None +invoke_folder = None + + +def get_invoke_folder(verbose=0): + """Get the folder the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +def get_invoke_file_name(verbose=0): + """Get the filename the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_file_path = sys.executable + else: + if verbose: + print("running in a normal Python process") + invoke_file_path = __file__ + + if verbose: + print(f"invoke_file_path = {invoke_file_path}") + + # get just the file name, return without file extension: + return os.path.splitext(ntpath.basename(invoke_file_path))[0] + + +def main(): + """Execution starts here.""" + print("main() start") + + print("NOTE: this script requires besapi v3.3.3+ due to besapi.plugin_utilities") + print( + "WARNING: results may be incorrect if not run as a MO or an account without scope of all computers" + ) + + parser = besapi.plugin_utilities.setup_plugin_argparse() + + # add additional arg specific to this script: + parser.add_argument( + "-d", + "--days", + help="last report days to filter on", + required=False, + type=int, + default=900, + ) + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + # allow set global scoped vars + global bes_conn, verbose, invoke_folder + verbose = args.verbose + + # get folder the script was invoked from: + invoke_folder = get_invoke_folder() + + log_file_path = os.path.join( + get_invoke_folder(verbose), get_invoke_file_name(verbose) + ".log" + ) + + print(log_file_path) + + logging_config = besapi.plugin_utilities.get_plugin_logging_config( + log_file_path, verbose, args.console + ) + + logging.basicConfig(**logging_config) + + logging.log(99, "---------- Starting New Session -----------") + logging.debug("invoke folder: %s", invoke_folder) + logging.debug("%s's version: %s", get_invoke_file_name(verbose), __version__) + logging.debug("BESAPI Module version: %s", besapi.besapi.__version__) + logging.debug("Python version: %s", platform.sys.version) + + bes_conn = besapi.plugin_utilities.get_besapi_connection(args) + + # defaults to 900 days: + last_report_days_filter = args.days + + # get relay info: + session_relevance = f"""(multiplicity of it, it) of unique values of (it as string) of (relay selection method of it | "NoRelayMethod" , relay server of it | "NoRelayServer") of bes computers whose(now - last report time of it < {last_report_days_filter} * day)""" + results = bes_conn.session_relevance_string(session_relevance) + + logging.info("Relay Info:\n%s", results) + + session_relevance = f"""(multiplicity of it, it) of unique values of (it as string) of (relay selection method of it | "NoRelayMethod" , relay server of it | "NoRelayServer", relay hostname of it | "NoRelayHostname", id of it | 0) of bes computers whose(now - last report time of it < {last_report_days_filter} * day AND relay server flag of it)""" + results = bes_conn.session_relevance_string(session_relevance) + + logging.info("Info on Relays:\n%s", results) + + session_relevance = f"""unique values of values of client settings whose(name of it = "_BESClient_Relay_NameOverride") of bes computers whose(now - last report time of it < {last_report_days_filter} * day)""" + results = bes_conn.session_relevance_string(session_relevance) + + logging.info("Relay name override values:\n%s", results) + + session_relevance = f"""(multiplicity of it, it) of unique values of values of client settings whose(name of it = "_BESRelay_Register_Affiliation_AdvertisementList") of bes computers whose(now - last report time of it < {last_report_days_filter} * day)""" + results = bes_conn.session_relevance_string(session_relevance) + + logging.info("Relay_Register_Affiliation values:\n%s", results) + + session_relevance = f"""(multiplicity of it, it) of unique values of values of client settings whose(name of it = "_BESClient_Register_Affiliation_SeekList") of bes computers whose(now - last report time of it < {last_report_days_filter} * day)""" + results = bes_conn.session_relevance_string(session_relevance) + + logging.info("Client_Register_Affiliation_Seek values:\n%s", results) + + # this should require MO: + results = bes_conn.get("admin/masthead/parameters") + + logging.info( + "masthead parameters:\n%s", + json.dumps(results.besdict["MastheadParameters"], indent=2), + ) + + # this should require MO: + results = bes_conn.get("admin/fields") + + logging.info( + "Admin Fields:\n%s", json.dumps(results.besdict["AdminField"], indent=2) + ) + + # this should require MO: + results = bes_conn.get("admin/options") + + logging.info( + "Admin Options:\n%s", json.dumps(results.besdict["SystemOptions"], indent=2) + ) + + # this should require MO: + results = bes_conn.get("admin/reports") + + logging.info( + "Admin Report Options:\n%s", + json.dumps(results.besdict["ClientReports"], indent=2), + ) + + logging.log(99, "---------- Ending Session -----------") + + +if __name__ == "__main__": + main() diff --git a/examples/rest_cmd_args.py b/examples/rest_cmd_args.py new file mode 100644 index 0000000..1d77903 --- /dev/null +++ b/examples/rest_cmd_args.py @@ -0,0 +1,60 @@ +""" +Example session relevance results from a string. + +requires `besapi`, install with command `pip install besapi` + +Example Usage: +python rest_cmd_args.py -r https://localhost:52311/api -u API_USER -p API_PASSWORD +""" + +import argparse +import json +import logging + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + + parser = argparse.ArgumentParser( + description="Provide command line arguments for REST URL, username, and password" + ) + parser.add_argument( + "-besserver", "--besserver", help="Specify the BES URL", required=False + ) + parser.add_argument("-r", "--rest-url", help="Specify the REST URL", required=True) + parser.add_argument("-u", "--user", help="Specify the username", required=True) + parser.add_argument("-p", "--password", help="Specify the password", required=True) + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + rest_url = args.rest_url + + # normalize url to https://HostOrIP:52311 + if rest_url.endswith("/api"): + rest_url = rest_url.replace("/api", "") + + try: + bes_conn = besapi.besapi.BESConnection(args.user, args.password, rest_url) + except (ConnectionRefusedError, besapi.besapi.requests.exceptions.ConnectionError): + bes_conn = besapi.besapi.BESConnection(args.user, args.password, args.besserver) + + # get unique device OSes + session_relevance = 'unique values of (it as trimmed string) of (preceding text of last " (" of it | it) of operating systems of bes computers' + + data = {"output": "json", "relevance": session_relevance} + + result = bes_conn.post(bes_conn.url("query"), data) + + json_result = json.loads(str(result)) + + json_string = json.dumps(json_result, indent=2) + + print(json_string) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.WARNING) + main() diff --git a/examples/send_message_all_computers.py b/examples/send_message_all_computers.py new file mode 100644 index 0000000..25805a7 --- /dev/null +++ b/examples/send_message_all_computers.py @@ -0,0 +1,114 @@ +"""This will send a BigFix UI message to ALL computers!""" + +import besapi + +MESSAGE_TITLE = """Test message from besapi""" +MESSAGE = MESSAGE_TITLE + +CONTENT_XML = rf""" + + + Send Message: {MESSAGE_TITLE} + = ("3.1.0" as version)) of key "HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall" of x32 registry) else if (mac of operating system) then (exists application "BigFixSSA.app" whose (version of it >= "3.1.0")) else false) AND (exists line whose (it = "disableMessagesTab: false") of file (if (windows of operating system) then (pathname of parent folder of parent folder of client) & "\BigFix Self Service Application\resources\ssa.config" else "/Library/Application Support/BigFix/BigFixSSA/ssa.config"))]]> + //Nothing to do + + + {MESSAGE_TITLE} + true + + {MESSAGE}

]]>
+ false + false + false + ForceToRun + Interval + P3D + false +
+ false + false + false + false + false + false + NoRequirement + AllUsers + false + false + false + true + 3 + false + false + false + false + + false +
+ + false + false + + false + false + false + false + false + false + + false + + false + + false + false + false + false + false + false + false + false + false + false + false + false + false + false + + false + false + false + false + false + + false + false + false + false + + true + + true + + + action-ui-metadata + {{"type":"notification","sender":"broadcast","expirationDays":3}} + +
+
+""" + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + result = bes_conn.post("actions", CONTENT_XML) + + print(result) + + +if __name__ == "__main__": + main() diff --git a/examples/serversettings.cfg b/examples/serversettings.cfg new file mode 100644 index 0000000..f7729f8 --- /dev/null +++ b/examples/serversettings.cfg @@ -0,0 +1,11 @@ +# this file is modeled after the clientsettings.cfg but for bigfix server admin fields +# see the script that uses this file here: +# https://github.com/jgstew/besapi/blob/master/examples/serversettings.py +passwordComplexityDescription=Passwords must contain 12 characters or more, both uppercase and lowercase letters, and at least 1 digit. +passwordComplexityRegex=(?=.*[[:lower:]])(?=.*[[:upper:]])(?=.*[[:digit:]]).{12,} +disableNmoManualGroups = 1 +includeSFIDsInBaselineActions= 1 +requireConfirmAction =1 +loginTimeoutSeconds=7200 +timeoutLockMinutes=345 +timeoutLogoutMinutes=360 diff --git a/examples/serversettings.py b/examples/serversettings.py new file mode 100644 index 0000000..54c0276 --- /dev/null +++ b/examples/serversettings.py @@ -0,0 +1,245 @@ +""" +Set server settings like clientsettings.cfg does for client settings. + +See example serversettings.cfg file here: +- https://github.com/jgstew/besapi/blob/master/examples/serversettings.cfg + +requires `besapi`, install with command `pip install besapi` + +Example Usage: +python serversettings.py -r https://localhost:52311/api -u API_USER -p API_PASSWORD + +References: +- https://developer.bigfix.com/rest-api/api/admin.html +- https://github.com/jgstew/besapi/blob/master/examples/rest_cmd_args.py +- https://github.com/jgstew/tools/blob/master/Python/locate_self.py +""" + +import argparse +import configparser +import getpass +import logging +import logging.handlers +import os +import platform +import sys + +import besapi + +__version__ = "0.0.1" +verbose = 0 +bes_conn = None +invoke_folder = None +config_ini = None + + +def get_invoke_folder(): + """Get the folder the script was invoked from. + + References: + - https://github.com/jgstew/tools/blob/master/Python/locate_self.py + """ + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +def get_config(path="serversettings.cfg"): + """Load config from ini file.""" + + # example config: https://github.com/jgstew/besapi/blob/master/examples/serversettings.cfg + + if not (os.path.isfile(path) and os.access(path, os.R_OK)): + path = os.path.join(invoke_folder, path) + + logging.info("loading config from: `%s`", path) + + if not (os.path.isfile(path) and os.access(path, os.R_OK)): + raise FileNotFoundError(path) + + configparser_instance = configparser.ConfigParser() + + try: + # try read config file with section headers: + with open(path) as stream: + configparser_instance.read_string(stream.read()) + except configparser.MissingSectionHeaderError: + # if section header missing, add a fake one: + with open(path) as stream: + configparser_instance.read_string( + "[bigfix_server_admin_fields]\n" + stream.read() + ) + + config_ini = list(configparser_instance.items("bigfix_server_admin_fields")) + + logging.debug(config_ini) + + return config_ini + + +def test_file_exists(path): + """Return true if file exists.""" + + if not (os.path.isfile(path) and os.access(path, os.R_OK)): + path = os.path.join(invoke_folder, path) + + logging.info("testing if exists: `%s`", path) + + if os.path.isfile(path) and os.access(path, os.R_OK) and os.access(path, os.W_OK): + return path + + return False + + +def get_settings_xml(config): + """Turn config into settings xml.""" + + settings_xml = "" + + for setting in config: + settings_xml += f"""\n +\t{setting[0]} +\t{setting[1]} +""" + + settings_xml = ( + """""" + + settings_xml + + "\n" + ) + + return settings_xml + + +def post_settings(settings_xml): + """Post settings to server.""" + + return bes_conn.post("admin/fields", settings_xml) + + +def main(): + """Execution starts here.""" + print("main() start") + + parser = argparse.ArgumentParser( + description="Provide command line arguments for REST URL, username, and password" + ) + parser.add_argument( + "-v", + "--verbose", + help="Set verbose output", + required=False, + action="count", + default=0, + ) + parser.add_argument( + "-besserver", "--besserver", help="Specify the BES URL", required=False + ) + parser.add_argument("-r", "--rest-url", help="Specify the REST URL", required=False) + parser.add_argument("-u", "--user", help="Specify the username", required=False) + parser.add_argument("-p", "--password", help="Specify the password", required=False) + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + # allow set global scoped vars + global bes_conn, verbose, config_ini, invoke_folder + verbose = args.verbose + + # get folder the script was invoked from: + invoke_folder = get_invoke_folder() + + # set different log levels: + log_level = logging.INFO + if verbose: + log_level = logging.INFO + if verbose > 1: + log_level = logging.DEBUG + + # get path to put log file in: + log_filename = os.path.join(invoke_folder, "serversettings.log") + + print(f"Log File Path: {log_filename}") + + handlers = [ + logging.handlers.RotatingFileHandler( + log_filename, maxBytes=5 * 1024 * 1024, backupCount=1 + ) + ] + + # log output to console if arg provided: + if verbose: + handlers.append(logging.StreamHandler()) + + # setup logging: + logging.basicConfig( + encoding="utf-8", + level=log_level, + format="%(asctime)s %(levelname)s:%(message)s", + handlers=handlers, + ) + logging.log(99, "----- Starting New Session ------") + logging.debug("invoke folder: %s", invoke_folder) + logging.debug("Python version: %s", platform.sys.version) + logging.debug("BESAPI Module version: %s", besapi.besapi.__version__) + logging.debug("this plugin's version: %s", __version__) + + password = args.password + + if not password: + logging.warning("Password was not provided, provide REST API password.") + print("Password was not provided, provide REST API password.") + password = getpass.getpass() + + # process args, setup connection: + rest_url = args.rest_url + + # normalize url to https://HostOrIP:52311 + if rest_url and rest_url.endswith("/api"): + rest_url = rest_url.replace("/api", "") + + try: + bes_conn = besapi.besapi.BESConnection(args.user, password, rest_url) + # bes_conn.login() + except ( + AttributeError, + ConnectionRefusedError, + besapi.besapi.requests.exceptions.ConnectionError, + ): + try: + bes_conn = besapi.besapi.BESConnection(args.user, password, args.besserver) + # handle case where args.besserver is None + # AttributeError: 'NoneType' object has no attribute 'startswith' + except AttributeError: + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + + # get config: + config_ini = get_config() + + logging.info("getting settings_xml from config info") + + # process settings + settings_xml = get_settings_xml(config_ini) + + logging.debug(settings_xml) + + rest_result = post_settings(settings_xml) + + logging.info(rest_result) + + logging.log(99, "----- Ending Session ------") + + +if __name__ == "__main__": + main() diff --git a/examples/session_relevance_array_compare.py b/examples/session_relevance_array_compare.py new file mode 100644 index 0000000..0c38efe --- /dev/null +++ b/examples/session_relevance_array_compare.py @@ -0,0 +1,88 @@ +""" +Get session relevance results from an array and compare the speed of each statement. + +requires `besapi`, install with command `pip install besapi` +""" + +import json +import time + +import besapi +import besapi.plugin_utilities + +session_relevance_array = ["True", "number of integers in (1,10000000)"] + + +def get_session_result(session_relevance, bes_conn, iterations=1): + """Get session relevance result and measure timing. + + returns a tuple: (timing_py, timing_eval, json_result) + """ + + data = {"output": "json", "relevance": session_relevance} + + total_time_py = 0 + total_time_eval = 0 + result = None + for i in range(iterations): + start_time = time.perf_counter() + result = bes_conn.post(bes_conn.url("query"), data) + end_time = time.perf_counter() + total_time_py += end_time - start_time + json_result = json.loads(str(result)) + total_time_eval += get_evaltime_ms(json_result) + + # Sleep only between iterations + if i < iterations - 1: + time.sleep(1) + + timing_py = total_time_py / iterations + + timing_eval = total_time_eval / iterations + + return timing_py, timing_eval, json_result + + +def get_evaltime_ms(json_result): + """Extract evaluation time in milliseconds from JSON result.""" + try: + return float(json_result["evaltime_ms"]) / 1000 + except KeyError: + return None + + +def string_truncate(text, max_length=70): + """Truncate a string to a maximum length and append ellipsis if truncated.""" + if len(text) > max_length: + return text[:max_length] + "..." + return text + + +def main(): + """Execution starts here.""" + print("main()") + # requires besapi v3.9.5 or later: + bes_conn = besapi.plugin_utilities.get_besapi_connection( + # besapi.plugin_utilities.get_plugin_args() + ) + # bes_conn.login() + + iterations = 2 + + print("\n---- Getting results from array: ----") + print(f"- timing averaged over {iterations} iterations -\n\n") + for session_relevance in session_relevance_array: + timing, timing_eval, result = get_session_result( + session_relevance, bes_conn, iterations + ) + print( + f"Results for '{string_truncate(session_relevance)}':\nNumber of results: {len(result['result'])}\n" + ) + print(f" API took: {timing:0.4f} seconds") + print(f"Eval time: {timing_eval:0.4f} seconds\n\n") + + print("---------------- END ----------------") + + +if __name__ == "__main__": + main() diff --git a/examples/session_relevance_from_file.py b/examples/session_relevance_from_file.py new file mode 100644 index 0000000..c89de17 --- /dev/null +++ b/examples/session_relevance_from_file.py @@ -0,0 +1,121 @@ +""" +Example session relevance results from a file. + +requires `besapi`, install with command `pip install besapi` +""" + +import logging +import ntpath +import os +import platform +import sys + +import besapi +import besapi.plugin_utilities + +__version__ = "1.2.1" +verbose = 0 +invoke_folder = None + + +def get_invoke_folder(verbose=0): + """Get the folder the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +def get_invoke_file_name(verbose=0): + """Get the filename the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_file_path = sys.executable + else: + if verbose: + print("running in a normal Python process") + invoke_file_path = __file__ + + if verbose: + print(f"invoke_file_path = {invoke_file_path}") + + # get just the file name, return without file extension: + return os.path.splitext(ntpath.basename(invoke_file_path))[0] + + +def main(): + """Execution starts here.""" + print("main()") + print("NOTE: this script requires besapi v3.3.3+ due to besapi.plugin_utilities") + + parser = besapi.plugin_utilities.setup_plugin_argparse() + + # add additional arg specific to this script: + parser.add_argument( + "-f", + "--file", + help="text file to read session relevance query from", + required=False, + type=str, + default="examples/session_relevance_query_input.txt", + ) + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + # allow set global scoped vars + global verbose, invoke_folder + verbose = args.verbose + + # get folder the script was invoked from: + invoke_folder = get_invoke_folder() + + log_file_path = os.path.join( + get_invoke_folder(verbose), get_invoke_file_name(verbose) + ".log" + ) + + print(log_file_path) + + logging_config = besapi.plugin_utilities.get_plugin_logging_config( + log_file_path, verbose, args.console + ) + + logging.basicConfig(**logging_config) + + logging.log(99, "---------- Starting New Session -----------") + logging.debug("invoke folder: %s", invoke_folder) + logging.debug("%s's version: %s", get_invoke_file_name(verbose), __version__) + logging.debug("BESAPI Module version: %s", besapi.besapi.__version__) + logging.debug("Python version: %s", platform.sys.version) + + bes_conn = besapi.plugin_utilities.get_besapi_connection(args) + + # args.file defaults to "examples/session_relevance_query_input.txt" + with open(args.file) as file: + session_relevance = file.read() + + result = bes_conn.session_relevance_string(session_relevance) + + logging.debug(result) + + with open("examples/session_relevance_query_output.txt", "w") as file_out: + file_out.write(result) + + logging.log(99, "---------- END -----------") + + +if __name__ == "__main__": + main() diff --git a/examples/session_relevance_from_file_json.py b/examples/session_relevance_from_file_json.py new file mode 100644 index 0000000..7c9a62b --- /dev/null +++ b/examples/session_relevance_from_file_json.py @@ -0,0 +1,43 @@ +""" +Example session relevance results in json format. + +This is much more fragile because it uses GET instead of POST + +requires `besapi`, install with command `pip install besapi` +""" + +import json + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + with open("examples/session_relevance_query_input.txt") as file: + session_relevance = file.read() + + # this requires besapi 3.5.3+ + result = bes_conn.session_relevance_json(session_relevance) + + if __debug__: + print(result) + + json_result = result + + json_string = json.dumps(json_result, indent=2) + + if __debug__: + print(json_string) + + with open( + "examples/session_relevance_query_from_file_output.json", "w" + ) as file_out: + file_out.write(json_string) + + +if __name__ == "__main__": + main() diff --git a/examples/session_relevance_from_string.py b/examples/session_relevance_from_string.py new file mode 100644 index 0000000..7ea7f84 --- /dev/null +++ b/examples/session_relevance_from_string.py @@ -0,0 +1,37 @@ +""" +Example session relevance results from a string. + +requires `besapi`, install with command `pip install besapi` +""" + +import json + +import besapi + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + session_relevance = '(multiplicity of it, it) of unique values of (it as trimmed string) of (preceding text of first "|" of it | it) of values of results of bes properties "Installed Applications - Windows"' + + data = {"output": "json", "relevance": session_relevance} + + result = bes_conn.post(bes_conn.url("query"), data) + + json_result = json.loads(str(result)) + + json_string = json.dumps(json_result, indent=2) + + print(json_string) + + with open( + "examples/session_relevance_query_from_string_output.json", "w" + ) as file_out: + file_out.write(json_string) + + +if __name__ == "__main__": + main() diff --git a/examples/session_relevance_query_input.txt b/examples/session_relevance_query_input.txt new file mode 100644 index 0000000..0fa66f5 --- /dev/null +++ b/examples/session_relevance_query_input.txt @@ -0,0 +1 @@ +("[%22" & it & "%22]") of concatenation "%22, %22" of names of bes computers diff --git a/examples/setup_server_plugin_service.py b/examples/setup_server_plugin_service.py new file mode 100644 index 0000000..34266a8 --- /dev/null +++ b/examples/setup_server_plugin_service.py @@ -0,0 +1,202 @@ +""" +Setup the root server server plugin service with creds provided. + +requires `besapi`, install with command `pip install besapi` + +Example Usage: +python setup_server_plugin_service.py -r https://localhost:52311/api -u API_USER -p API_PASSWORD + +References: +- https://developer.bigfix.com/rest-api/api/admin.html +- https://github.com/jgstew/besapi/blob/master/examples/rest_cmd_args.py +- https://github.com/jgstew/tools/blob/master/Python/locate_self.py +""" + +import argparse +import getpass +import logging +import logging.handlers +import os +import platform +import sys + +import besapi +import besapi.plugin_utilities + +__version__ = "0.1.1" +verbose = 0 +bes_conn = None +invoke_folder = None + + +def get_invoke_folder(): + """Get the folder the script was invoked from. + + References: + - https://github.com/jgstew/tools/blob/master/Python/locate_self.py + """ + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +def test_file_exists(path): + """Return true if file exists.""" + + if not (os.path.isfile(path) and os.access(path, os.R_OK)): + path = os.path.join(invoke_folder, path) + + logging.info("testing if exists: `%s`", path) + + if os.path.isfile(path) and os.access(path, os.R_OK) and os.access(path, os.W_OK): + return path + + return False + + +def main(): + """Execution starts here.""" + print("main() start") + + args = besapi.plugin_utilities.get_plugin_args() + + # allow set global scoped vars + global bes_conn, verbose, invoke_folder + verbose = args.verbose + password = args.password + + # get folder the script was invoked from: + invoke_folder = get_invoke_folder() + + # get path to put log file in: + log_filename = os.path.join(invoke_folder, "setup_server_plugin_service.log") + + logging_config = besapi.plugin_utilities.get_plugin_logging_config( + log_filename, verbose, True + ) + + logging.basicConfig(**logging_config) + + logging.log(99, "----- Starting New Session ------") + logging.debug("invoke folder: %s", invoke_folder) + logging.debug("Python version: %s", platform.sys.version) + logging.debug("BESAPI Module version: %s", besapi.besapi.__version__) + logging.debug("this plugin's version: %s", __version__) + + bes_conn = besapi.plugin_utilities.get_besapi_connection(args) + + if bes_conn.am_i_main_operator() is False: + logging.error("You must be a Main Operator to run this script!") + sys.exit(1) + + root_id = int( + bes_conn.session_relevance_string( + "unique value of ids of bes computers whose(root server flag of it AND now - last report time of it < 3 * day)" + ) + ) + + logging.info("Root server computer id: %s", root_id) + + InstallPluginService_id = int( + bes_conn.session_relevance_string( + 'unique value of ids of fixlets whose(name of it contains "Install BES Server Plugin Service") of bes sites whose(name of it = "BES Support")' + ) + ) + + logging.info( + "Install BES Server Plugin Service content id: %s", InstallPluginService_id + ) + + ConfigureCredentials_id = int( + bes_conn.session_relevance_string( + 'unique value of ids of fixlets whose(name of it contains "Configure REST API credentials for BES Server Plugin Service") of bes sites whose(name of it = "BES Support")' + ) + ) + + logging.info( + "Configure REST API credentials for BES Server Plugin Service content id: %s", + ConfigureCredentials_id, + ) + + EnableWakeOnLAN_id = int( + bes_conn.session_relevance_string( + 'unique value of ids of fixlets whose(name of it contains "Enable Wake-on-LAN Medic") of bes sites whose(name of it = "BES Support")' + ) + ) + + logging.info( + "Enable Wake-on-LAN Medic content id: %s", + EnableWakeOnLAN_id, + ) + + # Build the XML for the Multi Action Group to setup the plugin service: + XML_String_MultiActionGroup = f""" + + + Setup Server Plugin Service + exists main gather service + + install initscripts + + + + true + + + + BES Support + {InstallPluginService_id} + Action1 + + + + + BES Support + {ConfigureCredentials_id} + Action1 + + {args.user} + + + + + + BES Support + {EnableWakeOnLAN_id} + Action1 + + + + true + P7D + + + {root_id} + + +""" + + # create action to setup server plugin service: + action_result = bes_conn.post("actions", XML_String_MultiActionGroup) + + logging.info(action_result) + + logging.log(99, "----- Ending Session ------") + + +if __name__ == "__main__": + main() diff --git a/examples/stop_open_completed_actions.py b/examples/stop_open_completed_actions.py new file mode 100644 index 0000000..9f673cc --- /dev/null +++ b/examples/stop_open_completed_actions.py @@ -0,0 +1,27 @@ +import besapi + +# another session relevance option: +# ids of bes actions whose( ("Expired" = state of it OR "Stopped" = state of it) AND (now - time issued of it > 180 * day) ) + +SESSION_RELEVANCE = """ids of bes actions whose( (targeted by list flag of it OR targeted by id flag of it) AND not reapply flag of it AND not group member flag of it AND "Open"=state of it AND (now - time issued of it) >= 8 * day )""" + + +def main(): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + session_result = bes_conn.session_relevance_array(SESSION_RELEVANCE) + + # print(session_result) + + # https://developer.bigfix.com/rest-api/api/action.html + for action_id in session_result: + print("Stopping Action:", action_id) + action_stop_result = bes_conn.post("action/" + action_id + "/stop", "") + print(action_stop_result) + + +if __name__ == "__main__": + main() diff --git a/examples/upload_files.py b/examples/upload_files.py new file mode 100644 index 0000000..89a3ca4 --- /dev/null +++ b/examples/upload_files.py @@ -0,0 +1,33 @@ +""" +Upload files in folder. + +requires `besapi`, install with command `pip install besapi` +""" + +import os + +import besapi + + +def main(path_folder="./tmp"): + """Execution starts here.""" + print("main()") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + print(f"INFO: Uploading new files within: {os.path.abspath(path_folder)}") + + for entry in os.scandir(path_folder): + if entry.is_file() and "README.md" not in entry.path: + # this check for spaces is not required for besapi>=3.1.9 + if " " in os.path.basename(entry.path): + print(f"ERROR: files cannot contain spaces! skipping: {entry.path}") + continue + print(f"Processing: {entry.path}") + output = bes_conn.upload(entry.path) + # print(output) + print(bes_conn.parse_upload_result_to_prefetch(output)) + + +if __name__ == "__main__": + main("./examples/upload_files") diff --git a/examples/upload_files/README.md b/examples/upload_files/README.md new file mode 100644 index 0000000..5ace3f2 --- /dev/null +++ b/examples/upload_files/README.md @@ -0,0 +1 @@ +put files in this folder to have them be uploaded to the root server by the script upload_files.py diff --git a/examples/validate_bes_xml.py b/examples/validate_bes_xml.py new file mode 100644 index 0000000..fe7b0c1 --- /dev/null +++ b/examples/validate_bes_xml.py @@ -0,0 +1,26 @@ +""" +Validate BigFix XML file against XML Schema. + +requires `besapi`, install with command `pip install besapi` +""" + +import besapi + + +def validate_xml_bes_file(file_path): + """Take a file path as input, read as binary data, validate against xml schema.""" + with open(file_path, "rb") as file: + file_data = file.read() + + return besapi.besapi.validate_xsd(file_data) + + +def main(file_path): + """Execution starts here.""" + print("main()") + + print(validate_xml_bes_file(file_path)) + + +if __name__ == "__main__": + main("./examples/content/RelaySelectAction.bes") diff --git a/examples/wake_on_lan.py b/examples/wake_on_lan.py new file mode 100644 index 0000000..6fb4848 --- /dev/null +++ b/examples/wake_on_lan.py @@ -0,0 +1,96 @@ +""" +Send Wake On Lan (WoL) request to given computer IDs. + +requires `besapi`, install with command `pip install besapi` + +Related: + +- https://support.hcltechsw.com/csm?id=kb_article&sysparm_article=KB0023378 +- http://localhost:__WebReportsPort__/json/wakeonlan?cid=_ComputerID_&cid=_NComputerID_ +- POST(binary) http://localhost:52311/data/wake-on-lan +- https://localhost:52311/rd-proxy?RequestUrl=cgi-bin/bfenterprise/BESGatherMirrorNew.exe/-triggergatherdb?forwardtrigger +- https://localhost:52311/rd-proxy?RequestUrl=../../cgi-bin/bfenterprise/ClientRegister.exe?RequestType=GetComputerID +- https://localhost:52311/rd-proxy?RequestUrl=cgi-bin/bfenterprise/BESGatherMirror.exe/-besgather&body=SiteContents&url=http://_MASTHEAD_FQDN_:52311/cgi-bin/bfgather.exe/actionsite +- https://localhost:52311/rd-proxy?RequestUrl=cgi-bin/bfenterprise/BESMirrorRequest.exe/-textreport +- Gather Download Request: https://localhost:52311/rd-proxy?RequestUrl=bfmirror/downloads/_ACTION_ID_/_DOWNLOAD_ID_ +""" + +import besapi + +SESSION_RELEVANCE_COMPUTER_IDS = """ + ids of bes computers + whose(root server flag of it AND now - last report time of it < 10 * day) +""" + + +def main(): + """Execution starts here.""" + print("main()") + + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + bes_conn.login() + + # SessionRelevance for computer ids you wish to wake: + # this currently returns the root server itself, which should have no real effect. + # change this to a singular or plural result of computer ids you wish to wake. + session_relevance = SESSION_RELEVANCE_COMPUTER_IDS + + computer_id_array = bes_conn.session_relevance_array(session_relevance) + + # print(computer_id_array) + + computer_ids_xml_string = "" + + for item in computer_id_array: + computer_ids_xml_string += '' + + # print(computer_ids_xml_string) + + soap_xml = ( + """ + + + + + + """ + + computer_ids_xml_string + + """ + + + + + + + + + + + + + + + + + + + + + + + +""" + ) + + result = bes_conn.session.post( + f"{bes_conn.rootserver}/WakeOnLan", data=soap_xml, verify=False + ) + + print(result) + print(result.text) + + print("Finished, Response 200 should mean succces.") + + +if __name__ == "__main__": + main() diff --git a/examples/windows_software_template.py b/examples/windows_software_template.py new file mode 100644 index 0000000..9a84ebf --- /dev/null +++ b/examples/windows_software_template.py @@ -0,0 +1,189 @@ +""" +This is to create a bigfix task to install windows software from a semi universal +template. + +- https://github.com/jgstew/bigfix-content/blob/main/fixlet/Universal_Windows_Installer_Template_Example.bes +""" + +import sys + +import bigfix_prefetch +import generate_bes_from_template + +import besapi +import besapi.plugin_utilities + +# This is a template for a BigFix Task to install software on Windows +# from: https://github.com/jgstew/bigfix-content/blob/main/fixlet/Universal_Windows_Installer_Template_Example.bes +TEMPLATE = r""" + + + Install {{{filename}}} - Windows + This example will install {{{filename}}}. + windows of operating system + + {{DownloadSize}}{{^DownloadSize}}0{{/DownloadSize}} + windows_software_template.py + jgstew + {{{SourceReleaseDate}}} + + + + + x-fixlet-modification-time + {{{x-fixlet-modification-time}}} + + BESC + + + Click + here + to deploy this action. + + // The goal of this is to be used as a template with minimal input +// ideally only the URL would need to be provided or URL and cmd_args +// the filename would by default be derived from the URL +// the prefetch would be generated from the URL + +// Download using prefetch block +begin prefetch block + parameter "filename"="{{{filename}}}" + parameter "cmd_args"="{{{arguments}}}" + {{{prefetch}}} + // if windows and if filename ends with .zip, download unzip.exe + if {(parameter "filename") as lowercase ends with ".zip"} + add prefetch item name=unzip.exe sha1=84debf12767785cd9b43811022407de7413beb6f size=204800 url=http://software.bigfix.com/download/redist/unzip-6.0.exe sha256=2122557d350fd1c59fb0ef32125330bde673e9331eb9371b454c2ad2d82091ac + endif + if {(parameter "filename") as lowercase ends with ".7z"} + add prefetch item name=7zr.exe sha1=ec3b89ef381fd44deaf386b49223857a47b66bd8 size=593408 url=https://www.7-zip.org/a/7zr.exe sha256=d2c0045523cf053a6b43f9315e9672fc2535f06aeadd4ffa53c729cd8b2b6dfe + endif +end prefetch block + +// Disable wow64 redirection on x64 OSes +action uses wow64 redirection {not x64 of operating system} + +// TODO: handle .7z case + +// if windows and if filename ends with .zip, extract with unzip.exe to __Download folder +if {(parameter "filename") as lowercase ends with ".zip"} +wait __Download\unzip.exe -o "__Download\{parameter "filename"}" -d __Download +// add unzip to utility cache +utility __Download\unzip.exe +endif + +// use relevance to find exe or msi in __Download folder (in case it came from archive) +parameter "installer" = "{ if (parameter "filename") as lowercase does not end with ".zip" then (parameter "filename") else ( tuple string items 0 of concatenations ", " of names of files whose(following text of last "." of name of it is contained by set of ("exe";"msi";"msix";"msixbundle")) of folder "__Download" of client folder of current site ) }" + +if {(parameter "installer") as lowercase ends with ".exe"} +override wait +timeout_seconds=3600 +hidden=true +wait "__Download\{parameter "installer"}" {parameter "cmd_args"} +endif + +if {(parameter "installer") as lowercase ends with ".msi"} +override wait +timeout_seconds=3600 +hidden=true +wait msiexec.exe /i "__Download\{parameter "installer"}" {if (parameter "cmd_args") as lowercase does not contain "/qn" then "/qn " else ""}{parameter "cmd_args"} +endif + +if {(parameter "installer") as lowercase ends with ".ps1"} +override wait +timeout_seconds=3600 +hidden=true +wait powershell -ExecutionPolicy Bypass -File "__Download\{parameter "installer"}" {parameter "cmd_args"} +endif + +if {(parameter "installer") as lowercase ends with ".msix" OR (parameter "installer") as lowercase ends with ".msixbundle"} +override wait +timeout_seconds=3600 +hidden=true +wait powershell -ExecutionPolicy Bypass -Command 'Add-AppxProvisionedPackage -Online -SkipLicense -PackagePath "__Download\{parameter "installer"}" {parameter "cmd_args"}' +endif + + + + +""" + + +def get_args(): + """Get arguments from command line.""" + parser = besapi.plugin_utilities.setup_plugin_argparse() + + parser.add_argument( + "--url", + required=False, + help="url to download the file from, required if using cmd line", + ) + + parser.add_argument( + "-a", + "--arguments", + required=False, + help="arguments to pass to the installer", + default="", + ) + + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + return args + + +def get_prefetch_block(url): + """Get prefetch string for a file.""" + prefetch_dict = bigfix_prefetch.prefetch_from_url.url_to_prefetch(url, True) + prefetch_block = bigfix_prefetch.prefetch_from_dictionary.prefetch_from_dictionary( + prefetch_dict, "block" + ) + return prefetch_block + + +def main(): + """Execution starts here.""" + + if len(sys.argv) > 1: + args = get_args() + else: + print("need to provide cmd args, use -h for help") + # TODO: open GUI to get args? + return 1 + + print("downloading file and calculating prefetch") + prefetch = get_prefetch_block(args.url) + + # get filename from end of URL: + filename = args.url.split("/")[-1] + + # remove ? and after from filename if present: + index = filename.find("?") + if index != -1: + filename = filename[:index] + + template_dictionary = { + "filename": filename, + "arguments": args.arguments, + "prefetch": prefetch, + } + + template_dictionary = ( + generate_bes_from_template.generate_bes_from_template.get_missing_bes_values( + template_dictionary + ) + ) + + # print(template_dictionary) + task_xml = generate_bes_from_template.generate_bes_from_template.generate_content_from_template_string( + template_dictionary, + TEMPLATE, + ) + + print(task_xml) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..5ed96a7 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["src"], + "exclude": [".venv", "tests"], + "pythonVersion": "3.9", + "venv": "my_project_venv", + "venvPath": "./.venv", + "reportMissingImports": true, + "reportUnusedImport": "warning", + "reportOptionalMemberAccess": "none", + "reportAttributeAccessIssue": "none" +} diff --git a/requirements.txt b/requirements.txt index e871578..fcbfa76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ cmd2 lxml +pywin32 ; sys_platform=="win32" requests +setuptools +urllib3 >= 2.2.3 diff --git a/setup.cfg b/setup.cfg index 4a4fecf..8ceefcb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,13 +1,45 @@ [metadata] # single source version in besapi.__init__.__version__ # can get version on command line with: `python setup.py --version` -version = attr: besapi.__version__ +name = besapi +author = Matt Hansen, James Stewart +author_email = hansen.m@psu.edu, james@jgstew.com +maintainer = James Stewart +maintainer_email = james@jgstew.com +version = attr: besapi.besapi.__version__ +description = Library for working with the BigFix REST API +keywords = bigfix iem tem rest api long_description = file: README.md long_description_content_type = text/markdown +url = https://github.com/jgstew/besapi +license = MIT +license_file = LICENSE.txt classifiers = Programming Language :: Python :: 3 + Programming Language :: Python :: 3.9 Operating System :: OS Independent License :: OSI Approved :: MIT License + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + Topic :: Software Development :: Libraries :: Python Modules + Topic :: System :: Systems Administration +package_dir = + = src [options] -python_requires = >=3.6 +python_requires = >=3.9 +include_package_data = True +packages = find: +install_requires = + cmd2 + lxml + requests + setuptools + pywin32; platform_system == "Windows" + urllib3 >= 2.2.3 + +[options.package_data] +besapi = schemas/*.xsd + +[options.packages.find] +where = src diff --git a/setup.py b/setup.py index 31a5a44..227e5d9 100644 --- a/setup.py +++ b/setup.py @@ -1,26 +1,7 @@ #!/usr/bin/env python -""" -file to configure python build and packaging for pip -""" +"""File to configure python build and packaging for pip.""" -try: - from setuptools import setup -except (ImportError, ModuleNotFoundError): - from distutils.core import setup +from setuptools import setup -setup( - name="besapi", - # version= moved to setup.cfg - author="Matt Hansen, James Stewart", - author_email="hansen.m@psu.edu, james@jgstew.com", - description="Library for working with the BigFix REST API", - license="MIT", - keywords="bigfix iem tem rest api", - url="https://github.com/CLCMacTeam/besapi", - # long_description= moved to setup.cfg - packages=["besapi", "bescli"], - package_data={"besapi": ["schemas/*.xsd"]}, - install_requires=["requests", "lxml", "cmd2"], - include_package_data=True, - package_dir={"": "src"}, -) +if __name__ == "__main__": + setup() diff --git a/src/besapi/__init__.py b/src/besapi/__init__.py index 28f4ec5..b1766da 100644 --- a/src/besapi/__init__.py +++ b/src/besapi/__init__.py @@ -1,9 +1,5 @@ -""" -besapi is a python module for interacting with the BigFix REST API -""" +"""Besapi is a python module for interacting with the BigFix REST API.""" # https://stackoverflow.com/questions/279237/import-a-module-from-a-relative-path/4397291 from . import besapi - -__version__ = "3.0.2" diff --git a/src/besapi/__main__.py b/src/besapi/__main__.py index 6269c0c..fc88b46 100644 --- a/src/besapi/__main__.py +++ b/src/besapi/__main__.py @@ -1,6 +1,8 @@ -""" -To run this module directly -""" +"""To run this module directly.""" + +import logging + from bescli import bescli +logging.basicConfig() bescli.main() diff --git a/src/besapi/besapi.py b/src/besapi/besapi.py index 15511cc..c9d8150 100644 --- a/src/besapi/besapi.py +++ b/src/besapi/besapi.py @@ -1,39 +1,43 @@ #!/usr/bin/env python """ -besapi.py +This module besapi provides a simple interface to the BigFix REST API. MIT License Copyright (c) 2014 Matt Hansen +Copyright (c) 2021 James Stewart Maintained by James Stewart since 2021 Library for communicating with the BES (BigFix) REST API. """ +import configparser import datetime +import getpass +import hashlib +import importlib.resources import json import logging import os import random import site import string +import sys +import urllib.parse -# import urllib3.poolmanager - -try: - from urllib import parse -except ImportError: - from urlparse import parse_qs as parse - +import lxml.etree +import lxml.objectify import requests -from lxml import etree, objectify -from pkg_resources import resource_filename +import urllib3.poolmanager + +__version__ = "4.0.2" -logging.basicConfig(level=logging.WARNING) besapi_logger = logging.getLogger("besapi") +# pylint: disable=consider-using-f-string + def rand_password(length=20): - """get a random password""" + """Get a random password.""" all_safe_chars = string.ascii_letters + string.digits + "!#()*+,-.:;<=>?[]^_|~" @@ -44,7 +48,7 @@ def rand_password(length=20): def sanitize_txt(*args): """Clean arbitrary text for safe file system usage.""" - valid_chars = "-_.() %s%s" % (string.ascii_letters, string.digits) + valid_chars = f"-_.() {string.ascii_letters}{string.digits}" sani_args = [] for arg in args: @@ -64,6 +68,7 @@ def sanitize_txt(*args): def elem2dict(node): """ Convert an lxml.etree node tree into a dict. + https://gist.github.com/jacobian/795571?permalink_comment_id=2981870#gistcomment-2981870 """ result = {} @@ -78,7 +83,6 @@ def elem2dict(node): else: value = elem2dict(element) if key in result: - if type(result[key]) is list: result[key].append(value) else: @@ -93,7 +97,10 @@ def elem2dict(node): def replace_text_between( original_text, first_delimiter, second_delimiter, replacement_text ): - """Replace text between delimeters. Each delimiter should only appear once.""" + """Replace text between delimiters. + + Each delimiter should only appear once. + """ leading_text = original_text.split(first_delimiter)[0] trailing_text = original_text.split(second_delimiter)[1] @@ -108,53 +115,317 @@ def replace_text_between( # https://github.com/jgstew/generate_bes_from_template/blob/bcc6c79632dd375c2861608ded3ae5872801a669/src/generate_bes_from_template/generate_bes_from_template.py#L87-L92 def parse_bes_modtime(string_datetime): - """parse datetime string to object""" + """Parse datetime string to object.""" # ("%a, %d %b %Y %H:%M:%S %z") return datetime.datetime.strptime(string_datetime, "%a, %d %b %Y %H:%M:%S %z") -# # https://docs.python-requests.org/en/latest/user/advanced/#transport-adapters -# class HTTPAdapterBiggerBlocksize(requests.adapters.HTTPAdapter): -# """custom HTTPAdapter for requests to override blocksize -# for Uploading or Downloading large files""" +def get_action_combined_relevance(relevances: list[str]): + """Take array of ordered relevance clauses and return relevance string for + action. + """ + + relevance_combined = "" + + if not relevances: + return "False" + if len(relevances) == 0: + return "False" + if len(relevances) == 1: + return relevances[0] + if len(relevances) > 0: + for clause in relevances: + if len(relevance_combined) == 0: + relevance_combined = clause + else: + relevance_combined = ( + "( " + relevance_combined + " ) AND ( " + clause + " )" + ) + + return relevance_combined -# # override inti_poolmanager from regular HTTPAdapter -# # https://stackoverflow.com/questions/22915295/python-requests-post-and-big-content/22915488#comment125583017_22915488 -# def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): -# """Initializes a urllib3 PoolManager. -# This method should not be called from user code, and is only -# exposed for use when subclassing the -# :class:`HTTPAdapter `. +def get_target_xml(targets=None): + """Get target xml based upon input. -# :param connections: The number of urllib3 connection pools to cache. -# :param maxsize: The maximum number of connections to save in the pool. -# :param block: Block when no free connections are available. -# :param pool_kwargs: Extra keyword arguments used to initialize the Pool Manager. -# """ -# # save these values for pickling -# self._pool_connections = connections -# self._pool_maxsize = maxsize -# self._pool_block = block + Input can be a single string: + - starts with "" if all computers should be targeted + - Otherwise will be interpreted as custom relevance -# # This doesn't work until urllib3 is updated to a future version: -# # updating blocksize to be larger: -# # pool_kwargs["blocksize"] = 8 * 1024 * 1024 + Input can be a single int: + - Single Computer ID Target -# self.poolmanager = urllib3.poolmanager.PoolManager( -# num_pools=connections, -# maxsize=maxsize, -# block=block, -# strict=True, -# **pool_kwargs, -# ) + Input can be an array: + - Array of Strings: ComputerName + - Array of Integers: ComputerID + """ + if targets is None or not targets: + besapi_logger.warning("No valid targeting found, will target no computers.") + # default if invalid: + return "False" + + # if targets is int: + if isinstance(targets, int): + if targets == 0: + raise ValueError( + "Int 0 is not valid Computer ID, set targets to an array of strings of computer names or an array of ints of computer ids or custom relevance string or " + ) + return f"{targets}" + + # if targets is str: + if isinstance(targets, str): + # if targets string starts with "": + if targets.startswith(""): + if "false" in targets.lower(): + # In my testing, false does not work correctly + return "False" + # return "false" + return "true" + # treat as custom relevance: + return f"" + + # if targets is array: + if isinstance(targets, list): + element_type = type(targets[0]) + if element_type is int: + # array of computer ids + return ( + "" + + "".join(map(str, targets)) + + "" + ) + if element_type is str: + # array of computer names + return ( + "" + + "".join(targets) + + "" + ) + + besapi_logger.warning("No valid targeting found, will target no computers.") + + # default if invalid: + return "False" + + +def validate_xsd(doc): + """Validate results using XML XSDs.""" + try: + xmldoc = lxml.etree.fromstring(doc) + except BaseException: # pylint: disable=broad-except + return False + + for xsd in ["BES.xsd", "BESAPI.xsd", "BESActionSettings.xsd"]: + schema_path = importlib.resources.files(__package__) / f"schemas/{xsd}" + with schema_path.open("r") as xsd_file: + xmlschema_doc = lxml.etree.parse(xsd_file) + + # one schema may throw an error while another will validate + try: + xmlschema = lxml.etree.XMLSchema(xmlschema_doc) + except lxml.etree.XMLSchemaParseError as err: + # this should only error if the XSD itself is malformed + besapi_logger.error("ERROR with `%s`: %s", xsd, err) + raise err + + if xmlschema.validate(xmldoc): + return True + + return False + + +def validate_xml_bes_file(file_path): + """Take a file path as input, + read as binary data,. + + validate against xml schema + + returns True for valid xml + returns False for invalid xml (or if file is not xml) + """ + with open(file_path, "rb") as file: + file_data = file.read() + + return validate_xsd(file_data) + + +def get_bes_conn_using_env(): + """Get BESConnection using environment variables.""" + username = os.getenv("BES_USER_NAME") + password = os.getenv("BES_PASSWORD") + rootserver = os.getenv("BES_ROOT_SERVER") + + if username and password and rootserver: + bes_conn = BESConnection(username, password, rootserver) + if bes_conn: + return bes_conn + + return None + + +def get_bes_conn_using_config_file(conf_file=None): + """ + Read connection values from config file. + + return besapi connection + """ + config_paths = [ + "/etc/besapi.conf", + os.path.expanduser("~/besapi.conf"), + os.path.expanduser("~/.besapi.conf"), + "besapi.conf", + ] + # if conf_file specified, then only use that: + if conf_file: + config_paths = [conf_file] + + configparser_instance = configparser.ConfigParser() + + found_config_files = configparser_instance.read(config_paths) + + if found_config_files and configparser_instance: + print("Attempting BESAPI Connection using config file:", found_config_files) + try: + BES_ROOT_SERVER = configparser_instance.get("besapi", "BES_ROOT_SERVER") + except BaseException: # pylint: disable=broad-except + BES_ROOT_SERVER = None + + try: + BES_USER_NAME = configparser_instance.get("besapi", "BES_USER_NAME") + except BaseException: # pylint: disable=broad-except + BES_USER_NAME = None + + try: + BES_PASSWORD = configparser_instance.get("besapi", "BES_PASSWORD") + except BaseException: # pylint: disable=broad-except + BES_PASSWORD = None + + if BES_ROOT_SERVER and BES_USER_NAME and BES_PASSWORD: + return BESConnection(BES_USER_NAME, BES_PASSWORD, BES_ROOT_SERVER) + + return None + + +def get_bes_conn_interactive( + user=None, password=None, root_server=None, force_prompt=False +): + """Get BESConnection using interactive prompts.""" + + if not (force_prompt or sys.__stdin__.isatty()): + logging.error("No TTY available for interactive login!") + return None + + print( + "Attempting BESAPI Connection using interactive prompts. Use Ctrl-C to cancel." + ) + try: + if not user: + user = str(input("User [%s]: " % getpass.getuser())).strip() + if not user: + user = getpass.getuser() + + if not root_server: + root_server = str( + input("Root Server (ex. %s): " % "https://localhost:52311") + ).strip() + if not root_server or root_server == "": + print("Root Server is required, try again!") + return get_bes_conn_interactive( + user=user, + password=password, + root_server=None, + force_prompt=force_prompt, + ) + + if not password: + password = str( + getpass.getpass(f"Password for {user}@{root_server}: ") + ).strip() + + if not password or password == "": + print("Password is required, try again!") + return get_bes_conn_interactive( + user=user, + password=None, + root_server=root_server, + force_prompt=force_prompt, + ) + except (KeyboardInterrupt, EOFError): + print("\nLogin cancelled.") + return None + + try: + return BESConnection(user, password, root_server) + except requests.exceptions.HTTPError as err: + print("Bad Username or Bad Password, Try again!") + logging.debug(err) + print(" BES_ROOT_SERVER: ", root_server) + return get_bes_conn_interactive( + user=None, password=None, root_server=root_server, force_prompt=force_prompt + ) + except requests.exceptions.ConnectionError as err: + print("Bad Root Server Specified, Try again!") + logging.debug("Connection Error: %s", err) + print(" BES_USER_NAME: ", user) + print("BES_PASSWORD length: ", len(password)) + return get_bes_conn_interactive( + user=user, password=password, root_server=None, force_prompt=force_prompt + ) + except Exception as e: # pylint: disable=broad-except + logging.error("Error occurred while establishing BESConnection: %s", e) + return None + + +# https://docs.python-requests.org/en/latest/user/advanced/#transport-adapters +class HTTPAdapterBlocksize(requests.adapters.HTTPAdapter): + """Custom HTTPAdapter for requests to override blocksize + for Uploading or Downloading large files. + """ + + def __init__(self, blocksize=1000000, **kwargs): + self.blocksize = blocksize + super().__init__(**kwargs) + + # override init_poolmanager from regular HTTPAdapter + # https://stackoverflow.com/questions/22915295/python-requests-post-and-big-content/22915488#comment125583017_22915488 + def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): + """Initializes a urllib3 PoolManager. + + This method should not be called from user code, and is only + exposed for use when subclassing the + :class:`HTTPAdapter `. + + :param connections: The number of urllib3 connection pools to cache. + :param maxsize: The maximum number of connections to save in the pool. + :param block: Block when no free connections are available. + :param pool_kwargs: Extra keyword arguments used to initialize the Pool Manager. + """ + # save these values for pickling + self._pool_connections = connections + self._pool_maxsize = maxsize + self._pool_block = block + + try: + self.poolmanager = urllib3.poolmanager.PoolManager( + num_pools=connections, + maxsize=maxsize, + block=block, + blocksize=self.blocksize, + **pool_kwargs, + ) + except Exception: # pylint: disable=broad-except + self.poolmanager = urllib3.poolmanager.PoolManager( + num_pools=connections, + maxsize=maxsize, + block=block, + **pool_kwargs, + ) class BESConnection: - """BigFix RESTAPI connection abstraction class""" + """BigFix RESTAPI connection abstraction class.""" def __init__(self, username, password, rootserver, verify=False): - if not verify: # disable SSL warnings requests.packages.urllib3.disable_warnings() # pylint: disable=no-member @@ -164,8 +435,21 @@ def __init__(self, username, password, rootserver, verify=False): self.username = username self.session = requests.Session() self.session.auth = (username, password) - # store info on operator used to login - # self.operator_info = {} + + # # configure retries for requests + # retry = requests.adapters.Retry( + # total=2, + # backoff_factor=0.1, + # # status_forcelist=[500, 502, 503, 504], + # ) + + # # mount the HTTPAdapter with the retry configuration + # self.session.mount("https://", requests.adapters.HTTPAdapter(max_retries=retry)) + + # store if connection user is main operator + self.is_main_operator = None + + self.webreports_info_xml = None # use a sitepath context if none specified when required. self.site_path = "master" @@ -181,16 +465,16 @@ def __init__(self, username, password, rootserver, verify=False): try: # get root server port self.rootserver_port = int(rootserver.split("://", 1)[1].split(":", 1)[1]) - except BaseException: + except BaseException: # pylint: disable=broad-except # if error, assume default self.rootserver_port = 52311 self.login() def __repr__(self): - """object representation""" + """Object representation.""" # https://stackoverflow.com/a/2626364/861745 - return f"Object: besapi.BESConnction( username={self.username}, rootserver={self.rootserver} )" + return f"Object: besapi.BESConnection( username={self.username}, rootserver={self.rootserver} )" def __eq__(self, other): if ( @@ -202,65 +486,143 @@ def __eq__(self, other): return False def __del__(self): - """cleanup on deletion of instance""" + """Cleanup on deletion of instance.""" self.logout() self.session.auth = None def __bool__(self): - """get true or false""" + """Get true or false.""" return self.login() def url(self, path): - """get absolute url""" + """Get absolute url.""" if path.startswith(self.rootserver): url = path else: - url = "%s/api/%s" % (self.rootserver, path) + url = f"{self.rootserver}/api/{path}" return url def get(self, path="help", **kwargs): - """HTTP GET request""" + """HTTP GET request.""" self.last_connected = datetime.datetime.now() return RESTResult( self.session.get(self.url(path), verify=self.verify, **kwargs) ) - def post(self, path, data, **kwargs): - """HTTP POST request""" + def post(self, path, data, validate_xml=None, **kwargs): + """HTTP POST request.""" + + # if validate_xml is true, data must validate to xml schema + # if validate_xml is false, no schema check will be made + if validate_xml: + if not validate_xsd(data): + err_msg = "data being posted did not validate to XML schema. If expected, consider setting validate_xml to false." + if validate_xml: + besapi_logger.error(err_msg) + raise ValueError(err_msg) + + # this is intended it validate_xml is None, but not used currently + besapi_logger.warning(err_msg) + self.last_connected = datetime.datetime.now() return RESTResult( self.session.post(self.url(path), data=data, verify=self.verify, **kwargs) ) - def put(self, path, data, **kwargs): - """HTTP PUT request""" + def put(self, path, data, validate_xml=None, **kwargs): + """HTTP PUT request.""" self.last_connected = datetime.datetime.now() + + # if validate_xml is true, data must validate to xml schema + # if validate_xml is false, no schema check will be made + if validate_xml: + if not validate_xsd(data): + err_msg = "data being put did not validate to XML schema. If expected, consider setting validate_xml to false." + if validate_xml: + besapi_logger.error(err_msg) + raise ValueError(err_msg) + + # this is intended it validate_xml is None, but not used currently + besapi_logger.warning(err_msg) + return RESTResult( self.session.put(self.url(path), data=data, verify=self.verify, **kwargs) ) def delete(self, path, **kwargs): - """HTTP DELETE request""" + """HTTP DELETE request.""" self.last_connected = datetime.datetime.now() return RESTResult( self.session.delete(self.url(path), verify=self.verify, **kwargs) ) + def am_i_main_operator(self): + """Check if the current user is the main operator user.""" + if self.is_main_operator is None: + try: + self.webreports_info_xml = self.get("webreports") + self.is_main_operator = True + except PermissionError: + self.is_main_operator = False + except Exception as err: # pylint: disable=broad-except + besapi_logger.error("Error checking if main operator: %s", err) + self.is_main_operator = None + + if self.is_main_operator is not None: + return self.is_main_operator + + def session_relevance_json(self, relevance, **kwargs): + """Get Session Relevance Results in JSON. + + This will submit the relevance string as json instead of html form data. + """ + session_relevance = urllib.parse.quote(relevance, safe=":+") + rel_data = {"output": "json", "relevance": session_relevance} + self.last_connected = datetime.datetime.now() + result = RESTResult( + self.session.post( + self.url("query"), + data=rel_data, + verify=self.verify, + **kwargs, + ) + ) + return json.loads(result.text) + + def session_relevance_json_array(self, relevance, **kwargs): + """Get Session Relevance Results in an array from the json return. + + This will submit the relevance string as json instead of html form data. + """ + result = self.session_relevance_json(relevance, **kwargs) + return result["result"] + + def session_relevance_json_string(self, relevance, **kwargs): + """Get Session Relevance Results in a string from the json return. + + This will submit the relevance string as json instead of html form data. + """ + # not sure if the following is needed to handle some cases: + # relevance = "(it as string) of ( " + relevance + " )" + rel_result_array = self.session_relevance_json_array(relevance, **kwargs) + # Ensure each element is converted to a string + return "\n".join(map(str, rel_result_array)) + def session_relevance_xml(self, relevance, **kwargs): - """Get Session Relevance Results XML""" + """Get Session Relevance Results XML.""" self.last_connected = datetime.datetime.now() return RESTResult( self.session.post( self.url("query"), - data=f"relevance={parse.quote(relevance, safe=':+')}", + data=f"relevance={urllib.parse.quote(relevance, safe=':+')}", verify=self.verify, **kwargs, ) ) def session_relevance_array(self, relevance, **kwargs): - """Get Session Relevance Results array""" + """Get Session Relevance Results array.""" rel_result = self.session_relevance_xml(relevance, **kwargs) # print(rel_result) result = [] @@ -272,24 +634,29 @@ def session_relevance_array(self, relevance, **kwargs): if "no such child: Answer" in str(err): try: result.append("ERROR: " + rel_result.besobj.Query.Error.text) - except AttributeError as err: - if "no such child: Error" in str(err): + except AttributeError as err2: + if "no such child: Error" in str(err2): result.append(" Nothing returned, but no error.") besapi_logger.info("Query did not return any results") else: - besapi_logger.error("%s\n%s", err, rel_result.text) + besapi_logger.error("%s\n%s", err2, rel_result.text) + result.append("ERROR: " + rel_result.text) raise else: + besapi_logger.error("%s\n%s", err, rel_result.text) + result.append("ERROR: " + rel_result.text) raise return result def session_relevance_string(self, relevance, **kwargs): - """Get Session Relevance Results string""" - rel_result_array = self.session_relevance_array(relevance, **kwargs) + """Get Session Relevance Results string.""" + rel_result_array = self.session_relevance_array( + "(it as string) of ( " + relevance + " )", **kwargs + ) return "\n".join(rel_result_array) - def login(self): - """do login""" + def login(self, timeout=(3, 20)): + """Do login.""" if bool(self.last_connected): duration_obj = datetime.datetime.now() - self.last_connected duration_minutes = duration_obj / datetime.timedelta(minutes=1) @@ -301,31 +668,62 @@ def login(self): # default timeout is 5 minutes # I'm not sure if this is required # or if 'requests' would handle this automatically anyway - if int(duration_minutes) > 3: - besapi_logger.info("Refreshing Login to prevent timeout.") - self.last_connected = None + # if int(duration_minutes) > 3: + # besapi_logger.info("Refreshing Login to prevent timeout.") + # self.last_connected = None if not bool(self.last_connected): - result_login = self.get("login") + result_login = self.get("login", timeout=timeout) if not result_login.request.status_code == 200: result_login.request.raise_for_status() if result_login.request.status_code == 200: # set time of connection self.last_connected = datetime.datetime.now() - # This doesn't work until urllib3 is updated to a future version: - # if self.connected(): - # self.session.mount(self.url("upload"), HTTPAdapterBiggerBlocksize()) + # This doesn't work until urllib3 is at least ~v2: + if self.last_connected: + try: + self.session.mount(self.url("upload"), HTTPAdapterBlocksize()) + except Exception: # pylint: disable=broad-except + pass return bool(self.last_connected) def logout(self): - """clear session and close it""" + """Clear session and close it.""" self.session.cookies.clear() self.session.close() + def set_dashboard_variable_value( + self, dashboard_name, var_name, var_value, private=False + ): + """Set the variable value from a dashboard datastore.""" + + dash_var_xml = f""" + + {dashboard_name} + {var_name} + {str(private).lower()} + {var_value} + + + """ + + return self.post( + f"dashboardvariable/{dashboard_name}/{var_name}", data=dash_var_xml + ) + + def get_dashboard_variable_value(self, dashboard_name, var_name): + """Get the variable value from a dashboard datastore.""" + + return str( + self.get( + f"dashboardvariable/{dashboard_name}/{var_name}" + ).besobj.DashboardData.Value + ) + def validate_site_path(self, site_path, check_site_exists=True, raise_error=False): - """make sure site_path is valid""" + """Make sure site_path is valid.""" if site_path is None: if not raise_error: @@ -349,18 +747,18 @@ def validate_site_path(self, site_path, check_site_exists=True, raise_error=Fals if not check_site_exists: # don't check if site exists first return site_path - else: - # check site exists first - site_result = self.get(f"site/{site_path}") - if site_result.request.status_code != 200: - besapi_logger.info("Site `%s` does not exist", site_path) - if not raise_error: - return None - raise ValueError(f"Site at path `{site_path}` does not exist!") + # check site exists first + site_result = self.get(f"site/{site_path}") + if site_result.request.status_code != 200: + besapi_logger.info("Site `%s` does not exist", site_path) + if not raise_error: + return None - # site_path is valid and exists: - return site_path + raise ValueError(f"Site at path `{site_path}` does not exist!") + + # site_path is valid and exists: + return site_path # Invalid: No valid prefix found raise ValueError( @@ -368,8 +766,9 @@ def validate_site_path(self, site_path, check_site_exists=True, raise_error=Fals ) def get_current_site_path(self, site_path=None): - """if site_path is none, get current instance site_path, - otherwise validate and return provided site_path""" + """If site_path is none, get current instance site_path, + otherwise validate and return provided site_path. + """ # use instance site_path context if none provided: if site_path is None or str(site_path).strip() == "": @@ -383,15 +782,40 @@ def get_current_site_path(self, site_path=None): return self.validate_site_path(site_path, check_site_exists=False) def set_current_site_path(self, site_path): - """set current site path context""" + """Set current site path context.""" if self.validate_site_path(site_path): self.site_path = site_path return self.site_path + return None + + def import_bes_to_site(self, bes_file_path, site_path=None): + """Import bes file to site.""" + + if not os.access(bes_file_path, os.R_OK): + besapi_logger.error("%s is not readable", bes_file_path) + raise FileNotFoundError(f"{bes_file_path} is not readable") + + site_path = self.get_current_site_path(site_path) + + self.validate_site_path(site_path, False, True) + + with open(bes_file_path, "rb") as f: + content = f.read() + + # validate BES File contents: + if not validate_xsd(content): + besapi_logger.error("%s is not valid", bes_file_path) + return None + + # https://developer.bigfix.com/rest-api/api/import.html + result = self.post(f"import/{site_path}", content) + return result + def create_site_from_file(self, bes_file_path, site_type="custom"): - """create new site""" - xml_parsed = etree.parse(bes_file_path) + """Create new site.""" + xml_parsed = lxml.etree.parse(bes_file_path) new_site_name = xml_parsed.xpath("/BES/CustomSite/Name/text()")[0] result_site_path = self.validate_site_path( @@ -402,12 +826,12 @@ def create_site_from_file(self, bes_file_path, site_type="custom"): besapi_logger.warning("Site `%s` already exists", result_site_path) return None - result_site = self.post("sites", etree.tostring(xml_parsed)) + result_site = self.post("sites", lxml.etree.tostring(xml_parsed)) return result_site def get_user(self, user_name): - """get a user""" + """Get a user.""" result_users = self.get(f"operator/{user_name}") @@ -417,8 +841,8 @@ def get_user(self, user_name): besapi_logger.info("User `%s` Not Found!", user_name) def create_user_from_file(self, bes_file_path): - """create user from xml""" - xml_parsed = etree.parse(bes_file_path) + """Create user from xml.""" + xml_parsed = lxml.etree.parse(bes_file_path) new_user_name = xml_parsed.xpath("/BESAPI/Operator/Name/text()")[0] result_user = self.get_user(new_user_name) @@ -426,12 +850,13 @@ def create_user_from_file(self, bes_file_path): besapi_logger.warning("User `%s` Already Exists!", new_user_name) return result_user besapi_logger.info("Creating User `%s`", new_user_name) - _ = self.post("operators", etree.tostring(xml_parsed)) - # print(user_result) + user_result = self.post("operators", lxml.etree.tostring(xml_parsed)) + besapi_logger.debug("user creation result:\n%s", user_result) + return self.get_user(new_user_name) def get_computergroup(self, group_name, site_path=None): - """get computer group resource URI""" + """Get computer group resource URI.""" site_path = self.get_current_site_path(site_path) result_groups = self.get(f"computergroups/{site_path}") @@ -446,9 +871,9 @@ def get_computergroup(self, group_name, site_path=None): besapi_logger.info("Group `%s` Not Found!", group_name) def create_group_from_file(self, bes_file_path, site_path=None): - """create a new group""" + """Create a new group.""" site_path = self.get_current_site_path(site_path) - xml_parsed = etree.parse(bes_file_path) + xml_parsed = lxml.etree.parse(bes_file_path) new_group_name = xml_parsed.xpath("/BES/ComputerGroup/Title/text()")[0] existing_group = self.get_computergroup(new_group_name, site_path) @@ -459,13 +884,44 @@ def create_group_from_file(self, bes_file_path, site_path=None): # print(lxml.etree.tostring(xml_parsed)) - _ = self.post(f"computergroups/{site_path}", etree.tostring(xml_parsed)) + create_group_result = self.post( + f"computergroups/{site_path}", lxml.etree.tostring(xml_parsed) + ) + + besapi_logger.debug("group creation result:\n%s", create_group_result) return self.get_computergroup(site_path, new_group_name) - def upload(self, file_path, file_name=None): + def get_upload(self, file_name, file_hash): """ - upload a single file + Check for a specific file upload reference. + + each upload is uniquely identified by sha1 and filename + + - https://developer.bigfix.com/rest-api/api/upload.html + - https://github.com/jgstew/besapi/issues/3 + """ + if len(file_hash) != 40: + raise ValueError("Invalid SHA1 Hash! Must be 40 characters!") + + if " " in file_hash or " " in file_name: + raise ValueError("file name and hash cannot contain spaces") + + if len(file_name) > 0: + result = self.get(self.url("upload/" + file_hash + "/" + file_name)) + else: + raise ValueError("No file_name specified. Must be at least one character.") + + if "Upload not found" in result.text: + besapi_logger.debug("WARNING: Upload not found!") + return None + + return result + + def upload(self, file_path, file_name=None, file_hash=None): + """ + Upload a single file. + https://developer.bigfix.com/rest-api/api/upload.html """ if not os.access(file_path, os.R_OK): @@ -476,19 +932,54 @@ def upload(self, file_path, file_name=None): if not file_name: file_name = os.path.basename(file_path) + # files cannot contain spaces: + if " " in file_name: + besapi_logger.warning( + "Replacing spaces with underscores in `%s`", file_name + ) + file_name = file_name.replace(" ", "_") + + if not file_hash: + besapi_logger.warning( + "SHA1 hash of file to be uploaded not provided, calculating it." + ) + sha1 = hashlib.sha1() + with open(file_path, "rb") as f: + while True: + # read 64k chunks + data = f.read(65536) + if not data: + break + sha1.update(data) + file_hash = sha1.hexdigest() + + check_upload = None + if file_hash: + check_upload = self.get_upload(str(file_name), str(file_hash)) + + if check_upload: + besapi_logger.warning( + "Existing Matching Upload Found, Skipping Upload!" + ) + # return same data as if we had uploaded + return check_upload + # Example Header:: Content-Disposition: attachment; filename="file.xml" headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} + logging.warning( + "Uploading `%s` to BigFix Server, this could take a while.", file_name + ) with open(file_path, "rb") as f: return self.post(self.url("upload"), data=f, headers=headers) def parse_upload_result_to_prefetch( self, result_upload, use_localhost=True, use_https=True ): - """take a rest response from an upload and parse into prefetch""" + """Take a rest response from an upload and parse into prefetch.""" file_url = str(result_upload.besobj.FileUpload.URL) if use_https: file_url = file_url.replace("http://", "https://") - # there are 3 different posibilities for the server FQDN + # there are 3 different possibilities for the server FQDN # localhost # self.rootserver (without port number) # the returned value from the upload result @@ -505,14 +996,14 @@ def parse_upload_result_to_prefetch( return f"prefetch {file_name} sha1:{file_sha1} size:{file_size} {file_url} sha256:{file_sha256}" def get_content_by_resource(self, resource_url): - """get a single content item by resource""" + """Get a single content item by resource.""" # Get Specific Content content = None try: content = self.get(resource_url.replace("http://", "https://")) except PermissionError as err: - logging.error("Could not export item:") - logging.error(err) + besapi_logger.error("Could not export item:") + besapi_logger.error(err) # item_id = int(resource_url.split("/")[-1]) # site_name = resource_url.split("/")[-2] @@ -523,9 +1014,16 @@ def get_content_by_resource(self, resource_url): return content def update_item_from_file(self, file_path, site_path=None): - """update an item by name and last modified""" + """Update an item by name and last modified.""" site_path = self.get_current_site_path(site_path) - bes_tree = etree.parse(file_path) + bes_tree = lxml.etree.parse(file_path) + + with open(file_path, "rb") as f: + content = f.read() + if not validate_xsd(content): + besapi_logger.error("%s is not valid", file_path) + return None + # get name of first child tag of BES # - https://stackoverflow.com/a/3601919/861745 bes_type = str(bes_tree.xpath("name(/BES/*[1])")) @@ -549,7 +1047,7 @@ def save_item_to_besfile( export_folder="./", name_trim=100, ): - """save an xml string to bes file""" + """Save an xml string to bes file.""" item_folder = export_folder if not os.path.exists(item_folder): os.makedirs(item_folder) @@ -577,8 +1075,10 @@ def export_item_by_resource( include_item_type_folder=False, include_item_id=False, ): - """export a single item by resource + """Export a single item by resource. + example resources: + - content_type/site_type/site/id - https://localhost:52311/api/content_type/site_type/site/id """ @@ -625,8 +1125,10 @@ def export_site_contents( include_site_folder=True, include_item_ids=True, ): - """export contents of site + """Export contents of site. + Originally here: + - https://gist.github.com/jgstew/1b2da12af59b71c9f88a - https://bigfix.me/fixlet/details/21282 """ @@ -702,7 +1204,7 @@ def export_site_contents( def export_all_sites( self, include_external=False, export_folder="./", name_trim=70, verbose=False ): - """export all bigfix sites to a folder""" + """Export all bigfix sites to a folder.""" results_sites = self.get("sites") if verbose: print(results_sites) @@ -721,11 +1223,12 @@ def export_all_sites( class RESTResult: - """BigFix REST API Result Abstraction Class""" + """BigFix REST API Result Abstraction Class.""" def __init__(self, request): self.request = request self.text = request.text + self.valid = None self._besxml = None self._besobj = None self._besdict = None @@ -739,7 +1242,7 @@ def __init__(self, request): f"\n - HTTP Response Status Code: `403` Forbidden\n - ERROR: `{self.text}`\n - URL: `{self.request.url}`" ) - besapi_logger.info( + besapi_logger.debug( "HTTP Request Status Code `%d` from URL `%s`", self.request.status_code, self.request.url, @@ -760,7 +1263,9 @@ def __init__(self, request): if self.validate_xsd(request.text): self.valid = True else: - # print("WARNING: response appears invalid") + besapi_logger.debug( + "INFO: REST API Result does not appear to be XML, this could be expected." + ) self.valid = False def __str__(self): @@ -768,7 +1273,7 @@ def __str__(self): # I think this is needed for python3 compatibility: try: return self.besxml.decode("utf-8") - except BaseException: + except BaseException: # pylint: disable=broad-except return self.besxml else: return self.text @@ -778,7 +1283,7 @@ def __call__(self): @property def besxml(self): - """property for parsed xml representation""" + """Property for parsed xml representation.""" if self.valid and self._besxml is None: self._besxml = self.xmlparse_text(self.text) @@ -786,7 +1291,7 @@ def besxml(self): @property def besobj(self): - """property for xml object representation""" + """Property for xml object representation.""" if self.valid and self._besobj is None: self._besobj = self.objectify_text(self.text) @@ -794,10 +1299,10 @@ def besobj(self): @property def besdict(self): - """property for python dict representation""" + """Property for python dict representation.""" if self._besdict is None: if self.valid: - self._besdict = elem2dict(etree.fromstring(self.besxml)) + self._besdict = elem2dict(lxml.etree.fromstring(self.besxml)) else: self._besdict = {"text": str(self)} @@ -805,56 +1310,40 @@ def besdict(self): @property def besjson(self): - """property for json representation""" + """Property for json representation.""" if self._besjson is None: self._besjson = json.dumps(self.besdict, indent=2) return self._besjson def validate_xsd(self, doc): - """validate results using XML XSDs""" - try: - xmldoc = etree.fromstring(doc) - except BaseException: - return False - - for xsd in ["BES.xsd", "BESAPI.xsd", "BESActionSettings.xsd"]: - xmlschema_doc = etree.parse(resource_filename(__name__, "schemas/%s" % xsd)) - - # one schema may throw an error while another will validate - try: - xmlschema = etree.XMLSchema(xmlschema_doc) - except etree.XMLSchemaParseError as err: - # this should only error if the XSD itself is malformed - besapi_logger.error("ERROR with `%s`: %s", xsd, err) - raise err - - if xmlschema.validate(xmldoc): - return True - - return False + """Validate results using XML XSDs.""" + # return self.valid if already set + if self.valid is not None and isinstance(self.valid, bool): + return self.valid + return validate_xsd(doc) def xmlparse_text(self, text): - """parse response text as xml""" + """Parse response text as xml.""" if type(text) is str: - root_xml = etree.fromstring(text.encode("utf-8")) + root_xml = lxml.etree.fromstring(text.encode("utf-8")) else: root_xml = text - return etree.tostring(root_xml, encoding="utf-8", xml_declaration=True) + return lxml.etree.tostring(root_xml, encoding="utf-8", xml_declaration=True) def objectify_text(self, text): - """parse response text as objectified xml""" + """Parse response text as objectified xml.""" if type(text) is str: root_xml = text.encode("utf-8") else: root_xml = text - return objectify.fromstring(root_xml) + return lxml.objectify.fromstring(root_xml) def main(): - """if invoked directly, run bescli command loop""" + """If invoked directly, run bescli command loop.""" # pylint: disable=import-outside-toplevel try: from bescli import bescli @@ -865,4 +1354,5 @@ def main(): if __name__ == "__main__": + logging.basicConfig() main() diff --git a/src/besapi/plugin_utilities.py b/src/besapi/plugin_utilities.py new file mode 100644 index 0000000..ed09ed9 --- /dev/null +++ b/src/besapi/plugin_utilities.py @@ -0,0 +1,274 @@ +"""This is a set of utility functions for use in multiple plugins. + +see example here: https://github.com/jgstew/besapi/blob/master/examples/export_all_sites.py +""" + +import argparse +import getpass +import logging +import logging.handlers +import ntpath +import os +import sys +from typing import Union + +import besapi + +if os.name == "nt": + import besapi.plugin_utilities_win + + +# NOTE: This does not work as expected when run from plugin_utilities +def get_invoke_folder(verbose=0): + """Get the folder the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_folder = os.path.abspath(os.path.dirname(sys.executable)) + else: + if verbose: + print("running in a normal Python process") + invoke_folder = os.path.abspath(os.path.dirname(__file__)) + + if verbose: + print(f"invoke_folder = {invoke_folder}") + + return invoke_folder + + +# NOTE: This does not work as expected when run from plugin_utilities +def get_invoke_file_name(verbose=0): + """Get the filename the script was invoked from.""" + # using logging here won't actually log it to the file: + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + if verbose: + print("running in a PyInstaller bundle") + invoke_file_path = sys.executable + else: + if verbose: + print("running in a normal Python process") + invoke_file_path = __file__ + + if verbose: + print(f"invoke_file_path = {invoke_file_path}") + + # get just the file name, return without file extension: + return os.path.splitext(ntpath.basename(invoke_file_path))[0] + + +def setup_plugin_argparse(plugin_args_required=False): + """Setup argparse for plugin use.""" + arg_parser = argparse.ArgumentParser( + description="Provide command line arguments for REST URL, username, and password" + ) + arg_parser.add_argument( + "-v", + "--verbose", + help="Set verbose output", + required=False, + action="count", + default=0, + ) + arg_parser.add_argument( + "-c", + "--console", + help="log output to console", + required=False, + action="store_true", + ) + arg_parser.add_argument( + "-besserver", "--besserver", help="Specify the BES URL", required=False + ) + arg_parser.add_argument( + "-r", "--rest-url", help="Specify the REST URL", required=plugin_args_required + ) + arg_parser.add_argument( + "-u", "--user", help="Specify the username", required=plugin_args_required + ) + arg_parser.add_argument( + "-p", "--password", help="Specify the password", required=False + ) + + return arg_parser + + +def get_plugin_args(plugin_args_required=False): + """Get basic args for plugin use.""" + arg_parser = setup_plugin_argparse(plugin_args_required) + args, _unknown = arg_parser.parse_known_args() + return args + + +def get_plugin_logging_config(log_file_path="", verbose=0, console=True): + """Get config for logging for plugin use. + + use this like: logging.basicConfig(**logging_config) + """ + + if not log_file_path or log_file_path == "": + log_file_path = os.path.join( + get_invoke_folder(verbose), get_invoke_file_name(verbose) + ".log" + ) + + # set different log levels: + log_level = logging.WARNING + if verbose: + log_level = logging.INFO + print("INFO: Log File Path:", log_file_path) + if verbose > 1: + log_level = logging.DEBUG + + handlers = [ + logging.handlers.RotatingFileHandler( + log_file_path, maxBytes=5 * 1024 * 1024, backupCount=1 + ) + ] + + logging.addLevelName(99, "SESSION") + + # log output to console if arg provided: + if console: + handlers.append(logging.StreamHandler()) + print("INFO: also logging to console") + + # return logging config: + return { + "encoding": "utf-8", + "level": log_level, + "format": "%(asctime)s %(levelname)s:%(message)s", + "handlers": handlers, + "force": True, + } + + +def get_besapi_connection_env_then_config(): + """Get connection to besapi using env vars first, then config file.""" + logging.info("attempting connection to BigFix using ENV method.") + # try to get connection from env vars: + bes_conn = besapi.besapi.get_bes_conn_using_env() + if bes_conn: + return bes_conn + + logging.info("attempting connection to BigFix using config file method.") + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + return bes_conn + + +def get_besapi_connection_args( + args: argparse.Namespace, +) -> Union[besapi.besapi.BESConnection, None]: + """Get connection to besapi using provided args.""" + password = None + bes_conn = None + + if args.password: + password = args.password + + # if user was provided as arg but password was not: + if args.user and not password: + if os.name == "nt": + # attempt to get password from windows root server registry: + # this is specifically for the case where user is provided for a plugin + password = besapi.plugin_utilities_win.get_win_registry_rest_pass() + + # if user was provided as arg but password was not: + if args.user and not password: + logging.warning("Password was not provided, provide REST API password.") + print("Password was not provided, provide REST API password:") + password = getpass.getpass() + + if password: + logging.debug("REST API Password Length: %s", len(password)) + + # process args, setup connection: + rest_url = args.rest_url + + # normalize url to https://HostOrIP:52311 + if rest_url and rest_url.endswith("/api"): + rest_url = rest_url.replace("/api", "") + + # attempt bigfix connection with provided args: + if args.user and password: + try: + if not rest_url: + raise AttributeError("args.rest_url is not set.") + bes_conn = besapi.besapi.BESConnection(args.user, password, rest_url) + except ( + AttributeError, + ConnectionRefusedError, + besapi.besapi.requests.exceptions.ConnectionError, + ) as e: + logging.exception( + "connection to `%s` failed, attempting `%s` instead", + rest_url, + args.besserver, + ) + try: + if not args.besserver: + raise AttributeError("args.besserver is not set.") from e + bes_conn = besapi.besapi.BESConnection( + args.user, password, args.besserver + ) + # handle case where args.besserver is None + # AttributeError: 'NoneType' object has no attribute 'startswith' + except AttributeError: + logging.exception("----- ERROR: BigFix Connection Failed ------") + logging.exception( + "attempts to connect to BigFix using rest_url and besserver both failed" + ) + return None + except BaseException as err: # pylint: disable=broad-exception-caught + # always log error + logging.exception("ERROR: %s", err) + logging.exception( + "----- ERROR: BigFix Connection Failed! Unknown reason ------" + ) + return None + else: + logging.info( + "No user arg provided, no password found. Cannot create connection." + ) + return None + + return bes_conn + + +def get_besapi_connection( + args: Union[argparse.Namespace, None] = None, +) -> Union[besapi.besapi.BESConnection, None]: + """Get connection to besapi. + + If on Windows, will attempt to get connection from Windows Registry first. + If args provided, will attempt to get connection using provided args. + If no args provided, will attempt to get connection from env vars. + If no env vars, will attempt to get connection from config file. + + Arguments: + args: argparse.Namespace object, usually from setup_plugin_argparse() + Returns: + A BESConnection object if successful, otherwise None. + """ + # if windows, try to get connection from windows registry: + if os.name == "nt": + bes_conn = besapi.plugin_utilities_win.get_besconn_root_windows_registry() + if bes_conn: + return bes_conn + + # if no args provided, try to get connection from env then config file: + if not args: + logging.info("no args provided, attempting connection using env then config.") + return get_besapi_connection_env_then_config() + + # attempt bigfix connection with provided args: + if args.user: + bes_conn = get_besapi_connection_args(args) + else: + logging.info( + "no user arg provided, attempting connection using env then config." + ) + return get_besapi_connection_env_then_config() + + return bes_conn diff --git a/src/besapi/plugin_utilities_win.py b/src/besapi/plugin_utilities_win.py new file mode 100644 index 0000000..d705e9e --- /dev/null +++ b/src/besapi/plugin_utilities_win.py @@ -0,0 +1,213 @@ +""" +Plugin utilities for Windows systems, including DPAPI encryption/decryption +and Windows Registry access. +""" + +import base64 +import logging +import sys +from typing import Union + +import besapi + +logger = logging.getLogger(__name__) + +try: + import winreg +except (ImportError, ModuleNotFoundError) as e: + if not sys.platform.startswith("win"): + raise RuntimeError( + "This script requires the 'winreg' module, which is only available on Windows." + ) from e + else: + raise e + +try: + import win32crypt # type: ignore[import] +except (ImportError, ModuleNotFoundError) as e: + if not sys.platform.startswith("win"): + raise RuntimeError("This script only works on Windows systems") from e + raise ImportError( + "This script requires the pywin32 package. Install it via 'pip install pywin32'." + ) from e + + +def win_dpapi_encrypt_str( + plaintext: str, scope_flags: int = 4, entropy: Union[str, bytes, None] = None +) -> Union[str, None]: + """Encrypt a string using Windows DPAPI and return it as a base64-encoded string. + + Args: + plaintext (str): The string to encrypt. + scope_flags (int): The context scope for encryption (default is 4). + entropy (bytes | None): Optional entropy for encryption. + + Returns: + str: The base64-encoded encrypted string. + """ + if not plaintext or plaintext.strip() == "": + logger.warning("No plaintext provided for encryption.") + return None + + # 1. Convert the plaintext string to bytes + plaintext_bytes = plaintext.encode("utf-8") + + # 2. Call CryptProtectData. + # The last parameter (flags) is set to 4 + # to indicate that the data should be encrypted in the machine context. + # + # The function returns a tuple: (description, encrypted_bytes) + # We only need the second element. + encrypted_bytes = win32crypt.CryptProtectData( + plaintext_bytes, + None, # Description + entropy, # Optional entropy + None, # Reserved + None, # Prompt Struct + scope_flags, + ) + + # 3. Encode the encrypted bytes to a Base64 string + encrypted_b64 = base64.b64encode(encrypted_bytes).decode("utf-8") + + return encrypted_b64 + + +def win_dpapi_decrypt_base64( + encrypted_b64: str, scope_flags: int = 4, entropy: Union[str, bytes, None] = None +) -> Union[str, None]: + """Decrypt a base64-encoded string encrypted with Windows DPAPI. + + Args: + encrypted_b64 (str): The base64-encoded encrypted string. + scope_flags (int): The context scope for decryption (default is 4). + entropy (bytes | None): Optional entropy for decryption. + + Returns: + str: The decrypted string. + """ + if not encrypted_b64 or encrypted_b64.strip() == "": + logger.warning("No encrypted data provided for decryption.") + return None + + # 1. Decode the Base64 string to get the raw encrypted bytes + encrypted_bytes = base64.b64decode(encrypted_b64) + + # 2. Call CryptUnprotectData. + # The last parameter (flags) is set to 4 + # to indicate that the data was encrypted in the machine context. + # + # The function returns a tuple: (description, decrypted_bytes) + # We only need the second element. + _, decrypted_bytes = win32crypt.CryptUnprotectData( + encrypted_bytes, + entropy, # Optional entropy + None, # Reserved + None, # Prompt Struct + scope_flags, + ) + + if decrypted_bytes: + decrypted_string = decrypted_bytes.decode("utf-8").strip() + return decrypted_string + + logger.debug("Decryption returned no data.") + return None + + +def win_registry_value_read(hive, subkey, value_name): + """ + Reads a value from the Windows Registry. + + Args: + hive: The root hive (e.g., winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER). + subkey: The path to the subkey (e.g., "SOFTWARE\\Microsoft\\Windows\\CurrentVersion"). + value_name: The name of the value to read. + + Returns: + The value data if found, otherwise None. + """ + try: + # Open the specified registry key + key = winreg.OpenKey(hive, subkey, 0, winreg.KEY_READ) + + # Query the value data + value_data, _ = winreg.QueryValueEx(key, value_name) + + # Close the key + winreg.CloseKey(key) + + return value_data + except FileNotFoundError: + logger.debug("Registry key or value '%s\\%s' not found.", subkey, value_name) + return None + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("An error occurred: %s", e) + return None + + +def get_win_registry_rest_pass() -> Union[str, None]: + """ + Retrieves the base64 encrypted REST Password from the Windows Registry. + + Returns: + The REST Password if found, otherwise None. + """ + hive = winreg.HKEY_LOCAL_MACHINE # type: ignore[attr-defined] + # HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\BigFix\Enterprise Server\MFSConfig + subkey = r"SOFTWARE\Wow6432Node\BigFix\Enterprise Server\MFSConfig" + value_name = "RESTPassword" + + reg_value = win_registry_value_read(hive, subkey, value_name) + + if not reg_value: + logger.debug("No registry value found for %s.", value_name) + return None + + # remove {obf} from start of string if present: + if reg_value and reg_value.startswith("{obf}"): + reg_value = reg_value[5:] + + if reg_value and len(reg_value) > 50: + password = win_dpapi_decrypt_base64(reg_value) + if password and len(password) > 3: + return password + + logger.debug("Decryption failed or decrypted password length is too short.") + return None + + +def get_besconn_root_windows_registry() -> Union[besapi.besapi.BESConnection, None]: + """ + Attempts to create a BESConnection using credentials stored in the Windows + Registry. + + Returns: + A BESConnection object if successful, otherwise None. + """ + password = get_win_registry_rest_pass() + if not password: + return None + + hive = winreg.HKEY_LOCAL_MACHINE # type: ignore[attr-defined] + subkey = r"SOFTWARE\Wow6432Node\BigFix\Enterprise Server\MFSConfig" + + user = win_registry_value_read(hive, subkey, "RESTUsername") + + if not user: + return None + + rest_url = win_registry_value_read(hive, subkey, "RESTURL") + + if not rest_url: + return None + + # normalize url to https://HostOrIP:52311 + if rest_url.endswith("/api"): + rest_url = rest_url.replace("/api", "") + + try: + return besapi.besapi.BESConnection(user, password, rest_url) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Failed to create BESConnection from registry values: %s", e) + return None diff --git a/src/bescli/__init__.py b/src/bescli/__init__.py index b2069aa..e82a9fa 100644 --- a/src/bescli/__init__.py +++ b/src/bescli/__init__.py @@ -1,6 +1,5 @@ -""" -bescli provides a command line interface to interact with besapi -""" +"""This bescli provides a command line interface to interact with besapi.""" + # https://stackoverflow.com/questions/279237/import-a-module-from-a-relative-path/4397291 from . import bescli diff --git a/src/bescli/__main__.py b/src/bescli/__main__.py index 789509e..66ca7d8 100644 --- a/src/bescli/__main__.py +++ b/src/bescli/__main__.py @@ -1,6 +1,8 @@ -""" -To run this module directly -""" +"""To run this module directly.""" + +import logging + from . import bescli +logging.basicConfig() bescli.main() diff --git a/src/bescli/bescli.py b/src/bescli/bescli.py index 4e70558..2f22231 100644 --- a/src/bescli/bescli.py +++ b/src/bescli/bescli.py @@ -1,6 +1,6 @@ #!/usr/bin/env python """ -bescli.py +This bescli module provides a command line interface to interact with besapi. MIT License Copyright (c) 2014 Matt Hansen @@ -10,6 +10,7 @@ """ import getpass +import json import logging import os import site @@ -26,9 +27,12 @@ from besapi import besapi except ImportError: # this is for the case in which we are calling bescli from besapi - import besapi + import besapi # type: ignore[no-redef] -from besapi import __version__ +try: + from besapi.besapi import __version__ +except ImportError: + from besapi import __version__ # type: ignore[attr-defined, no-redef] class BESCLInterface(Cmd): @@ -36,6 +40,13 @@ class BESCLInterface(Cmd): def __init__(self, **kwargs): Cmd.__init__(self, **kwargs) + + # set an intro message + self.intro = ( + f"\nWelcome to the BigFix REST API Interactive Python Module v{__version__}" + ) + + # sets the prompt look: self.prompt = "BigFix> " self.num_errors = 0 @@ -46,10 +57,57 @@ def __init__(self, **kwargs): # set default config file path self.conf_path = os.path.expanduser("~/.besapi.conf") self.CONFPARSER = SafeConfigParser() + # for completion: + self.api_resources = [] self.do_conf() + def parse_help_resources(self): + """Get api resources from help.""" + if self.bes_conn: + help_result = self.bes_conn.get("help") + help_result = help_result.text.split("\n") + # print(help_result) + help_resources = [] + for item in help_result: + if "/api/" in item: + _, _, res = item.partition("/api/") + # strip whitespace just in case: + help_resources.append(res.strip()) + + return help_resources + else: + return [ + "actions", + "clientqueryresults", + "dashboardvariables", + "help", + "login", + "query", + "relaysites", + "serverinfo", + "sites", + ] + + def complete_api_resources(self, text, line, begidx, endidx): + """Define completion for apis.""" + + # only initialize once + if not self.api_resources: + self.api_resources = self.parse_help_resources() + + # TODO: make this work to complete only the first word after get/post/delete + # return the matching subset: + return [name for name in self.api_resources if name.startswith(text)] + + complete_get = complete_api_resources + def do_get(self, line): - """Perform get request to BigFix server using provided api endpoint argument""" + """Perform get request to BigFix server using provided api endpoint + argument. + """ + + # remove any extra whitespace + line = line.strip() # Remove root server prefix: # if root server prefix is not removed @@ -67,31 +125,57 @@ def do_get(self, line): b = self.bes_conn.get(robjs[0]) # print objectify.ObjectPath(robjs[1:]) if b: - print(eval("b()." + ".".join(robjs[1:]))) + self.poutput(eval("b()." + ".".join(robjs[1:]))) else: output_item = self.bes_conn.get(line) - # print(type(output_item)) - print(output_item) - # print(output_item.besdict) - # print(output_item.besjson) + # self.poutput(type(output_item)) + self.poutput(output_item) + # self.poutput(output_item.besdict) + # self.poutput(output_item.besjson) else: self.pfeedback("Not currently logged in. Type 'login'.") + complete_delete = complete_api_resources + + def do_delete(self, line): + """Perform delete request to BigFix server using provided api endpoint + argument. + """ + + # remove any extra whitespace + line = line.strip() + + # Remove root server prefix: + if "/api/" in line: + line = str(line).split("/api/", 1)[1] + self.pfeedback("get " + line) + + if self.bes_conn: + output_item = self.bes_conn.delete(line) + + self.poutput(output_item) + # self.poutput(output_item.besdict) + # self.poutput(output_item.besjson) + else: + self.pfeedback("Not currently logged in. Type 'login'.") + + complete_post = complete_api_resources + def do_post(self, statement): - """post file as data to path""" - print(statement) - print("not yet implemented") + """Post file as data to path.""" + self.poutput(statement) + self.poutput("not yet implemented") def do_config(self, conf_file=None): - """Attempt to load config info from file and login""" + """Attempt to load config info from file and login.""" self.do_conf(conf_file) def do_loadconfig(self, conf_file=None): - """Attempt to load config info from file and login""" + """Attempt to load config info from file and login.""" self.do_conf(conf_file) def do_conf(self, conf_file=None): - """Attempt to load config info from file and login""" + """Attempt to load config info from file and login.""" config_path = [ "/etc/besapi.conf", os.path.expanduser("~/besapi.conf"), @@ -111,7 +195,6 @@ def do_conf(self, conf_file=None): self.conf_path = found_config_files[0] if self.CONFPARSER: - try: self.BES_ROOT_SERVER = self.CONFPARSER.get("besapi", "BES_ROOT_SERVER") except BaseException: @@ -133,8 +216,22 @@ def do_conf(self, conf_file=None): self.pfeedback(" - attempt login using config parameters - ") self.do_login() + def do_login_new(self, user=None): + """Login to BigFix Server.""" + if not user or str(user).strip() == "": + user = self.BES_USER_NAME + self.bes_conn = besapi.get_bes_conn_interactive( + user=user, + password=self.BES_PASSWORD, + root_server=self.BES_ROOT_SERVER, + ) + if self.bes_conn: + self.pfeedback("Login Successful!") + (self.BES_USER_NAME, self.BES_PASSWORD) = self.bes_conn.session.auth + self.BES_ROOT_SERVER = self.bes_conn.rootserver + def do_login(self, user=None): - """Login to BigFix Server""" + """Login to BigFix Server.""" if not user: if self.BES_USER_NAME: @@ -144,7 +241,7 @@ def do_login(self, user=None): if not user: user = getpass.getuser() - self.BES_USER_NAME = user + self.BES_USER_NAME = user.strip() if not self.CONFPARSER.has_section("besapi"): self.CONFPARSER.add_section("besapi") self.CONFPARSER.set("besapi", "BES_USER_NAME", user) @@ -154,14 +251,14 @@ def do_login(self, user=None): if not root_server: root_server = str(input("Root Server [%s]: " % self.BES_ROOT_SERVER)) - self.BES_ROOT_SERVER = root_server + self.BES_ROOT_SERVER = root_server.strip() else: root_server = str( input("Root Server (ex. %s): " % "https://server.institution.edu:52311") ) if root_server: - self.BES_ROOT_SERVER = root_server + self.BES_ROOT_SERVER = root_server.strip() if not self.CONFPARSER.has_section("besapi"): self.CONFPARSER.add_section("besapi") self.CONFPARSER.set("besapi", "BES_ROOT_SERVER", root_server) @@ -212,8 +309,8 @@ def do_login(self, user=None): else: self.perror("Login Error!") - def do_logout(self, arg=None): - """Logout and clear session""" + def do_logout(self, _=None): + """Logout and clear session.""" if self.bes_conn: self.bes_conn.logout() # del self.bes_conn @@ -221,8 +318,8 @@ def do_logout(self, arg=None): self.pfeedback("Logout Complete!") def do_debug(self, setting): - """Enable or Disable Debug Mode""" - print(bool(setting)) + """Enable or Disable Debug Mode.""" + self.poutput(bool(setting)) self.debug = bool(setting) self.echo = bool(setting) self.quiet = bool(setting) @@ -233,7 +330,7 @@ def do_debug(self, setting): logging.getLogger("besapi").setLevel(logging.WARNING) def do_clear(self, arg=None): - """clear current config and logout""" + """Clear current config and logout.""" if self.bes_conn: self.bes_conn.logout() # self.bes_conn = None @@ -253,15 +350,15 @@ def do_clear(self, arg=None): self.BES_PASSWORD = None def do_saveconfig(self, arg=None): - """save current config to file""" + """Save current config to file.""" self.do_saveconf(arg) - def do_saveconf(self, arg=None): - """save current config to file""" + def do_saveconf(self, _=None): + """Save current config to file.""" if not self.bes_conn: self.do_login() if not self.bes_conn: - print("Can't save config without working login") + self.poutput("Can't save config without working login") else: conf_file_path = self.conf_path self.pfeedback(f"Saving Config File to: {conf_file_path}") @@ -269,43 +366,54 @@ def do_saveconf(self, arg=None): self.CONFPARSER.write(configfile) def do_showconfig(self, arg=None): - """List the current settings and connection status""" + """List the current settings and connection status.""" self.do_ls(arg) - def do_ls(self, arg=None): - """List the current settings and connection status""" - print(" Connected: " + str(bool(self.bes_conn))) - print( + def do_ls(self, _=None): + """List the current settings and connection status.""" + self.poutput(" Connected: " + str(bool(self.bes_conn))) + self.poutput( " BES_ROOT_SERVER: " + (self.BES_ROOT_SERVER if self.BES_ROOT_SERVER else "") ) - print( + self.poutput( " BES_USER_NAME: " + (self.BES_USER_NAME if self.BES_USER_NAME else "") ) - print( + self.poutput( " Password Length: " + str(len(self.BES_PASSWORD if self.BES_PASSWORD else "")) ) - print(" Config File Path: " + self.conf_path) + self.poutput(" Config File Path: " + self.conf_path) if self.bes_conn: - print("Current Site Path: " + self.bes_conn.get_current_site_path(None)) + self.poutput( + "Current Site Path: " + self.bes_conn.get_current_site_path(None) + ) - def do_error_count(self, arg=None): - """Output the number of errors""" + def do_error_count(self, _=None): + """Output the number of errors.""" self.poutput(f"Error Count: {self.num_errors}") - def do_exit(self, arg=None): - """Exit this application""" + def do_exit(self, _=None): + """Exit this application.""" self.exit_code = self.num_errors # no matter what I try I can't get anything but exit code 0 on windows return self.do_quit("") + def do_am_i_main_operator(self, _=None): + """Check if the connection user is a main operator user.""" + if not self.bes_conn: + self.do_login() + if not self.bes_conn: + self.perror("ERROR: can't check without login") + else: + self.poutput(f"Am I Main Operator? {self.bes_conn.am_i_main_operator()}") + def do_query(self, statement): - """Get Session Relevance Results""" + """Get Session Relevance Results.""" if not self.bes_conn: self.do_login() if not self.bes_conn: - self.poutput("ERROR: can't query without login") + self.perror("ERROR: can't query without login") else: if statement.raw: # get everything after `query ` @@ -315,102 +423,127 @@ def do_query(self, statement): self.pfeedback("A: ") self.poutput(rel_result) - def do_version(self, statement=None): - """output version of besapi""" + def do_version(self, _=None): + """Output version of besapi.""" self.poutput(f"besapi version: {__version__}") def do_get_action(self, statement=None): - """usage: get_action 123""" + """Usage: get_action 123.""" result_op = self.bes_conn.get(f"action/{statement}") self.poutput(result_op) def do_get_operator(self, statement=None): - """usage: get_operator ExampleOperatorName""" + """Usage: get_operator ExampleOperatorName.""" result_op = self.bes_conn.get_user(statement) self.poutput(result_op) - def do_get_current_site(self, statement=None): - """output current site path context""" + def do_get_current_site(self, _=None): + """Output current site path context.""" self.poutput( - f"Current Site Path: `{ self.bes_conn.get_current_site_path(None) }`" + f"Current Site Path: `{self.bes_conn.get_current_site_path(None)}`" ) def do_set_current_site(self, statement=None): - """set current site path context""" + """Set current site path context.""" self.poutput( - f"New Site Path: `{ self.bes_conn.set_current_site_path(statement) }`" + f"New Site Path: `{self.bes_conn.set_current_site_path(statement)}`" ) def do_get_content(self, resource_url): - """get a specific item by resource url""" - print(self.bes_conn.get_content_by_resource(resource_url)) + """Get a specific item by resource url.""" + self.poutput(self.bes_conn.get_content_by_resource(resource_url)) def do_export_item_by_resource(self, statement): - """export content itemb to current folder""" - print(self.bes_conn.export_item_by_resource(statement)) + """Export content itemb to current folder.""" + self.poutput(self.bes_conn.export_item_by_resource(statement)) def do_export_site(self, site_path): - """export site contents to current folder""" + """Export site contents to current folder.""" self.bes_conn.export_site_contents( site_path, verbose=True, include_site_folder=False, include_item_ids=False ) - def do_export_all_sites(self, statement=None): - """export site contents to current folder""" + def do_export_all_sites(self, _=None): + """Export site contents to current folder.""" self.bes_conn.export_all_sites(verbose=False) + complete_import_bes = Cmd.path_complete + + def do_import_bes(self, statement): + """Import bes file.""" + + bes_file_path = str(statement.args).strip() + + site_path = self.bes_conn.get_current_site_path(None) + + self.poutput(f"Import file: {bes_file_path}") + + self.poutput(self.bes_conn.import_bes_to_site(bes_file_path, site_path)) + complete_upload = Cmd.path_complete def do_upload(self, file_path): - """upload file to root server""" + """Upload file to root server.""" if not os.access(file_path, os.R_OK): - print(file_path, "is not a readable file") + self.poutput(file_path, "is not a readable file") else: upload_result = self.bes_conn.upload(file_path) - print(upload_result) - print(self.bes_conn.parse_upload_result_to_prefetch(upload_result)) + self.poutput(upload_result) + self.poutput(self.bes_conn.parse_upload_result_to_prefetch(upload_result)) complete_create_group = Cmd.path_complete def do_create_group(self, file_path): - """create bigfix group from bes file""" + """Create bigfix group from bes file.""" if not os.access(file_path, os.R_OK): - print(file_path, "is not a readable file") + self.poutput(file_path, "is not a readable file") else: - print(self.bes_conn.create_group_from_file(file_path)) + self.poutput(self.bes_conn.create_group_from_file(file_path)) complete_create_user = Cmd.path_complete def do_create_user(self, file_path): - """create bigfix user from bes file""" + """Create bigfix user from bes file.""" if not os.access(file_path, os.R_OK): - print(file_path, "is not a readable file") + self.poutput(file_path, "is not a readable file") else: - print(self.bes_conn.create_user_from_file(file_path)) + self.poutput(self.bes_conn.create_user_from_file(file_path)) complete_create_site = Cmd.path_complete def do_create_site(self, file_path): - """create bigfix site from bes file""" + """Create bigfix site from bes file.""" if not os.access(file_path, os.R_OK): - print(file_path, "is not a readable file") + self.poutput(file_path, "is not a readable file") else: - print(self.bes_conn.create_site_from_file(file_path)) + self.poutput(self.bes_conn.create_site_from_file(file_path)) complete_update_item = Cmd.path_complete def do_update_item(self, file_path): - """update bigfix content item from bes file""" + """Update bigfix content item from bes file.""" if not os.access(file_path, os.R_OK): - print(file_path, "is not a readable file") + self.poutput(file_path, " is not a readable file") else: - print(self.bes_conn.update_item_from_file(file_path)) + self.poutput(self.bes_conn.update_item_from_file(file_path)) + + def do_serverinfo(self, _=None): + """Get server info and return formatted.""" + + # not sure what the minimum version for this is: + result = self.bes_conn.get("serverinfo") + + result_json = json.loads(result.text) + + self.poutput(f"\nServer Info for {self.BES_ROOT_SERVER}") + self.poutput(json.dumps(result_json, indent=2)) def main(): - """Run the command loop if invoked""" + """Run the command loop if invoked.""" BESCLInterface().cmdloop() if __name__ == "__main__": + logging.basicConfig() main() diff --git a/tests/bad/ComputerGroups_BAD.bes b/tests/bad/ComputerGroups_BAD.bes new file mode 100644 index 0000000..172f463 --- /dev/null +++ b/tests/bad/ComputerGroups_BAD.bes @@ -0,0 +1,35 @@ + + + + Non-BigFix Systems + + + + Docker - Hosts - Linux + 6818 + + + Docker - Containers - Linux + 6819 + + + Windows non-BigFix + 7293 + + + VM - VMWare + 7349 + + + VM - Hyper-V + 7354 + + + VM - Azure + 7357 + + + VM - AWS + 7358 + + diff --git a/tests/bad/RelaySelectTask_BAD.bes b/tests/bad/RelaySelectTask_BAD.bes new file mode 100644 index 0000000..68f4985 --- /dev/null +++ b/tests/bad/RelaySelectTask_BAD.bes @@ -0,0 +1,27 @@ + + + + RelaySelect + + Internal + jgstew + 2021-08-03 + + + + + x-fixlet-modification-time + Tue, 03 Aug 2021 15:18:27 +0000 + + BESC + + + Click + here + to deploy this action. + + + + + diff --git a/tests/good/ComputerGroupsExample.bes b/tests/good/ComputerGroupsExample.bes new file mode 100644 index 0000000..618790c --- /dev/null +++ b/tests/good/ComputerGroupsExample.bes @@ -0,0 +1,35 @@ + + + + Non-BigFix Systems + 6350 + + + Docker - Hosts - Linux + 6818 + + + Docker - Containers - Linux + 6819 + + + Windows non-BigFix + 7293 + + + VM - VMWare + 7349 + + + VM - Hyper-V + 7354 + + + VM - Azure + 7357 + + + VM - AWS + 7358 + + diff --git a/tests/good/RelaySelectTask.bes b/tests/good/RelaySelectTask.bes new file mode 100644 index 0000000..78d2117 --- /dev/null +++ b/tests/good/RelaySelectTask.bes @@ -0,0 +1,31 @@ + + + + RelaySelect + + not exists relay service + not exists main gather service + + + Internal + jgstew + 2021-08-03 + + + + + x-fixlet-modification-time + Tue, 03 Aug 2021 15:18:27 +0000 + + BESC + + + Click + here + to deploy this action. + + + + + diff --git a/tests/test_besapi.py b/tests/test_besapi.py new file mode 100644 index 0000000..db525c9 --- /dev/null +++ b/tests/test_besapi.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python +"""Test besapi with pytest. + +This was converted from tests/tests.py which was used before this pytest was added. +""" + +import json +import os +import random +import subprocess +import sys + +import pytest + +# mypy: disable-error-code="arg-type" + +if not os.getenv("TEST_PIP"): + # add module folder to import paths for testing local src + sys.path.append( + os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src") + ) + # reverse the order so we make sure to get the local src module + sys.path.reverse() + +import besapi +import besapi.plugin_utilities + +if os.name == "nt": + import besapi.plugin_utilities_win + + +def test_besapi_version(): + """Test that the besapi version is not None.""" + assert besapi.besapi.__version__ is not None + + +def test_rand_password(): + """Test that the generated random password has the correct length.""" + assert 15 == len(besapi.besapi.rand_password(15)) + + +def test_sanitize_txt(): + """Test that the sanitize_txt function works correctly.""" + assert ("test--string", "test") == besapi.besapi.sanitize_txt( + r"test/\string", "test%" + ) + + +def test_replace_text_between(): + """Test that the replace_text_between function works correctly.""" + assert "http://localhost:52311/file.example" == besapi.besapi.replace_text_between( + "http://example:52311/file.example", "://", ":52311", "localhost" + ) + + +def test_validate_site_path(): + """Test the validate_site_path function with various inputs.""" + assert "master" in besapi.besapi.BESConnection.validate_site_path( + "", "master", False + ) + assert "custom/" in besapi.besapi.BESConnection.validate_site_path( + "", "custom/Example", False + ) + assert "operator/" in besapi.besapi.BESConnection.validate_site_path( + "", "operator/Example", False + ) + + +def test_validate_xml_bes_file(): + """Test the validate_xml_bes_file function with good and bad files.""" + assert besapi.besapi.validate_xml_bes_file("tests/good/RelaySelectTask.bes") is True + assert ( + besapi.besapi.validate_xml_bes_file("tests/good/ComputerGroupsExample.bes") + is True + ) + assert ( + besapi.besapi.validate_xml_bes_file("tests/bad/RelaySelectTask_BAD.bes") + is False + ) + assert ( + besapi.besapi.validate_xml_bes_file("tests/bad/ComputerGroups_BAD.bes") is False + ) + + +def test_failing_validate_site_path(): + """Test that validate_site_path raises ValueError for invalid inputs.""" + + with pytest.raises(ValueError): + besapi.besapi.BESConnection.validate_site_path("", "bad/Example", False) + + with pytest.raises(ValueError): + besapi.besapi.BESConnection.validate_site_path("", "bad/master", False) + + with pytest.raises(ValueError): + besapi.besapi.BESConnection.validate_site_path("", "", False, True) + + with pytest.raises(ValueError): + besapi.besapi.BESConnection.validate_site_path("", None, False, True) + + +class RequestResult: + text = "this is just a test" + headers: list = [] + + +def test_rest_result(): + """Test the RESTResult class.""" + request_result = RequestResult() + rest_result = besapi.besapi.RESTResult(request_result) + + assert rest_result.besdict is not None + assert rest_result.besjson is not None + assert b"Example" in rest_result.xmlparse_text("Example") + assert rest_result.text == "this is just a test" + + +def test_parse_bes_modtime(): + """Test the parse_bes_modtime function.""" + assert ( + 2017 == besapi.besapi.parse_bes_modtime("Tue, 05 Sep 2017 23:31:48 +0000").year + ) + + +def test_get_action_combined_relevance(): + """Test the get_action_combined_relevance function.""" + assert ( + "( ( True ) AND ( windows of operating system ) ) AND ( False )" + == besapi.besapi.get_action_combined_relevance( + ["True", "windows of operating system", "False"] + ) + ) + + +def test_get_target_xml(): + """Test the get_target_xml function with various inputs.""" + assert "False" == besapi.besapi.get_target_xml() + assert "true" == besapi.besapi.get_target_xml( + "" + ) + assert "1" == besapi.besapi.get_target_xml(1) + assert ( + "" + == besapi.besapi.get_target_xml("not windows of operating system") + ) + assert ( + "12" + == besapi.besapi.get_target_xml([1, 2]) + ) + assert ( + "Computer 1Another Computer" + == besapi.besapi.get_target_xml(["Computer 1", "Another Computer"]) + ) + + +def test_bescli(): + """Test the BESCLInterface class and its methods.""" + import bescli + + bigfix_cli = bescli.bescli.BESCLInterface() + + # just make sure these don't throw errors: + bigfix_cli.do_ls() + bigfix_cli.do_clear() + bigfix_cli.do_ls() + bigfix_cli.do_logout() + bigfix_cli.do_error_count() + bigfix_cli.do_version() + bigfix_cli.do_conf() + + # this should really only run if the config file is present: + if bigfix_cli.bes_conn: + # session relevance tests require functioning web reports server + assert ( + int(bigfix_cli.bes_conn.session_relevance_string("number of bes computers")) + > 0 + ) + assert ( + "test session relevance string result" + in bigfix_cli.bes_conn.session_relevance_string( + '"test session relevance string result"' + ) + ) + bigfix_cli.do_set_current_site("master") + + # set working directory to folder this file is in: + os.chdir(os.path.dirname(os.path.abspath(__file__))) + + # set working directory to src folder in parent folder + os.chdir("../src") + + # Test file upload: + upload_result = bigfix_cli.bes_conn.upload( + "./besapi/__init__.py", "test_besapi_upload.txt" + ) + # print(upload_result) + assert upload_result.besobj.FileUpload.Available == 1 + assert upload_result.besdict["FileUpload"]["Available"] == "1" + assert upload_result.besjson is not None + upload_result_json = json.loads(upload_result.besjson) + assert upload_result_json["FileUpload"]["Available"] == "1" + + assert "test_besapi_upload.txt" in str(upload_result) + upload_prefetch = bigfix_cli.bes_conn.parse_upload_result_to_prefetch( + upload_result + ) + # print(upload_prefetch) + assert "prefetch test_besapi_upload.txt sha1:" in upload_prefetch + + dashboard_name = "_PyBESAPI_tests.py" + var_name = "TestVarName" + var_value = "TestVarValue " + str(random.randint(0, 9999)) + + assert var_value in str( + bigfix_cli.bes_conn.set_dashboard_variable_value( + dashboard_name, var_name, var_value + ) + ) + + assert var_value in str( + bigfix_cli.bes_conn.get_dashboard_variable_value(dashboard_name, var_name) + ) + + if os.name == "nt": + subprocess.run( + 'CMD /C python -m besapi ls clear ls conf "query number of bes computers" version error_count exit', + check=True, + ) + + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + print("login succeeded:", bes_conn.login()) + assert bes_conn.login() + + +def test_plugin_utilities_win_get_besconn_root_windows_registry(): + """Test getting a BESConnection from the Windows Registry.""" + if "BES_ROOT_SERVER" not in os.environ: + pytest.skip("Skipping Windows Registry test, BES_ROOT_SERVER not set.") + if "BES_USER_NAME" not in os.environ: + pytest.skip("Skipping Windows Registry test, BES_USER_NAME not set.") + if "BES_PASSWORD" not in os.environ: + pytest.skip("Skipping Windows Registry test, BES_PASSWORD not set.") + + if not os.name == "nt": + pytest.skip("Skipping Windows Registry test on non-Windows system.") + + # only run this test if besapi > v3.9.1: + if besapi.besapi.__version__ <= "3.9.1": + pytest.skip("Skipping test for besapi <= 3.9.1") + + # get env vars for testing: + root_server = os.getenv("BES_ROOT_SERVER") + root_user = os.getenv("BES_USER_NAME") + root_user_password = os.getenv("BES_PASSWORD") + + encrypted_str = besapi.plugin_utilities_win.win_dpapi_encrypt_str( + root_user_password + ) + + import winreg + + # write user and encrypted password to registry for testing: + # HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\BigFix\Enterprise Server\MFSConfig + subkey = r"SOFTWARE\Wow6432Node\BigFix\Enterprise Server\MFSConfig" + hive = winreg.HKEY_LOCAL_MACHINE # type: ignore[attr-defined] + + key = winreg.CreateKey(hive, subkey) + winreg.SetValueEx( + key, "RESTURL", 0, winreg.REG_SZ, "https://" + root_server + ":52311/api" + ) + winreg.SetValueEx(key, "RESTUsername", 0, winreg.REG_SZ, root_user) + winreg.SetValueEx(key, "RESTPassword", 0, winreg.REG_SZ, "{obf}" + encrypted_str) + winreg.CloseKey(key) + bes_conn = besapi.plugin_utilities_win.get_besconn_root_windows_registry() + assert bes_conn is not None + + +def test_bes_conn_json(): + """Test the BESConnection class with JSON output.""" + + bes_conn = besapi.plugin_utilities.get_besapi_connection(None) + + if bes_conn and bes_conn.login(): + print("testing session_relevance_json") + result = bes_conn.session_relevance_json("number of all bes sites") + assert result is not None + assert int(result["result"][0]) > 0 + result = bes_conn.session_relevance_json( + """("[%22" & it & "%22]") of concatenation "%22, %22" of names of all bes sites""" + ) + assert result is not None + string_first_result_json = result["result"][0] + print(string_first_result_json) + assert '", "' in string_first_result_json + assert '["' in string_first_result_json + assert '"BES Support"' in string_first_result_json + + print("testing session_relevance_json_array") + result = bes_conn.session_relevance_json_array("number of all bes sites") + print(result) + assert result is not None + assert int(result[0]) > 0 + print("testing session_relevance_json_string") + result = bes_conn.session_relevance_json_string("number of all bes sites") + print(result) + assert result is not None + assert int(result) > 0 + print("testing session_relevance_json_string tuple") + result = bes_conn.session_relevance_json_string( + '(ids of it, names of it, "TestString") of all bes sites' + ) + print(result) + assert result is not None + assert "TestString" in result + assert "BES Support" in result + print("testing session relevance with + in relevance") + str_relevance_plus = 'lengths of first matches (regex " +") of " "' + result = bes_conn.session_relevance_xml(str_relevance_plus) + print(result) + assert result is not None + else: + pytest.skip("Skipping BESConnection test, no config file or login failed.") + + +def test_bes_conn_session_relevance_with_special_characters(): + """Test the BESConnection class with JSON output.""" + + bes_conn = besapi.plugin_utilities.get_besapi_connection(None) + + if bes_conn and bes_conn.login(): + print("testing session relevance with + in relevance") + str_relevance_plus = 'lengths of first matches (regex " +") of " "' + str_relevance_answer = "8" + result = bes_conn.session_relevance_xml(str_relevance_plus) + print(result) + assert result is not None + # this test is failing and needs fixed: + # assert f'{str_relevance_answer}' in str(result) + result = bes_conn.session_relevance_json_string(str_relevance_plus) + print(result) + assert result is not None + assert str_relevance_answer in str(result) + result = bes_conn.session_relevance_json(str_relevance_plus) + print(result) + assert result is not None + assert f"[{str_relevance_answer}]" in str(result["result"]) + result = bes_conn.session_relevance_string(str_relevance_plus) + print(result) + assert result is not None + # this test is failing and needs fixed: + # assert str_relevance_answer in str(result) + result = bes_conn.session_relevance_array(str_relevance_plus) + print(result) + assert result is not None + # this test is failing and needs fixed: + # assert f"['{str_relevance_answer}']" in str(result) + result = bes_conn.session_relevance_json_array(str_relevance_plus) + print(result) + assert result is not None + assert f"[{str_relevance_answer}]" in str(result) + else: + pytest.skip("Skipping BESConnection test, no config file or login failed.") + + +def test_bes_conn_upload_always(): + """Test the BESConnection class with JSON output.""" + file_name = "LICENSE.txt" + file_path = "../" + file_name + if not os.path.isfile(os.path.abspath(file_path)): + # handle case where not running from src or tests folder. + file_path = "./" + file_name + assert os.path.isfile(os.path.abspath(file_path)) + + bes_conn = besapi.plugin_utilities.get_besapi_connection(None) + if bes_conn and bes_conn.login(): + # test upload + # Example Header:: Content-Disposition: attachment; filename="file.xml" + headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} + with open(file_path, "rb") as f: + result = bes_conn.post(bes_conn.url("upload"), data=f, headers=headers) + print(result) + assert result is not None + assert result.besobj.FileUpload.Available == 1 + assert result.besdict["FileUpload"]["Available"] == "1" + else: + pytest.skip("Skipping BESConnection upload test, login failed.") + + +def test_plugin_utilities_logging(): + """Test the plugin_utilities module.""" + print(besapi.plugin_utilities.get_invoke_folder()) + print(besapi.plugin_utilities.get_invoke_file_name()) + + parser = besapi.plugin_utilities.setup_plugin_argparse(plugin_args_required=False) + # allow unknown args to be parsed instead of throwing an error: + args, _unknown = parser.parse_known_args() + + # test logging plugin_utilities: + import logging + + logging_config = besapi.plugin_utilities.get_plugin_logging_config("./tests.log") + + # this use of logging.basicConfig requires python >= 3.9 + if sys.version_info >= (3, 9): + logging.basicConfig(**logging_config) + + logging.warning("Just testing to see if logging is working!") + + assert os.path.isfile("./tests.log") + + +def test_plugin_utilities_win_dpapi(): + """Test the Windows DPAPI encryption function, if on Windows.""" + if not os.name == "nt": + pytest.skip("Skipping Windows Registry test on non-Windows system.") + + # only run this test if besapi > v3.8.3: + if besapi.besapi.__version__ <= "3.8.3": + pytest.skip("Skipping test for besapi <= 3.8.3") + + test_string = "This is just a test string " + str(random.randint(0, 9999)) + encrypted_str = besapi.plugin_utilities_win.win_dpapi_encrypt_str(test_string) + print("Encrypted string:", encrypted_str) + assert encrypted_str != "" + assert encrypted_str != test_string + decrypted_str = besapi.plugin_utilities_win.win_dpapi_decrypt_base64(encrypted_str) + print("Decrypted string:", decrypted_str) + assert decrypted_str == test_string + + +def test_plugin_utilities_win_win_registry_value_read(): + """Test reading a Windows registry value.""" + if not os.name == "nt": + pytest.skip("Skipping Windows Registry test on non-Windows system.") + + # only run this test if besapi > v3.8.3: + if besapi.besapi.__version__ <= "3.8.3": + pytest.skip("Skipping test for besapi <= 3.8.3") + + import winreg + + registry_key = r"SOFTWARE\Microsoft\Windows\CurrentVersion" + registry_value = "ProgramFilesDir" + result = besapi.plugin_utilities_win.win_registry_value_read( + winreg.HKEY_LOCAL_MACHINE, registry_key, registry_value + ) + + assert result is not None + print("Registry value:", result) + assert "Program Files" in result + + +def test_plugin_utilities_win_get_win_registry_rest_pass(): + """Test getting the Windows Registry REST password.""" + if not os.name == "nt": + pytest.skip("Skipping Windows Registry test on non-Windows system.") + + # only run this test if besapi > v3.8.3: + if besapi.besapi.__version__ <= "3.8.3": + pytest.skip("Skipping test for besapi <= 3.8.3") + + import winreg + + test_string = "This is just a test string " + str(random.randint(0, 9999)) + encrypted_str = besapi.plugin_utilities_win.win_dpapi_encrypt_str(test_string) + + # write encrypted string to registry for testing: + # HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\BigFix\Enterprise Server\MFSConfig + subkey = r"SOFTWARE\Wow6432Node\BigFix\Enterprise Server\MFSConfig" + + key = winreg.CreateKey(winreg.HKEY_LOCAL_MACHINE, subkey) + winreg.SetValueEx(key, "RESTPassword", 0, winreg.REG_SZ, "{obf}" + encrypted_str) + winreg.CloseKey(key) + + result = besapi.plugin_utilities_win.get_win_registry_rest_pass() + assert result is not None + print("Windows Registry REST password:", result) + assert result == test_string diff --git a/tests/test_examples.txt b/tests/test_examples.txt new file mode 100644 index 0000000..000f866 --- /dev/null +++ b/tests/test_examples.txt @@ -0,0 +1,6 @@ + +The sanitize_txt function makes it so the text is safe for use as file names. + +>>> import besapi +>>> print(besapi.besapi.sanitize_txt("-/abc?defg^&*()")) +('--abcdefg()',) diff --git a/tests/test_plugin_utilities.py b/tests/test_plugin_utilities.py new file mode 100644 index 0000000..89666ff --- /dev/null +++ b/tests/test_plugin_utilities.py @@ -0,0 +1,60 @@ +import os +import sys + +import pytest + +# Ensure the local `src/` is first on sys.path so tests import the workspace package +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SRC = os.path.join(ROOT, "src") +if SRC not in sys.path: + sys.path.insert(0, SRC) + +from besapi import plugin_utilities + + +def test_setup_plugin_argparse_defaults(): + """Test that default args are set correctly.""" + parser = plugin_utilities.setup_plugin_argparse(plugin_args_required=False) + # ensure parser returns expected arguments when not required + args = parser.parse_args([]) + assert args.verbose == 0 + assert args.console is False + assert args.besserver is None + assert args.rest_url is None + assert args.user is None + assert args.password is None + + +def test_setup_plugin_argparse_required_flags(): + """Test that required args cause SystemExit when missing.""" + parser = plugin_utilities.setup_plugin_argparse(plugin_args_required=True) + # when required, missing required args should cause SystemExit + with pytest.raises(SystemExit): + parser.parse_args([]) + + +def test_get_plugin_args_parses_known_args(monkeypatch): + """Test that known command line args are parsed correctly.""" + # simulate command line args + monkeypatch.setattr( + sys, + "argv", + [ + "prog", + "-v", + "-c", + "--rest-url", + "https://example:52311", + "--user", + "me", + "--password", + "pw", + ], + ) + args = plugin_utilities.get_plugin_args(plugin_args_required=False) + assert args.verbose == 1 + assert args.console is True + assert args.rest_url == "https://example:52311" + assert args.user == "me" + assert args.password == "pw" + assert args.besserver is None diff --git a/tests/test_plugin_utilities_logging.py b/tests/test_plugin_utilities_logging.py new file mode 100644 index 0000000..64b5a9c --- /dev/null +++ b/tests/test_plugin_utilities_logging.py @@ -0,0 +1,78 @@ +import logging +import os +import sys +import tempfile + +import pytest + +# Ensure the local `src/` is first on sys.path so tests import the workspace package +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SRC = os.path.join(ROOT, "src") +if SRC not in sys.path: + sys.path.insert(0, SRC) + +from besapi import plugin_utilities + + +def test_get_plugin_logging_config_default(tmp_path, capsys): + """Test default logging config with no verbosity and no console.""" + # Use an explicit log file path in a temp dir to avoid touching real files + log_file = tmp_path / "test.log" + + cfg = plugin_utilities.get_plugin_logging_config( + str(log_file), verbose=0, console=False + ) + + # handlers should include a RotatingFileHandler only + handlers = cfg.get("handlers") + assert handlers, "handlers should be present" + assert len(handlers) == 1 + assert isinstance(handlers[0], logging.handlers.RotatingFileHandler) + + # level should be WARNING when verbose=0 + assert cfg.get("level") == logging.WARNING + + +def test_get_plugin_logging_config_verbose_and_console(tmp_path, capsys): + """Test logging config with verbosity and console logging.""" + # ensure the function prints info when verbose and console True + log_file = tmp_path / "test2.log" + + cfg = plugin_utilities.get_plugin_logging_config( + str(log_file), verbose=1, console=True + ) + + handlers = cfg.get("handlers") + # should have file handler + stream handler + assert any(isinstance(h, logging.handlers.RotatingFileHandler) for h in handlers) + assert any(isinstance(h, logging.StreamHandler) for h in handlers) + + # verbose=1 -> INFO + assert cfg.get("level") == logging.INFO + + # get printed output + captured = capsys.readouterr() + assert "INFO: Log File Path:" in captured.out + assert "INFO: also logging to console" in captured.out + + +def test_get_plugin_logging_config_debug_level(tmp_path): + """Test logging config with debug level verbosity.""" + log_file = tmp_path / "test3.log" + + cfg = plugin_utilities.get_plugin_logging_config( + str(log_file), verbose=2, console=False + ) + + # verbose>1 -> DEBUG + assert cfg.get("level") == logging.DEBUG + + +def test_plugin_logging_config_registers_session_level(tmp_path): + """Test that get_plugin_logging_config registers the custom SESSION log level + (99). + """ + log_file = tmp_path / "test_session.log" + plugin_utilities.get_plugin_logging_config(str(log_file), verbose=0, console=False) + # After calling, the level name for 99 should be 'SESSION' + assert logging.getLevelName(99) == "SESSION" diff --git a/tests/tests.py b/tests/tests.py index b4d3f21..6841c86 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,13 +1,15 @@ #!/usr/bin/env python -""" -Test besapi -""" +"""Test besapi.""" import argparse +import json import os +import random import subprocess import sys +# mypy: disable-error-code="arg-type" + # check for --test_pip arg parser = argparse.ArgumentParser() parser.add_argument( @@ -24,8 +26,9 @@ sys.path.reverse() import besapi +import besapi.plugin_utilities -print("besapi version: " + str(besapi.__version__)) +print("besapi version: " + str(besapi.besapi.__version__)) assert 15 == len(besapi.besapi.rand_password(15)) @@ -44,6 +47,13 @@ "", "operator/Example", False ) +assert besapi.besapi.validate_xml_bes_file("tests/good/RelaySelectTask.bes") is True +assert ( + besapi.besapi.validate_xml_bes_file("tests/good/ComputerGroupsExample.bes") is True +) + +assert besapi.besapi.validate_xml_bes_file("tests/bad/RelaySelectTask_BAD.bes") is False + # start failing tests: raised_errors = 0 @@ -75,9 +85,9 @@ # end failing tests -class RequestResult(object): +class RequestResult: text = "this is just a test" - headers = [] + headers: list = [] request_result = RequestResult() @@ -89,6 +99,37 @@ class RequestResult(object): assert rest_result.text == "this is just a test" +# test date parsing function: +assert 2017 == besapi.besapi.parse_bes_modtime("Tue, 05 Sep 2017 23:31:48 +0000").year + +# test action combined relevance +assert ( + "( ( True ) AND ( windows of operating system ) ) AND ( False )" + == besapi.besapi.get_action_combined_relevance( + ["True", "windows of operating system", "False"] + ) +) + +# test target xml +assert "False" == besapi.besapi.get_target_xml() +assert "true" == besapi.besapi.get_target_xml( + "" +) +assert "1" == besapi.besapi.get_target_xml(1) +assert ( + "" + == besapi.besapi.get_target_xml("not windows of operating system") +) +assert ( + "12" + == besapi.besapi.get_target_xml([1, 2]) +) +assert ( + "Computer 1Another Computer" + == besapi.besapi.get_target_xml(["Computer 1", "Another Computer"]) +) + +# test bescli: import bescli bigfix_cli = bescli.bescli.BESCLInterface() @@ -105,7 +146,9 @@ class RequestResult(object): # this should really only run if the config file is present: if bigfix_cli.bes_conn: # session relevance tests require functioning web reports server - print(bigfix_cli.bes_conn.session_relevance_string("number of bes computers")) + assert ( + int(bigfix_cli.bes_conn.session_relevance_string("number of bes computers")) > 0 + ) assert ( "test session relevance string result" in bigfix_cli.bes_conn.session_relevance_string( @@ -124,11 +167,64 @@ class RequestResult(object): upload_result = bigfix_cli.bes_conn.upload( "./besapi/__init__.py", "test_besapi_upload.txt" ) - print(upload_result) - print(bigfix_cli.bes_conn.parse_upload_result_to_prefetch(upload_result)) + + # print(upload_result) + # print(upload_result.besobj.FileUpload.Available) + assert upload_result.besobj.FileUpload.Available == 1 + assert "test_besapi_upload.txt" in str(upload_result) + upload_result_json = json.loads(upload_result.besjson) + # print(upload_result_json["FileUpload"]["Available"]) + assert upload_result_json["FileUpload"]["Available"] == "1" + + upload_prefetch = bigfix_cli.bes_conn.parse_upload_result_to_prefetch(upload_result) + # print(upload_prefetch) + assert "prefetch test_besapi_upload.txt sha1:" in upload_prefetch + assert "test_besapi_upload.txt" in str(upload_result) + # print(bigfix_cli.bes_conn.parse_upload_result_to_prefetch(upload_result)) + + dashboard_name = "_PyBESAPI_tests.py" + var_name = "TestVarName" + var_value = "TestVarValue " + str(random.randint(0, 9999)) + + assert var_value in str( + bigfix_cli.bes_conn.set_dashboard_variable_value( + dashboard_name, var_name, var_value + ) + ) + + assert var_value in str( + bigfix_cli.bes_conn.get_dashboard_variable_value(dashboard_name, var_name) + ) if os.name == "nt": subprocess.run( 'CMD /C python -m besapi ls clear ls conf "query number of bes computers" version error_count exit', check=True, ) + + bes_conn = besapi.besapi.get_bes_conn_using_config_file() + print("login succeeded:", bes_conn.login()) + assert bes_conn.login() + +# test plugin_utilities: +print(besapi.plugin_utilities.get_invoke_folder()) +print(besapi.plugin_utilities.get_invoke_file_name()) + +parser = besapi.plugin_utilities.setup_plugin_argparse(plugin_args_required=False) +# allow unknown args to be parsed instead of throwing an error: +args, _unknown = parser.parse_known_args() + +# test logging plugin_utilities: +import logging + +logging_config = besapi.plugin_utilities.get_plugin_logging_config("./tests.log") + +# this use of logging.basicConfig requires python >= 3.9 +if sys.version_info >= (3, 9): + logging.basicConfig(**logging_config) + + logging.warning("Just testing to see if logging is working!") + + assert os.path.isfile("./tests.log") + +sys.exit(0)