diff --git a/.github/workflows/build-and-snapshot.yml b/.github/workflows/build-and-snapshot.yml index e5e6e7b..8075417 100644 --- a/.github/workflows/build-and-snapshot.yml +++ b/.github/workflows/build-and-snapshot.yml @@ -12,7 +12,7 @@ on: jobs: lint-and-test-python: - name: Python Test Suite + name: Lint Python Test Suite runs-on: ubuntu-latest if: github.event_name == 'pull_request' || github.event_name == 'push' @@ -38,6 +38,7 @@ jobs: cd test source venv/bin/activate ../scripts/lint-python.sh ci + - name: Python test build: name: Build and Test Go Plugin @@ -59,12 +60,13 @@ jobs: - name: Install dependencies run: go mod tidy -e || true - - name: Lint Go files - run: | - echo "πŸ” Running go fmt..." - go fmt . - echo "πŸ” Running go vet..." - go vet . + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + + - name: Lint and format Go files + run: ./scripts/lint-go.sh ci - name: Build binary run: | diff --git a/.github/workflows/generate_plugin_repo.py b/.github/workflows/generate_plugin_repo.py index 055156d..15df8aa 100644 --- a/.github/workflows/generate_plugin_repo.py +++ b/.github/workflows/generate_plugin_repo.py @@ -49,7 +49,7 @@ def generate_plugin_repo_yaml(): binary_info = { "checksum": checksum, "platform": platform, - "url": f"{repo_url}/releases/download/v{version}/{filename}" + "url": f"{repo_url}/releases/download/{version}/{filename}" } binaries.append(binary_info) print(f"Added {platform}: {filename} (checksum: {checksum})") diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 1d4c3a4..9421282 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -25,9 +25,19 @@ jobs: with: python-version: "3.11" + - name: Set up Node.js for markdownlint + uses: actions/setup-node@v4 + with: + node-version: "18" + - name: Install Go dependencies run: go mod tidy -e || true + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + - name: Lint Go code run: ./scripts/lint-go.sh ci @@ -55,6 +65,9 @@ jobs: if: steps.check-python.outputs.python_tests_exist == 'true' run: ./scripts/lint-python.sh ci + - name: Lint Markdown files + run: ./scripts/lint-markdown.sh ci + # TODO: Re-enable Python tests when ready # - name: Run Python tests # if: steps.check-python.outputs.python_tests_exist == 'true' @@ -90,6 +103,7 @@ jobs: echo "==================================" echo "βœ… Go code formatting and linting" echo "βœ… Go tests" + echo "βœ… Markdown formatting and linting" if [ "${{ steps.check-python.outputs.python_tests_exist }}" == "true" ]; then echo "βœ… Python code quality checks" echo "βœ… Python tests" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29094a4..8cb4d3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,11 @@ jobs: - name: Install dependencies run: go mod tidy -e || true + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + - name: Lint Go files run: ./scripts/lint-go.sh ci @@ -151,59 +156,7 @@ jobs: dist/* plugin-repo-entry.yml plugin-repo-summary.txt - body: | - ## CF CLI Java Plugin ${{ github.event.inputs.version }} - - Plugin for profiling Java applications and getting heap and thread-dumps. - - ## Changes - - $(cat release_changelog.txt || echo "No changelog available") - - ## Installation - - ### Installation via CF Community Repository - - Make sure you have the CF Community plugin repository configured or add it via: - ```bash - cf add-plugin-repo CF-Community http://plugins.cloudfoundry.org - ``` - - Trigger installation of the plugin via: - ```bash - cf install-plugin -r CF-Community "java" - ``` - - ### Manual Installation - - Download this specific release (${{ github.event.inputs.version }}) and install manually: - - ```bash - # on Mac arm64 - cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/${{ github.event.inputs.version }}/cf-cli-java-plugin-macos-arm64 - # on Windows x86 - cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/${{ github.event.inputs.version }}/cf-cli-java-plugin-windows-amd64 - # on Linux x86 - cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/${{ github.event.inputs.version }}/cf-cli-java-plugin-linux-amd64 - ``` - - Or download the latest release: - - ```bash - # on Mac arm64 - cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-macos-arm64 - # on Windows x86 - cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-windows-amd64 - # on Linux x86 - cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-linux-amd64 - ``` - - **Note:** On Linux and macOS, if you get a permission error, run `chmod +x [cf-cli-java-plugin]` on the plugin binary. - On Windows, the plugin will refuse to install unless the binary has the `.exe` file extension. - - You can verify that the plugin is successfully installed by looking for `java` in the output of `cf plugins`. - - **Build Timestamp**: ${{ steps.timestamp.outputs.timestamp }} + body_path: release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update_version.py b/.github/workflows/update_version.py index ced03c2..a2ae345 100644 --- a/.github/workflows/update_version.py +++ b/.github/workflows/update_version.py @@ -12,21 +12,21 @@ def update_version_in_go_file(file_path, major, minor, build): """Update the version in the Go plugin metadata.""" with open(file_path, 'r') as f: content = f.read() - + # Pattern to match the Version struct in PluginMetadata pattern = r'(Version: plugin\.VersionType\s*{\s*Major:\s*)\d+(\s*,\s*Minor:\s*)\d+(\s*,\s*Build:\s*)\d+(\s*,\s*})' - + replacement = rf'\g<1>{major}\g<2>{minor}\g<3>{build}\g<4>' - + new_content = re.sub(pattern, replacement, content) - + if new_content == content: print(f"Warning: Version pattern not found or not updated in {file_path}") return False - + with open(file_path, 'w') as f: f.write(new_content) - + print(f"βœ… Updated version to {major}.{minor}.{build} in {file_path}") return True @@ -34,36 +34,36 @@ def process_readme_changelog(readme_path, version): """Process the README changelog section for the release.""" with open(readme_path, 'r') as f: content = f.read() - + # Look for the snapshot section snapshot_pattern = rf'## {re.escape(version)}-snapshot\s*\n' match = re.search(snapshot_pattern, content) - + if not match: print(f"Error: README.md does not contain a '## {version}-snapshot' section") return False, None - + # Find the content of the snapshot section start_pos = match.end() - + # Find the next ## section or end of file next_section_pattern = r'\n## ' next_match = re.search(next_section_pattern, content[start_pos:]) - + if next_match: end_pos = start_pos + next_match.start() section_content = content[start_pos:end_pos].strip() else: section_content = content[start_pos:].strip() - + # Remove the "-snapshot" from the header new_header = f"## {version}" updated_content = re.sub(snapshot_pattern, new_header + '\n\n', content) - + # Write the updated README with open(readme_path, 'w') as f: f.write(updated_content) - + print(f"βœ… Updated README.md: converted '## {version}-snapshot' to '## {version}'") return True, section_content @@ -75,12 +75,69 @@ def is_rc_version(version_str): """Return True if the version string ends with -rc or -rcN.""" return bool(re.match(r"^\d+\.\d+\.\d+-rc(\d+)?$", version_str)) +def generate_release_notes(version, changelog_content): + """Generate complete release notes file.""" + release_notes = f"""## CF CLI Java Plugin {version} + +Plugin for profiling Java applications and getting heap and thread-dumps. + +## Changes + +{changelog_content} + +## Installation + +### Installation via CF Community Repository + +Make sure you have the CF Community plugin repository configured or add it via: +```bash +cf add-plugin-repo CF-Community http://plugins.cloudfoundry.org +``` + +Trigger installation of the plugin via: +```bash +cf install-plugin -r CF-Community "java" +``` + +### Manual Installation + +Download this specific release ({version}) and install manually: + +```bash +# on Mac arm64 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/{version}/cf-cli-java-plugin-macos-arm64 +# on Windows x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/{version}/cf-cli-java-plugin-windows-amd64 +# on Linux x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/{version}/cf-cli-java-plugin-linux-amd64 +``` + +Or download the latest release: + +```bash +# on Mac arm64 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-macos-arm64 +# on Windows x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-windows-amd64 +# on Linux x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-linux-amd64 +``` + +**Note:** On Linux and macOS, if you get a permission error, run `chmod +x [cf-cli-java-plugin]` on the plugin binary. +On Windows, the plugin will refuse to install unless the binary has the `.exe` file extension. + +You can verify that the plugin is successfully installed by looking for `java` in the output of `cf plugins`. +""" + + with open("release_notes.md", 'w') as f: + f.write(release_notes) + def main(): if len(sys.argv) != 4: print("Usage: update_version.py ") print("Example: update_version.py 4 1 0") sys.exit(1) - + try: major = int(sys.argv[1]) minor = int(sys.argv[2]) @@ -88,7 +145,7 @@ def main(): except ValueError: print("Error: Version numbers must be integers") sys.exit(1) - + version = f"{major}.{minor}.{build}" version_arg = f"{major}.{minor}.{build}" if (major + minor + build) != 0 else sys.argv[1] # Accept any -rc suffix, e.g. 4.0.0-rc, 4.0.0-rc1, 4.0.0-rc2 @@ -117,29 +174,38 @@ def main(): section_content = content[start_pos:].strip() with open(changelog_file, 'w') as f: f.write(section_content) + + # Generate full release notes for RC + generate_release_notes(sys.argv[1], section_content) + print(f"βœ… RC release: Changelog for {base_version} saved to {changelog_file}") + print(f"βœ… RC release: Full release notes saved to release_notes.md") sys.exit(0) - + go_file = Path("cf_cli_java_plugin.go") readme_file = Path("README.md") changelog_file = Path("release_changelog.txt") - + # Update Go version success = update_version_in_go_file(go_file, major, minor, build) if not success: sys.exit(1) - + # Process README changelog success, changelog_content = process_readme_changelog(readme_file, version) if not success: sys.exit(1) - + # Write changelog content to a file for the workflow to use with open(changelog_file, 'w') as f: f.write(changelog_content) - + + # Generate full release notes + generate_release_notes(version, changelog_content) + print(f"βœ… Version updated successfully to {version}") print(f"βœ… Changelog content saved to {changelog_file}") + print(f"βœ… Full release notes saved to release_notes.md") if __name__ == "__main__": main() diff --git a/.gitignore b/.gitignore index 3df8c8a..83d268c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ counterfeiter # Built binaries build/ +pkg/ # built project binary (go build) cf-java-plugin @@ -49,4 +50,7 @@ test/snapshots/ *.hprof # Build artifacts -dist \ No newline at end of file +dist + +# go +pkg \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..37bc648 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,74 @@ +# golangci-lint configuration file +# Run with: golangci-lint run + +version: "2" + +run: + timeout: 5m + +linters: + enable: + # Core linters (enabled by default but explicitly listed) + - errcheck # Check for unchecked errors + - govet # Report suspicious constructs + - ineffassign # Detect ineffectual assignment + - staticcheck # Comprehensive static analysis + - unused # Find unused code + + # Architecture and interface linters (key for your request) + - interfacebloat # Detect interfaces with too many methods + - unparam # Report unused function parameters + + # Code quality linters + - asciicheck # Check for non-ASCII identifiers + - bidichk # Check for dangerous unicode sequences + - bodyclose # Check HTTP response body is closed + - contextcheck # Check context usage + - dupl # Check for code duplication + - durationcheck # Check duration multiplication + - errname # Check error naming conventions + - errorlint # Check error wrapping + - exhaustive # Check enum switch exhaustiveness + - goconst # Find repeated strings that could be constants + - godox # Find TODO, FIXME, etc. comments + - goprintffuncname # Check printf-style function names + - misspell # Check for misspellings + - nakedret # Check naked returns + - nilerr # Check nil error returns + - nolintlint # Check nolint directives + - predeclared # Check for shadowed predeclared identifiers + - rowserrcheck # Check SQL rows.Err + - sqlclosecheck # Check SQL Close calls + - unconvert # Check unnecessary type conversions + - wastedassign # Check wasted assignments + - whitespace # Check for extra whitespace + - gocritic + + + disable: + # Disabled as requested + - gochecknoglobals # Ignore global variables (as requested) + + # Disabled for being too strict or problematic + - testpackage # Too strict - requires separate test packages + - paralleltest # Not always applicable + - exhaustruct # Too strict - requires all struct fields + - varnamelen # Variable name length can be subjective + - wrapcheck # Error wrapping can be excessive + - nlreturn # Newline return rules too strict + - wsl # Whitespace linter too opinionated + - gosmopolitan # Locale-specific, not needed + - nonamedreturns # Named returns can be useful + - tagliatelle # Struct tag formatting can be subjective + - maintidx # Maintainability index can be subjective + - godot # Check comments end with period + - lll # Check line length + - ireturn # Interface return types are sometimes necessary + # ignore for now + - nestif + - gocognit + - gocyclo # Check cyclomatic complexity + - cyclop # Check cyclomatic complexity + - funlen # Check function length + - gosec # Security-focused linter + - revive # Fast, configurable, extensible linter diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..22e7517 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,13 @@ +{ + "default": true, + "MD013": { + "line_length": 120, + "code_blocks": false, + "tables": false + }, + "MD033": false, + "MD041": false, + "MD046": { + "style": "fenced" + } +} diff --git a/.vscode/README.md b/.vscode/README.md index 53c462c..5f71c2e 100644 --- a/.vscode/README.md +++ b/.vscode/README.md @@ -5,6 +5,7 @@ This directory contains a comprehensive VS Code configuration for developing and ## Quick Start 1. **Open the workspace**: Use the workspace file in the root directory: + ```bash code ../cf-java-plugin.code-workspace ``` @@ -33,6 +34,7 @@ This directory contains a comprehensive VS Code configuration for developing and ### ⚑ Tasks (Ctrl/Cmd + Shift + P β†’ "Tasks: Run Task") #### Test Execution + - **Run All Tests** - Execute all tests - **Run Current Test File** - Run the currently open test file - **Run Basic Commands Tests** - Basic command functionality @@ -45,6 +47,7 @@ This directory contains a comprehensive VS Code configuration for developing and - **Generate HTML Test Report** - Create HTML test report #### Development Tools + - **Setup Virtual Environment** - Initialize/setup the Python environment - **Clean Test Artifacts** - Clean up test files and artifacts - **Interactive Test Runner** - Launch interactive test selector @@ -76,6 +79,7 @@ Type these prefixes and press Tab for instant code generation: ## Test Organization & Filtering ### By File + ```bash pytest test_basic_commands.py -v # Basic commands pytest test_jfr.py -v # JFR tests @@ -84,6 +88,7 @@ pytest test_cf_java_plugin.py -v # Integration tests ``` ### By Test Class + ```bash pytest test_basic_commands.py::TestHeapDump -v # Only heap dump tests pytest test_jfr.py::TestJFRBasic -v # Basic JFR functionality @@ -91,12 +96,14 @@ pytest test_asprof.py::TestAsprofProfiles -v # Async-profiler profiles ``` ### By Pattern + ```bash pytest -k "heap" -v # All heap-related tests pytest -k "jfr or asprof" -v # All profiling tests ``` ### By Markers + ```bash pytest -m "sapmachine21" -v # SapMachine-specific tests ``` @@ -112,18 +119,20 @@ pytest -m "sapmachine21" -v # SapMachine-specific tests ## Test Execution Patterns ### Quick Development Cycle + 1. Edit test file 2. Press F5 β†’ "Debug Current Test File" 3. Fix issues and repeat ### Focused Testing + 1. Use custom filter: F5 β†’ "Debug Custom Filter" 2. Enter pattern like "heap and download" 3. Debug only matching tests ## File Organization -``` +```text test/ β”œβ”€β”€ .vscode/ # VS Code configuration β”‚ β”œβ”€β”€ launch.json # Debug configurations @@ -152,16 +161,19 @@ test/ ## Troubleshooting ### Python Environment Issues + 1. Ensure virtual environment is created: Run "Setup Virtual Environment" task 2. Check Python interpreter: Bottom left corner should show `./venv/bin/python` 3. Reload window: Ctrl/Cmd + Shift + P β†’ "Developer: Reload Window" ### Test Discovery Issues + 1. Save all files (tests auto-discover on save) 2. Check PYTHONPATH in terminal 3. Verify test files follow `test_*.py` naming ### Extension Issues + 1. Install recommended extensions when prompted 2. Check Extensions panel for any issues 3. Restart VS Code if needed @@ -169,10 +181,13 @@ test/ ## Advanced Features ### Parallel Testing + Use the "Run Tests in Parallel" task for faster execution on multi-core systems. ### HTML Reports + Generate comprehensive HTML test reports with the "Generate HTML Test Report" task. ### Interactive Runner + Launch `test_runner.py` for menu-driven test selection and execution. diff --git a/.vscode/settings.json b/.vscode/settings.json index b0ad222..918f577 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -103,8 +103,9 @@ // Go language support for main project "go.gopath": "${workspaceFolder}", "go.goroot": "", - "go.formatTool": "goimports", - "go.lintTool": "golint", + "go.formatTool": "gofumpt", + "go.lintTool": "golangci-lint", + "go.lintOnSave": "package", // YAML schema validation "yaml.schemas": { "./test/test_config.yml.example": [ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7e6b74..bd1fc35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,7 @@ # Contributing -When contributing to this repository, please first discuss the change you wish to make via issue before making a change. We restrict the scope of this plugin to keep it maintainable. +When contributing to this repository, please first discuss the change you wish to make via issue before making a +change. We restrict the scope of this plugin to keep it maintainable. We have a [code of conduct](#code-of-conduct), please follow it in all your interactions with the project. @@ -10,16 +11,20 @@ You are welcome to contribute code to the Cloud Foundry CLI Java Plugin in order There are three important things to know: -1. You must be aware of the Apache License (which describes contributions) and agree to the Contributors License Agreement (CLA). - This is common practice in all major Open Source projects. - To make this process as simple as possible, we are using the [CLA assistant](https://cla-assistant.io/) for individual contributions. - CLA assistant is an open source tool that integrates with GitHub very well and enables a one-click-experience for accepting the CLA. - For company contributors, [special rules apply](#company-contributors). -2. We set ourselves [requirements regarding code style and quality](#pull-request-process), and we kindly ask you to do the same with PRs. +1. You must be aware of the Apache License (which describes contributions) and agree to the Contributors License + Agreement (CLA). This is common practice in all major Open Source projects. + To make this process as simple as possible, we are using the [CLA assistant](https://cla-assistant.io/) for + individual contributions. + CLA assistant is an open source tool that integrates with GitHub very well and enables a one-click-experience + for accepting the CLA. + For company contributors, special rules apply. +2. We set ourselves requirements regarding code style and quality, and we kindly ask you to do the same with PRs. 3. Not all proposed contributions can be accepted. Some features may, for example, just fit a separate plugin better. - The code must fit the overall direction of Cloud Foundry CLI Java Plugin and really improve it, so there should be some "bang for the byte". - For most bug fixes this is a given, but it would be advisable to first discus new major features with the maintainers by opening an issue on the project. + The code must fit the overall direction of Cloud Foundry CLI Java Plugin and really improve it, so there should + be some "bang for the byte". + For most bug fixes this is a given, but it would be advisable to first discus new major features with the + maintainers by opening an issue on the project. ### Pull Request Process @@ -31,18 +36,24 @@ This a checklist of things to keep in your mind when opening pull requests for t (mock in depth wherever possible) 4. Update the README.md with details of changes to the options -Pull requests will be tested and validated by maintainers. In case small changes are needed (e.g., correcting typos), the maintainers may fix those issues themselves. +Pull requests will be tested and validated by maintainers. In case small changes are needed (e.g., correcting typos), +the maintainers may fix those issues themselves. In case of larger issues, you may be asked to apply modifications to your changes before the Pull Request can be merged. ### Developer Certificate of Origin (DCO) -Due to legal reasons, contributors will be asked to accept a DCO before they submit the first pull request to this projects, this happens in an automated fashion during the submission process. SAP uses [the standard DCO text of the Linux Foundation](https://developercertificate.org/). +Due to legal reasons, contributors will be asked to accept a DCO before they submit the first pull request to this +projects, this happens in an automated fashion during the submission process. SAP uses +[the standard DCO text of the Linux Foundation](https://developercertificate.org/). ## Contributing with AI-generated code -As artificial intelligence evolves, AI-generated code is becoming valuable for many software projects, including open-source initiatives. While we recognize the potential benefits of incorporating AI-generated content into our open-source projects there a certain requirements that need to be reflected and adhered to when making contributions. +As artificial intelligence evolves, AI-generated code is becoming valuable for many software projects, including +open-source initiatives. While we recognize the potential benefits of incorporating AI-generated content into our +open-source projects there a certain requirements that need to be reflected and adhered to when making contributions. -Please see our [guideline for AI-generated code contributions to SAP Open Source Software Projects](CONTRIBUTING_USING_GENAI.md) for these requirements. +Please see our [guideline for AI-generated code contributions to SAP Open Source Software Projects](CONTRIBUTING_USING_GENAI.md) +for these requirements. ## Code of Conduct diff --git a/CONTRIBUTING_USING_GENAI.md b/CONTRIBUTING_USING_GENAI.md index 1ed02c3..85e57a8 100644 --- a/CONTRIBUTING_USING_GENAI.md +++ b/CONTRIBUTING_USING_GENAI.md @@ -1,12 +1,32 @@ # Guideline for AI-generated code contributions to SAP Open Source Software Projects -As artificial intelligence evolves, AI-generated code is becoming valuable for many software projects, including open-source initiatives. While we recognize the potential benefits of incorporating AI-generated content into our open-source projects there are certain requirements that need to be reflected and adhered to when making contributions. +As artificial intelligence evolves, AI-generated code is becoming valuable for many software projects, including +open-source initiatives. While we recognize the potential benefits of incorporating AI-generated content into our +open-source projects there are certain requirements that need to be reflected and adhered to when making +contributions. -When using AI-generated code contributions in OSS Projects, their usage needs to align with Open-Source Software values and legal requirements. We have established these essential guidelines to help contributors navigate the complexities of using AI tools while maintaining compliance with open-source licenses and the broader [Open-Source Definition](https://opensource.org/osd). +When using AI-generated code contributions in OSS Projects, their usage needs to align with Open-Source Software +values and legal requirements. We have established these essential guidelines to help contributors navigate the +complexities of using AI tools while maintaining compliance with open-source licenses and the broader +[Open-Source Definition](https://opensource.org/osd). -AI-generated code or content can be contributed to SAP Open Source Software projects if the following conditions are met: +AI-generated code or content can be contributed to SAP Open Source Software projects if the following conditions +are met: -1. **Compliance with AI Tool Terms and Conditions**: Contributors must ensure that the AI tool's terms and conditions do not impose any restrictions on the tool's output that conflict with the project's open-source license or intellectual property policies. This includes ensuring that the AI-generated content adheres to the [Open-Source Definition](https://opensource.org/osd). -2. **Filtering Similar Suggestions**: Contributors must use features provided by AI tools to suppress responses that are similar to third-party materials or flag similarities. We only accept contributions from AI tools with such filtering options. If the AI tool flags any similarities, contributors must review and ensure compliance with the licensing terms of such materials before including them in the project. -3. **Management of Third-Party Materials**: If the AI tool's output includes pre-existing copyrighted materials, including open-source code authored or owned by third parties, contributors must verify that they have the necessary permissions from the original owners. This typically involves ensuring that there is an open-source license or public domain declaration that is compatible with the project's licensing policies. Contributors must also provide appropriate notice and attribution for these third-party materials, along with relevant information about the applicable license terms. -4. **Employer Policies Compliance**: If AI-generated content is contributed in the context of employment, contributors must also adhere to their employer’s policies. This ensures that all contributions are made with proper authorization and respect for relevant corporate guidelines. +1. **Compliance with AI Tool Terms and Conditions**: Contributors must ensure that the AI tool's terms and + conditions do not impose any restrictions on the tool's output that conflict with the project's open-source + license or intellectual property policies. This includes ensuring that the AI-generated content adheres to the + [Open-Source Definition](https://opensource.org/osd). +2. **Filtering Similar Suggestions**: Contributors must use features provided by AI tools to suppress responses that + are similar to third-party materials or flag similarities. We only accept contributions from AI tools with such + filtering options. If the AI tool flags any similarities, contributors must review and ensure compliance with the + licensing terms of such materials before including them in the project. +3. **Management of Third-Party Materials**: If the AI tool's output includes pre-existing copyrighted materials, + including open-source code authored or owned by third parties, contributors must verify that they have the + necessary permissions from the original owners. This typically involves ensuring that there is an open-source + license or public domain declaration that is compatible with the project's licensing policies. Contributors must + also provide appropriate notice and attribution for these third-party materials, along with relevant information + about the applicable license terms. +4. **Employer Policies Compliance**: If AI-generated content is contributed in the context of employment, + contributors must also adhere to their employer's policies. This ensures that all contributions are made with + proper authorization and respect for relevant corporate guidelines. diff --git a/README.md b/README.md index 5095ff9..5687f98 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,37 @@ -[![REUSE status](https://api.reuse.software/badge/github.com/SAP/cf-cli-java-plugin)](https://api.reuse.software/info/github.com/SAP/cf-cli-java-plugin) [![Build and Snapshot Release](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/build-and-snapshot.yml/badge.svg)](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/build-and-snapshot.yml) [![PR Validation](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/pr-validation.yml/badge.svg)](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/pr-validation.yml) +[![REUSE status](https://api.reuse.software/badge/github.com/SAP/cf-cli-java-plugin)](https://api.reuse.software/info/github.com/SAP/cf-cli-java-plugin) +[![Build and Snapshot Release](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/build-and-snapshot.yml/badge.svg)](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/build-and-snapshot.yml) +[![PR Validation](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/pr-validation.yml/badge.svg)](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/pr-validation.yml) # Cloud Foundry Command Line Java plugin -This plugin for the [Cloud Foundry Command Line](https://github.com/cloudfoundry/cli) provides convenience utilities to work with Java applications deployed on Cloud Foundry. +This plugin for the [Cloud Foundry Command Line](https://github.com/cloudfoundry/cli) provides convenience utilities to +work with Java applications deployed on Cloud Foundry. -Currently, it allows to: -* Trigger and retrieve a heap dump and a thread dump from an instance of a Cloud Foundry Java application -* To run jcmd remotely on your application -* To start, stop and retrieve JFR and [async-profiler](https://github.com/jvm-profiling-tools/async-profiler) ([SapMachine](https://sapmachine.io) only) profiles from your application +Currently, it allows you to: + +- Trigger and retrieve a heap dump and a thread dump from an instance of a Cloud Foundry Java application +- To run jcmd remotely on your application +- To start, stop and retrieve JFR and [async-profiler](https://github.com/jvm-profiling-tools/async-profiler) + ([SapMachine](https://sapmachine.io) only) profiles from your application ## Installation +### Installation via CF Community Repository + +Make sure you have the CF Community plugin repository configured (or add it via +`cf add-plugin-repo CF-Community http://plugins.cloudfoundry.org`) + +Trigger installation of the plugin via + +```sh +cf install-plugin java +``` + +The releases in the community repository are older than the actual releases on GitHub, that you can install manually, so +we recommend the manual installation. + ### Manual Installation + Download the latest release from [GitHub](https://github.com/SAP/cf-cli-java-plugin/releases/latest). To install a new version of the plugin, run the following: @@ -27,18 +47,6 @@ cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/down You can verify that the plugin is successfully installed by looking for `java` in the output of `cf plugins`. -### Installation via CF Community Repository - -Make sure you have the CF Community plugin repository configured or add it via (```cf add-plugin-repo CF-Community http://plugins.cloudfoundry.org```) - -Trigger installation of the plugin via -``` -cf install-plugin -r CF-Community "java" -``` - -The releases in the community repository are older that the actual releases on GitHub, that you can install manually, so we recommend -the manual installation. - ### Manual Installation of Snapshot Release Download the current snapshot release from [GitHub](https://github.com/SAP/cf-cli-java-plugin/releases/tag/snapshot). @@ -57,55 +65,66 @@ cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/sn ### Updating from version 1.x to 2.x -With release 2.0 we aligned the convention of the plugin having the same name as the command it contributes (in our case, `java`). -This change mostly affects you in the way you update your plugin. -If you have the version 1.x installed, you will need to uninstall the old version first by using the command: `cf uninstall-plugin JavaPlugin`. -You know you have the version 1.x installed if `JavaPlugin` appears in the output of `cf plugins`. +With release 2.0 we aligned the convention of the plugin having the same name as the command it contributes (in our +case, `java`). This change mostly affects you in the way you update your plugin. If you have the version 1.x installed, +you will need to uninstall the old version first by using the command: `cf uninstall-plugin JavaPlugin`. You know you +have the version 1.x installed if `JavaPlugin` appears in the output of `cf plugins`. ### Permission Issues -On Linux and macOS, if you get a permission error, run `chmod +x [cf-cli-java-plugin]` (replace `[cf-cli-java-plugin]` with the actual binary name you will use, which depends on the OS you are running) on the plugin binary. -On Windows, the plugin will refuse to install unless the binary has the `.exe` file extension. +On Linux and macOS, if you get a permission error, run `chmod +x [cf-cli-java-plugin]` (replace `[cf-cli-java-plugin]` +with the actual binary name you will use, which depends on the OS you are running) on the plugin binary. On Windows, the +plugin will refuse to install unless the binary has the `.exe` file extension. ## Usage ### Prerequisites #### JDK Tools -This plugin internally uses `jmap` for OpenJDK-like Java virtual machines. When using the [Cloud Foundry Java Buildpack](https://github.com/cloudfoundry/java-buildpack), `jmap` is no longer shipped by default in order to meet the legal obligations of the Cloud Foundry Foundation. -To ensure that `jmap` is available in the container of your application, you have to explicitly request a full JDK in your application manifest via the `JBP_CONFIG_OPEN_JDK_JRE` environment variable. This could be done like this: + +This plugin internally uses `jmap` for OpenJDK-like Java virtual machines. When using the +[Cloud Foundry Java Buildpack](https://github.com/cloudfoundry/java-buildpack), `jmap` is no longer shipped by default +in order to meet the legal obligations of the Cloud Foundry Foundation. To ensure that `jmap` is available in the +container of your application, you have to explicitly request a full JDK in your application manifest via the +`JBP_CONFIG_OPEN_JDK_JRE` environment variable. This could be done like this: ```yaml --- applications: -- name: - memory: 1G - path: - buildpack: https://github.com/cloudfoundry/java-buildpack - env: - JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/bionic/x86_64", version: 11.+ } }' - JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints']" + - name: + memory: 1G + path: + buildpack: https://github.com/cloudfoundry/java-buildpack + env: + JBP_CONFIG_OPEN_JDK_JRE: + '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64", version: 11.+ } + }' + JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints']" ``` -`-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints` is used to improve -profiling accurary and has no known negative performance impacts. +`-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints` is used to improve profiling accuracy and has no known negative +performance impacts. -Please note that this requires the use of an online buildpack (configured in the `buildpack` property). When system buildpacks are used, staging will fail with cache issues, because the system buildpacks don’t have the JDK chached. -Please also note that this is not to be considered a recommendation to use a full JDK. It's just one option to get the tools required for the use of this plugin when you need it, e.g., for troubleshooting. -The `version` property is optional and can be used to request a specific Java version. +Please note that this requires the use of an online buildpack (configured in the `buildpack` property). When system +buildpacks are used, staging will fail with cache issues, because the system buildpacks don’t have the JDK cached. +Please also note that this is not to be considered a recommendation to use a full JDK. It's just one option to get the +tools required for the use of this plugin when you need it, e.g., for troubleshooting. The `version` property is +optional and can be used to request a specific Java version. #### SSH Access -As it is built directly on `cf ssh`, the `cf java` plugin can work only with Cloud Foundry applications that have `cf ssh` enabled. -To check if your app fulfills the requirements, you can find out by running the `cf ssh-enabled [app-name]` command. -If not enabled yet, run `cf enable-ssh [app-name]`. + +As it is built directly on `cf ssh`, the `cf java` plugin can work only with Cloud Foundry applications that have +`cf ssh` enabled. To check if your app fulfills the requirements, you can find out by running the +`cf ssh-enabled [app-name]` command. If not enabled yet, run `cf enable-ssh [app-name]`. **Note:** You must restart your app after enabling SSH access. -In case a proxy server is used, ensure that `cf ssh` is configured accordingly. -Refer to the [official documentation](https://docs.cloudfoundry.org/cf-cli/http-proxy.html#v3-ssh-socks5) of the Cloud Foundry Command Line for more information. -If `cf java` is having issues connecting to your app, chances are the problem is in the networking issues encountered by `cf ssh`. -To verify, run your `cf java` command in "dry-run" mode by adding the `-n` flag and try to execute the command line that `cf java` gives you back. -If it fails, the issue is not in `cf java`, but in whatever makes `cf ssh` fail. +In case a proxy server is used, ensure that `cf ssh` is configured accordingly. Refer to the +[official documentation](https://docs.cloudfoundry.org/cf-cli/http-proxy.html#v3-ssh-socks5) of the Cloud Foundry +Command Line for more information. If `cf java` is having issues connecting to your app, chances are the problem is in +the networking issues encountered by `cf ssh`. To verify, run your `cf java` command in "dry-run" mode by adding the +`-n` flag and try to execute the command line that `cf java` gives you back. If it fails, the issue is not in `cf java`, +but in whatever makes `cf ssh` fail. ### Examples @@ -131,7 +150,7 @@ Creating a CPU-time profile via async-profiler: > cf java asprof-start-cpu $APP_NAME Profiling started # wait some time to gather data -> cf java asprof-stop-cpu $APP_NAME +> cf java asprof-stop $APP_NAME -> ./$APP_NAME-asprof-$RANDOM.jfr ``` @@ -146,12 +165,13 @@ $TIME s #### Variable Replacements for JCMD and Asprof Commands -When using `jcmd` and `asprof` commands with the `--args` parameter, the following variables are automatically replaced in your command strings: +When using `jcmd` and `asprof` commands with the `--args` parameter, the following variables are automatically replaced +in your command strings: -* `@FSPATH`: A writable directory path on the remote container (always set, typically `/tmp/jcmd` or `/tmp/asprof`) -* `@ARGS`: The command arguments you provided via `--args` -* `@APP_NAME`: The name of your Cloud Foundry application -* `@FILE_NAME`: Generated filename for file operations (includes full path with UUID) +- `@FSPATH`: A writable directory path on the remote container (always set, typically `/tmp/jcmd` or `/tmp/asprof`) +- `@ARGS`: The command arguments you provided via `--args` +- `@APP_NAME`: The name of your Cloud Foundry application +- `@FILE_NAME`: Generated filename for file operations (includes full path with UUID) Example usage: @@ -166,14 +186,15 @@ cf java jcmd $APP_NAME --args "GC.heap_dump /tmp/absolute_heap.hprof" cf java jcmd $APP_NAME --args 'echo "Processing app: @APP_NAME"' ``` -**Note**: Variables use the `@` prefix to avoid shell expansion issues. The plugin automatically creates the `@FSPATH` directory and downloads any files created there to your local directory (unless `--no-download` is used). +**Note**: Variables use the `@` prefix to avoid shell expansion issues. The plugin automatically creates the `@FSPATH` +directory and downloads any files created there to your local directory (unless `--no-download` is used). ### Commands -The following is a list of all available commands (some of the SapMachine specific), -generated via `cf java --help`: +The following is a list of all available commands (some of the SapMachine specific), generated via `cf java --help`:
+
 NAME:
    java - Obtain a heap-dump, thread-dump or profile from a running, SSH-enabled Java application.
 
@@ -187,52 +208,78 @@ USAGE:
         Generate a thread dump from a running Java application
 
      vm-info
-        Print information about the Java Virtual Machine running a Java application
+        Print information about the Java Virtual Machine running a Java
+        application
 
      jcmd (supports --args)
-        Run a JCMD command on a running Java application via --args, downloads and deletes all files that are created in the current folder, use '--no-download' to prevent this
+        Run a JCMD command on a running Java application via --args, downloads
+        and deletes all files that are created in the current folder, use
+        '--no-download' to prevent this. Environment variables available:
+        @FSPATH (writable directory path, always set), @ARGS (command
+        arguments), @APP_NAME (application name), @FILE_NAME (generated filename
+        for file operations without UUID), and @STATIC_FILE_NAME (without UUID).
+        Use single quotes around --args to prevent shell expansion.
 
      jfr-start
-        Start a Java Flight Recorder default recording on a running Java application (stores in the the container-dir)
+        Start a Java Flight Recorder default recording on a running Java
+        application (stores in the container-dir)
 
      jfr-start-profile
-        Start a Java Flight Recorder profile recording on a running Java application (stores in the the container-dir))
+        Start a Java Flight Recorder profile recording on a running Java
+        application (stores in the container-dir))
 
      jfr-start-gc (recent SapMachine only)
-        Start a Java Flight Recorder GC recording on a running Java application (stores in the the container-dir)
+        Start a Java Flight Recorder GC recording on a running Java application
+        (stores in the container-dir)
 
      jfr-start-gc-details (recent SapMachine only)
-        Start a Java Flight Recorder detailed GC recording on a running Java application (stores in the the container-dir)
+        Start a Java Flight Recorder detailed GC recording on a running Java
+        application (stores in the container-dir)
 
      jfr-stop
         Stop a Java Flight Recorder recording on a running Java application
 
      jfr-dump
-        Dump a Java Flight Recorder recording on a running Java application without stopping it
+        Dump a Java Flight Recorder recording on a running Java application
+        without stopping it
 
      jfr-status
-        Check the running Java Flight Recorder recording on a running Java application
+        Check the running Java Flight Recorder recording on a running Java
+        application
 
      vm-version
         Print the version of the Java Virtual Machine running a Java application
 
      vm-vitals
-        Print vital statistics about the Java Virtual Machine running a Java application
+        Print vital statistics about the Java Virtual Machine running a Java
+        application
 
      asprof (recent SapMachine only, supports --args)
-        Run async-profiler commands passed to asprof via --args, copies files in the current folder. Don't use in combination with asprof-* commands. Downloads and deletes all files that are created in the current folder, if not using 'start' asprof command, use '--no-download' to prevent this.
+        Run async-profiler commands passed to asprof via --args, copies files in
+        the current folder. Don't use in combination with asprof-* commands.
+        Downloads and deletes all files that are created in the current folder,
+        if not using 'start' asprof command, use '--no-download' to prevent
+        this. Environment variables available: @FSPATH (writable directory path,
+        always set), @ARGS (command arguments), @APP_NAME (application name),
+        @FILE_NAME (generated filename for file operations), and
+        @STATIC_FILE_NAME (without UUID). Use single quotes around --args to
+        prevent shell expansion.
 
      asprof-start-cpu (recent SapMachine only)
-        Start an async-profiler CPU-time profile recording on a running Java application
+        Start an async-profiler CPU-time profile recording on a running Java
+        application
 
      asprof-start-wall (recent SapMachine only)
-        Start an async-profiler wall-clock profile recording on a running Java application
+        Start an async-profiler wall-clock profile recording on a running Java
+        application
 
      asprof-start-alloc (recent SapMachine only)
-        Start an async-profiler allocation profile recording on a running Java application
+        Start an async-profiler allocation profile recording on a running Java
+        application
 
      asprof-start-lock (recent SapMachine only)
-        Start an async-profiler lock profile recording on a running Java application
+        Start an async-profiler lock profile recording on a running Java
+        application
 
      asprof-stop (recent SapMachine only)
         Stop an async-profiler profile recording on a running Java application
@@ -241,69 +288,93 @@ USAGE:
         Get the status of async-profiler on a running Java application
 
 OPTIONS:
-   -local-dir                -ld, the local directory path that the dump/JFR/... file will be saved to, defaults to the current directory
-   -no-download              -nd, don't download the heap dump/JFR/... file to local, only keep it in the container, implies '--keep'
+   -verbose                  -v, enable verbose output for the plugin
    -app-instance-index       -i [index], select to which instance of the app to connect
-   -args                     -a, Miscellaneous arguments to pass to the command (if supported) in the container, be aware to end it with a space if it is a simple option
-   -container-dir            -cd, the directory path in the container that the heap dump/JFR/... file will be saved to
+   -args                     -a, Miscellaneous arguments to pass to the command (if supported) in the
+                               container, be aware to end it with a space if it is a simple option. For
+                               commands that create arbitrary files (jcmd, asprof), the environment
+                               variables @FSPATH, @ARGS, @APP_NAME, @FILE_NAME, and @STATIC_FILE_NAME are
+                               available in --args to reference the working directory path, arguments,
+                               application name, and generated file name respectively.
+   -container-dir            -cd, the directory path in the container that the heap dump/JFR/... file will be
+                                saved to
    -dry-run                  -n, just output to command line what would be executed
-   -keep                     -k, keep the heap dump in the container; by default the heap dump/JFR/... will be deleted from the container's filesystem after been downloaded
-   -verbose                  -v, enable verbose output for the plugin
+   -keep                     -k, keep the heap dump in the container; by default the heap dump/JFR/... will
+                               be deleted from the container's filesystem after being downloaded
+   -local-dir                -ld, the local directory path that the dump/JFR/... file will be saved to,
+                                defaults to the current directory
+   -no-download              -nd, don't download the heap dump/JFR/... file to local, only keep it in the
+                                container, implies '--keep'
+
 
-The heap dumps and profiles will be copied to a local file if `-local-dir` is specified as a full folder path. Without providing `-local-dir` the heap dump will only be created in the container and not transferred. -To save disk space of the application container, the files are automatically deleted unless the `-keep` option is set. +The heap dumps and profiles will be copied to a local file if `-local-dir` is specified as a full folder path. Without +providing `-local-dir` the heap dump will only be created in the container and not transferred. To save disk space of +the application container, the files are automatically deleted unless the `-keep` option is set. -Providing `-container-dir` is optional. If specified the plugin will create the heap dump or profile at the given file path in the application container. Without providing this parameter, the file will be created either at `/tmp` or at the file path of a file system service if attached to the container. +Providing `-container-dir` is optional. If specified the plugin will create the heap dump or profile at the given file +path in the application container. Without providing this parameter, the file will be created either at `/tmp` or at the +file path of a file system service if attached to the container. ```shell cf java [heap-dump|stop-jfr|stop-asprof] [my-app] -local-dir /local/path [-container-dir /var/fspath] ``` -Everything else, like thread dumps, will be output to `std-out`. -You may want to redirect the command's output to file, e.g., by executing: +Everything else, like thread dumps, will be output to `std-out`. You may want to redirect the command's output to file, +e.g., by executing: ```shell cf java thread-dump [my_app] -i [my_instance_index] > heap-dump.hprof ``` -The `-k` flag is invalid when invoking non file producing commands. -(Unlike with heap dumps, the JVM does not need to output the thread dump to file before streaming it out.) +The `-k` flag is invalid when invoking non file producing commands. (Unlike with heap dumps, the JVM does not need to +output the thread dump to file before streaming it out.) ## Limitations -The capability of creating heap dumps and profiles is also limited by the filesystem available to the container. -The `cf java heap-dump`, `cf java asprof-stop` and `cf java jfr-stop` commands trigger a write to the file system, read the content of the file over the SSH connection, and then remove the file from the container's file system (unless you have the `-k` flag set). -The amount of filesystem space available to a container is set for the entire Cloud Foundry landscape with a global configuration. -The size of a heap dump is roughly linear with the allocated memory of the heap and the size of the profile is related to the length of the recording. -So, it could be that, in case of large heaps, long profiling durations or the filesystem having too much stuff in it, there is not enough space on the filesystem for creating the file. -In that case, the creation of the heap dump or profile recording and thus the command will fail. +The capability of creating heap dumps and profiles is also limited by the filesystem available to the container. The +`cf java heap-dump`, `cf java asprof-stop` and `cf java jfr-stop` commands trigger a write to the file system, read the +content of the file over the SSH connection, and then remove the file from the container's file system (unless you have +the `-k` flag set). The amount of filesystem space available to a container is set for the entire Cloud Foundry +landscape with a global configuration. The size of a heap dump is roughly linear with the allocated memory of the heap +and the size of the profile is related to the length of the recording. So, it could be that, in case of large heaps, +long profiling durations or the filesystem having too much stuff in it, there is not enough space on the filesystem for +creating the file. In that case, the creation of the heap dump or profile recording and thus the command will fail. -From the perspective of integration in workflows and overall shell-friendliness, the `cf java` plugin suffers from some shortcomings in the current `cf-cli` plugin framework: -* There is no distinction between `stdout` and `stderr` output from the underlying `cf ssh` command (see [this issue on the `cf-cli` project](https://github.com/cloudfoundry/cli/issues/1074)) - * The `cf java` will however (mostly) exit with status code `1` when the underpinning `cf ssh` command fails - * If split between `stdout` and `stderr` is needed, you can run the `cf java` plugin in dry-run mode (`--dry-run` flag) and execute its output instead +From the perspective of integration in workflows and overall shell-friendliness, the `cf java` plugin suffers from some +shortcomings in the current `cf-cli` plugin framework: + +- There is no distinction between `stdout` and `stderr` output from the underlying `cf ssh` command (see + [this issue on the `cf-cli` project](https://github.com/cloudfoundry/cli/issues/1074)) + - The `cf java` will however (mostly) exit with status code `1` when the underpinning `cf ssh` command fails + - If split between `stdout` and `stderr` is needed, you can run the `cf java` plugin in dry-run mode (`--dry-run` + flag) and execute its output instead ## Side-effects on the running instance -Storing dumps or profile recordings to the filesystem may lead to to not enough space on the filesystem been available for other tasks (e.g., temp files). -In that case, the application in the container may suffer unexpected errors. +Storing dumps or profile recordings to the filesystem may lead to to not enough space on the filesystem been available +for other tasks (e.g., temp files). In that case, the application in the container may suffer unexpected errors. ### Thread-Dumps -Executing a thread dump via the `cf java` command does not have much of an overhead on the affected JVM. -(Unless you have **a lot** of threads, that is.) + +Executing a thread dump via the `cf java` command does not have much of an overhead on the affected JVM. (Unless you +have **a lot** of threads, that is.) ### Heap-Dumps -Heap dumps, on the other hand, have to be treated with a little more care. -First of all, triggering the heap dump of a JVM makes the latter execute in most cases a full garbage collection, which will cause your JVM to become unresponsive for the duration. -How much time is needed to execute the heap dump, depends on the size of the heap (the bigger, the slower), the algorithm used and, above all, whether your container is swapping memory to disk or not (swap is *bad* for the JVM). -Since Cloud Foundry allows for over-commit in its cells, it is possible that a container would begin swapping when executing a full garbage collection. -(To be fair, it could be swapping even *before* the garbage collection begins, but let's not knit-pick here.) -So, it is theoretically possible that execuing a heap dump on a JVM in poor status of health will make it go even worse. + +Heap dumps, on the other hand, have to be treated with a little more care. First of all, triggering the heap dump of a +JVM makes the latter execute in most cases a full garbage collection, which will cause your JVM to become unresponsive +for the duration. How much time is needed to execute the heap dump, depends on the size of the heap (the bigger, the +slower), the algorithm used and, above all, whether your container is swapping memory to disk or not (swap is _bad_ for +the JVM). Since Cloud Foundry allows for over-commit in its cells, it is possible that a container would begin swapping +when executing a full garbage collection. (To be fair, it could be swapping even _before_ the garbage collection begins, +but let's not knit-pick here.) So, it is theoretically possible that execuing a heap dump on a JVM in poor status of +health will make it go even worse. ### Profiles -Profiles might cause overhead depending on the configuration, but the default configurations -typically have a limited overhead. + +Profiles might cause overhead depending on the configuration, but the default configurations typically have a limited +overhead. ## Development @@ -357,18 +428,17 @@ Centralized linting scripts: ## Support, Feedback, Contributing -This project is open to feature requests/suggestions, bug reports etc. -via [GitHub issues](https://github.com/SAP/cf-cli-java-plugin/issues). -Contribution and feedback are encouraged and always welcome. -Just be aware that this plugin is limited in scope to keep it maintainable. -For more information about how to contribute, the project structure, -as well as additional contribution information, -see our [Contribution Guidelines](CONTRIBUTING.md). +This project is open to feature requests/suggestions, bug reports etc. via +[GitHub issues](https://github.com/SAP/cf-cli-java-plugin/issues). Contribution and feedback are encouraged and always +welcome. Just be aware that this plugin is limited in scope to keep it maintainable. For more information about how to +contribute, the project structure, as well as additional contribution information, see our +[Contribution Guidelines](CONTRIBUTING.md). ## Security / Disclosure + If you find any bug that may be a security problem, please follow our instructions at -[in our security policy](https://github.com/SAP/cf-cli-java-plugin/security/policy) on how to report it. -Please do not create GitHub issues for security-related doubts or problems. +[in our security policy](https://github.com/SAP/cf-cli-java-plugin/security/policy) on how to report it. Please do not +create GitHub issues for security-related doubts or problems. ## Changelog @@ -382,5 +452,5 @@ Please do not create GitHub issues for security-related doubts or problems. ## License -Copyright 2017 - 2025 SAP SE or an SAP affiliate company and contributors. -Please see our LICENSE for copyright and license information. +Copyright 2017 - 2025 SAP SE or an SAP affiliate company and contributors. Please see our LICENSE for copyright and +license information. diff --git a/CI-TESTING-INTEGRATION.md b/TESTING.md similarity index 79% rename from CI-TESTING-INTEGRATION.md rename to TESTING.md index de526f5..6a1ca31 100644 --- a/CI-TESTING-INTEGRATION.md +++ b/TESTING.md @@ -2,7 +2,8 @@ ## 🎯 Overview -The CF Java Plugin now includes comprehensive CI/CD integration with automated testing, linting, and quality assurance for both Go and Python codebases. +The CF Java Plugin now includes comprehensive CI/CD integration with automated testing, linting, +and quality assurance for both Go and Python codebases. ## πŸ—οΈ CI/CD Pipeline @@ -20,12 +21,14 @@ The CF Java Plugin now includes comprehensive CI/CD integration with automated t - **Validation Steps**: - Go formatting (`go fmt`) and linting (`go vet`) - Python code quality (flake8, black, isort) + - Markdown linting and formatting - Python test execution - Plugin build verification ### Smart Python Detection The CI automatically detects if the Python test suite exists by checking for: + - `test/requirements.txt` - `test/setup.sh` @@ -34,19 +37,23 @@ If found, runs Python linting validation. **Note: Python test execution is tempo ## πŸ”’ Pre-commit Hooks ### Installation + ```bash ./setup-dev-env.sh # One-time setup ``` ### What It Checks + - βœ… Go code formatting (`go fmt`) - βœ… Go static analysis (`go vet`) - βœ… Python linting (flake8) - if test suite exists - βœ… Python formatting (black) - auto-fixes issues - βœ… Import sorting (isort) - auto-fixes issues - βœ… Python syntax validation +- βœ… Markdown linting (markdownlint) - checks git-tracked files ### Hook Behavior + - **Auto-fixes**: Python formatting and import sorting - **Blocks commits**: On critical linting issues - **Warnings**: For non-critical issues or missing Python suite @@ -54,11 +61,27 @@ If found, runs Python linting validation. **Note: Python test execution is tempo ## πŸ§ͺ Python Test Suite Integration ### Linting Standards -- **flake8**: Line length 120, ignores E203,W503 -- **black**: Line length 120, compatible with flake8 -- **isort**: Black-compatible profile for import sorting + +- **[flake8](https://flake8.pycqa.org/)**: Line length 120, ignores E203,W503 +- **[black](https://black.readthedocs.io/)**: Line length 120, compatible with flake8 +- **[isort](https://pycqa.github.io/isort/)**: Black-compatible profile for import sorting +- **[markdownlint](https://github.com/DavidAnson/markdownlint)**: Automated markdown formatting + (120 char limit, git-tracked files only) + +### Manual Usage + +```bash +./scripts/lint-go.sh check # Check Go code formatting and static analysis +./scripts/lint-go.sh fix # Auto-fix Go code issues +./scripts/lint-python.sh check # Check Python code quality +./scripts/lint-python.sh fix # Auto-fix Python code issues +./scripts/lint-markdown.sh check # Check formatting +./scripts/lint-markdown.sh fix # Auto-fix issues +./scripts/lint-all.sh check # Check all (Go, Python, Markdown) +``` ### Test Execution + ```bash cd test ./setup.sh # Setup environment @@ -68,6 +91,7 @@ cd test **CI Status**: Python tests are currently disabled in CI workflows but can be run locally. ### Coverage Reporting + - Generated in XML format for Codecov integration - Covers the `framework` module - Includes terminal output for local development @@ -75,6 +99,7 @@ cd test ## πŸ› οΈ Development Workflow ### First-time Setup + ```bash git clone cd cf-cli-java-plugin @@ -82,6 +107,7 @@ cd cf-cli-java-plugin ``` ### Daily Development + ```bash # Make changes code cf-java-plugin.code-workspace @@ -97,6 +123,7 @@ git push origin feature-branch ``` ### Manual Testing + ```bash # Test pre-commit hooks .git/hooks/pre-commit @@ -111,14 +138,21 @@ cd test && pytest test_jfr.py -v ## πŸ“Š Quality Metrics ### Go Code Quality + - Formatting enforcement via `go fmt` - Static analysis via `go vet` ### Python Code Quality + - Style compliance: flake8 (PEP 8 + custom rules) - Formatting: black (consistent style) - Import organization: isort (proper import ordering) +### Markdown Code Quality + +- Style compliance: markdownlint (120 char limit, git-tracked files only) +- Automated formatting with relaxed rules for compatibility + ## πŸ” GitHub Secrets Configuration For running Python tests in CI that require Cloud Foundry credentials, configure these GitHub repository secrets: @@ -150,6 +184,7 @@ For running Python tests in CI that require Cloud Foundry credentials, configure ### Environment Variable Usage The Python test framework automatically uses these environment variables: + - Falls back to `test_config.yml` if environment variables are not set - Supports both file-based and environment-based configuration - CI workflows pass secrets as environment variables to test processes diff --git a/cf_cli_java_plugin.go b/cf_cli_java_plugin.go index 62cbbaf..456cba0 100644 --- a/cf_cli_java_plugin.go +++ b/cf_cli_java_plugin.go @@ -4,11 +4,11 @@ * otherwise in the LICENSE file at the root of the repository. */ +// Package main implements a CF CLI plugin for Java applications, providing commands +// for heap dumps, thread dumps, profiling, and other Java diagnostics. package main import ( - "cf.plugin.ref/requires/cmd" - "errors" "fmt" "os" @@ -21,24 +21,25 @@ import ( "cf.plugin.ref/requires/utils" - guuid "github.com/satori/go.uuid" "github.com/simonleung8/flags" ) // Assert that JavaPlugin implements plugin.Plugin. var _ plugin.Plugin = (*JavaPlugin)(nil) -// The JavaPlugin is a cf cli plugin that supports taking heap and thread dumps on demand +// JavaPlugin is a CF CLI plugin that supports taking heap and thread dumps on demand type JavaPlugin struct { verbose bool } -// UUIDGenerator is an interface that encapsulates the generation of UUIDs -type UUIDGenerator interface { - Generate() string +// logVerbose logs a message with a format string if verbose mode is enabled +func (c *JavaPlugin) logVerbose(format string, args ...any) { + if c.verbose { + fmt.Printf("[VERBOSE] "+format+"\n", args...) + } } -// InvalidUsageError errors mean that the arguments passed in input to the command are invalid +// InvalidUsageError indicates that the arguments passed as input to the command are invalid type InvalidUsageError struct { message string } @@ -47,32 +48,158 @@ func (e InvalidUsageError) Error() string { return e.message } -type commandExecutorImpl struct { - cliConnection plugin.CliConnection +// Options holds all command-line options for the Java plugin +type Options struct { + AppInstanceIndex int + Keep bool + NoDownload bool + DryRun bool + Verbose bool + ContainerDir string + LocalDir string + Args string } -func (c commandExecutorImpl) Execute(args []string) ([]string, error) { - output, err := c.cliConnection.CliCommand(args...) +// FlagDefinition holds metadata for a command-line flag +type FlagDefinition struct { + Name string + ShortName string + Usage string + Description string // Longer description for help text + Type string + DefaultInt int +} - return output, err +// flagDefinitions contains all flag definitions in a centralized location +var flagDefinitions = []FlagDefinition{ + { + Name: "app-instance-index", + ShortName: "i", + Usage: "application `instance` to connect to", + Description: "select to which instance of the app to connect", + Type: "int", + DefaultInt: 0, + }, + { + Name: "keep", + ShortName: "k", + Usage: "whether to `keep` the heap-dump/JFR/... files on the container of the application instance after having downloaded it locally", + Description: "keep the heap dump in the container; by default the heap dump/JFR/... will be deleted from the container's filesystem after being downloaded", + Type: "bool", + }, + { + Name: "no-download", + ShortName: "nd", + Usage: "do not download the heap-dump/JFR/... file to the local machine", + Description: "don't download the heap dump/JFR/... file to local, only keep it in the container, implies '--keep'", + Type: "bool", + }, + { + Name: "dry-run", + ShortName: "n", + Usage: "triggers the `dry-run` mode to show only the cf-ssh command that would have been executed", + Description: "just output to command line what would be executed", + Type: "bool", + }, + { + Name: "verbose", + ShortName: "v", + Usage: "enable verbose output for the plugin", + Description: "enable verbose output for the plugin", + Type: "bool", + }, + { + Name: "container-dir", + ShortName: "cd", + Usage: "specify the folder path where the dump/JFR/... file should be stored in the container", + Description: "the directory path in the container that the heap dump/JFR/... file will be saved to", + Type: "string", + }, + { + Name: "local-dir", + ShortName: "ld", + Usage: "specify the folder where the dump/JFR/... file will be downloaded to, dump file will not be copied to local if this parameter was not set", + Description: "the local directory path that the dump/JFR/... file will be saved to, defaults to the current directory", + Type: "string", + }, + { + Name: "args", + ShortName: "a", + Usage: "Miscellaneous arguments to pass to the command in the container, be aware to end it with a space if it is a simple option", + Description: "Miscellaneous arguments to pass to the command (if supported) in the container, be aware to end it with a space if it is a simple option. For commands that create arbitrary files (jcmd, asprof), the environment variables @FSPATH, @ARGS, @APP_NAME, @FILE_NAME, and @STATIC_FILE_NAME are available in --args to reference the working directory path, arguments, application name, and generated file name respectively.", + Type: "string", + }, } -type uuidGeneratorImpl struct { +func (c *JavaPlugin) createOptionsParser() flags.FlagContext { + commandFlags := flags.New() + + // Create flags from centralized definitions + for _, flagDef := range flagDefinitions { + switch flagDef.Type { + case "int": + commandFlags.NewIntFlagWithDefault(flagDef.Name, flagDef.ShortName, flagDef.Usage, flagDef.DefaultInt) + case "bool": + commandFlags.NewBoolFlag(flagDef.Name, flagDef.ShortName, flagDef.Usage) + case "string": + commandFlags.NewStringFlag(flagDef.Name, flagDef.ShortName, flagDef.Usage) + } + } + + return commandFlags } -func (u uuidGeneratorImpl) Generate() string { - return guuid.NewV4().String() +// parseOptions creates and parses command-line flags, returning the Options struct +func (c *JavaPlugin) parseOptions(args []string) (*Options, []string, error) { + commandFlags := c.createOptionsParser() + parseErr := commandFlags.Parse(args...) + if parseErr != nil { + return nil, nil, parseErr + } + + options := &Options{ + AppInstanceIndex: commandFlags.Int("app-instance-index"), + Keep: commandFlags.IsSet("keep"), + NoDownload: commandFlags.IsSet("no-download"), + DryRun: commandFlags.IsSet("dry-run"), + Verbose: commandFlags.IsSet("verbose"), + ContainerDir: commandFlags.String("container-dir"), + LocalDir: commandFlags.String("local-dir"), + Args: commandFlags.String("args"), + } + + return options, commandFlags.Args(), nil +} + +// generateOptionsMapFromFlags creates the options map for plugin metadata +func (c *JavaPlugin) generateOptionsMapFromFlags() map[string]string { + options := make(map[string]string) + + // Generate options from the centralized flag definitions + for _, flagDef := range flagDefinitions { + // Create the prefix for the flag (short name with appropriate formatting) + prefix := "-" + flagDef.ShortName + if flagDef.Name == "app-instance-index" { + prefix += " [index]" + } + prefix += ", " + + // Use the Description field for detailed help text + options[flagDef.Name] = utils.WrapTextWithPrefix(flagDef.Description, prefix, 80, 27) + } + + return options } const ( - // JavaDetectionCommand is the prologue command to detect on the Garden container if it contains a Java app. + // JavaDetectionCommand is the prologue command to detect if the Garden container contains a Java app. JavaDetectionCommand = "if ! pgrep -x \"java\" > /dev/null; then echo \"No 'java' process found running. Are you sure this is a Java app?\" >&2; exit 1; fi" CheckNoCurrentJFRRecordingCommand = `OUTPUT=$($JCMD_COMMAND $(pidof java) JFR.check 2>&1); if [[ ! "$OUTPUT" == *"No available recording"* ]]; then echo "JFR recording already running. Stop it before starting a new recording."; exit 1; fi;` FilterJCMDRemoteMessage = `filter_jcmd_remote_message() { if command -v grep >/dev/null 2>&1; then - grep -v -e "Connected to remote JVM" -e "JVM response code = 0" + grep -v -e "Connected to remote JVM" -e "JVM response code = 0" else - cat # fallback: just pass through the input unchanged + cat # fallback: just pass through the input unchanged fi };` ) @@ -80,14 +207,14 @@ const ( // Run must be implemented by any plugin because it is part of the // plugin interface defined by the core CLI. // -// Run(....) is the entry point when the core CLI is invoking a command defined +// Run(...) is the entry point when the core CLI is invoking a command defined // by a plugin. The first parameter, plugin.CliConnection, is a struct that can -// be used to invoke cli commands. The second paramter, args, is a slice of -// strings. args[0] will be the Name of the command, and will be followed by -// any additional arguments a cli user typed in. +// be used to invoke CLI commands. The second parameter, args, is a slice of +// strings. args[0] will be the name of the command, and will be followed by +// any additional arguments a CLI user typed in. // -// Any error handling should be handled with the plugin itself (this means printing -// user facing errors). The CLI will exit 0 if the plugin exits 0 and will exit +// Any error handling should be handled within the plugin itself (this means printing +// user-facing errors). The CLI will exit 0 if the plugin exits 0 and will exit // 1 should the plugin exit nonzero. func (c *JavaPlugin) Run(cliConnection plugin.CliConnection, args []string) { // Check if verbose flag is in args for early logging @@ -98,42 +225,35 @@ func (c *JavaPlugin) Run(cliConnection plugin.CliConnection, args []string) { } } - if c.verbose { - fmt.Printf("[VERBOSE] Run called with args: %v\n", args) - } + c.logVerbose("Run called with args: %v", args) - _, err := c.DoRun(&commandExecutorImpl{cliConnection: cliConnection}, &uuidGeneratorImpl{}, args) + _, err := c.DoRun(cliConnection, args) if err != nil { - if c.verbose { - fmt.Printf("[VERBOSE] Error occurred: %v\n", err) - } + c.logVerbose("Error occurred: %v", err) os.Exit(1) } - if c.verbose { - fmt.Printf("[VERBOSE] Run completed successfully\n") - } + c.logVerbose("Run completed successfully") } -// DoRun is an internal method that we use to wrap the cmd package with CommandExecutor for test purposes -func (c *JavaPlugin) DoRun(commandExecutor cmd.CommandExecutor, uuidGenerator UUIDGenerator, args []string) (string, error) { +// DoRun is an internal method used to wrap the cmd package with CommandExecutor for test purposes +func (c *JavaPlugin) DoRun(cliConnection plugin.CliConnection, args []string) (string, error) { traceLogger := trace.NewLogger(os.Stdout, true, os.Getenv("CF_TRACE"), "") ui := terminal.NewUI(os.Stdin, os.Stdout, terminal.NewTeePrinter(os.Stdout), traceLogger) - if c.verbose { - fmt.Printf("[VERBOSE] DoRun called with args: %v\n", args) - } + c.logVerbose("DoRun called with args: %v", args) - output, err := c.execute(commandExecutor, uuidGenerator, args) + output, err := c.execute(cliConnection, args) if err != nil { if err.Error() == "unexpected EOF" { return output, err } ui.Failed(err.Error()) - if _, invalidUsageErr := err.(*InvalidUsageError); invalidUsageErr { + var invalidUsageErr *InvalidUsageError + if errors.As(err, &invalidUsageErr) { fmt.Println() fmt.Println() - _, err := commandExecutor.Execute([]string{"help", "java"}) + _, err := cliConnection.CliCommand("help", "java") if err != nil { ui.Failed("Failed to show help") } @@ -157,7 +277,7 @@ type Command struct { // Use @ prefix to avoid shell expansion issues, replaced directly in Go code // use @FILE_NAME to get the generated file name with a random UUID, // @STATIC_FILE_NAME without, and @FSPATH to get the path where the file is stored (for GenerateArbitraryFiles commands) - SshCommand string + SSHCommand string FilePattern string FileExtension string FileLabel string @@ -167,14 +287,14 @@ type Command struct { GenerateArbitraryFilesFolderName string } -// function names "HasMiscArgs" that is used on Command and checks whether the SSHCommand contains @ARGS +// HasMiscArgs checks whether the SSHCommand contains @ARGS func (c *Command) HasMiscArgs() bool { - return strings.Contains(c.SshCommand, "@ARGS") + return strings.Contains(c.SSHCommand, "@ARGS") } -// replaceVariables replaces @-prefixed variables in the command with actual values -// Returns the processed command string and an error if validation fails -func replaceVariables(command, appName, fspath, fileName, staticFileName, args string) (string, error) { +// replaceVariables replaces @-prefixed variables in the command with actual values. +// Returns the processed command string and an error if validation fails. +func (c *JavaPlugin) replaceVariables(command, appName, fspath, fileName, staticFileName, args string) (string, error) { // Validate: @ARGS cannot contain itself, other variables cannot contain any @ variables if strings.Contains(args, "@ARGS") { return "", fmt.Errorf("invalid variable reference: @ARGS cannot contain itself") @@ -219,7 +339,7 @@ var commands = []Command{ OpenJDK: Wrap everything in an if statement in case jmap is available */ - SshCommand: `if [ -f @FILE_NAME ]; then echo >&2 'Heap dump @FILE_NAME already exists'; exit 1; fi + SSHCommand: `if [ -f @FILE_NAME ]; then echo >&2 'Heap dump @FILE_NAME already exists'; exit 1; fi JMAP_COMMAND=$(find -executable -name jmap | head -1 | tr -d [:space:]) # SAP JVM: Wrap everything in an if statement in case jvmmon is available JVMMON_COMMAND=$(find -executable -name jvmmon | head -1 | tr -d [:space:]) @@ -233,7 +353,7 @@ if [ -z "${JMAP_COMMAND}" ] && [ -z "${JVMMON_COMMAND}" ]; then path: buildpack: https://github.com/cloudfoundry/java-buildpack env: - JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/bionic/x86_64", version: 11.+ } }' + JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64", version: 21.+ } }' " exit 1 @@ -258,7 +378,7 @@ fi`, Name: "thread-dump", Description: "Generate a thread dump from a running Java application", GenerateFiles: false, - SshCommand: `JSTACK_COMMAND=$(find -executable -name jstack | head -1); + SSHCommand: `JSTACK_COMMAND=$(find -executable -name jstack | head -1); JVMMON_COMMAND=$(find -executable -name jvmmon | head -1) if [ -z "${JMAP_COMMAND}" ] && [ -z "${JVMMON_COMMAND}" ]; then echo >&2 "jvmmon or jmap are required for generating heap dump, you can modify your application manifest.yaml on the 'JBP_CONFIG_OPEN_JDK_JRE' environment variable. This could be done like this: @@ -269,7 +389,7 @@ fi`, path: buildpack: https://github.com/cloudfoundry/java-buildpack env: - JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/bionic/x86_64", version: 11.+ } }' + JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64", version: 21.+ } }' " exit 1 @@ -282,7 +402,7 @@ fi`, Description: "Print information about the Java Virtual Machine running a Java application", RequiredTools: []string{"jcmd"}, GenerateFiles: false, - SshCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.info | filter_jcmd_remote_message`, + SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.info | filter_jcmd_remote_message`, }, { Name: "jcmd", @@ -291,37 +411,37 @@ fi`, GenerateFiles: false, GenerateArbitraryFiles: true, GenerateArbitraryFilesFolderName: "jcmd", - SshCommand: `$JCMD_COMMAND $(pidof java) @ARGS`, + SSHCommand: `$JCMD_COMMAND $(pidof java) @ARGS`, }, { Name: "jfr-start", - Description: "Start a Java Flight Recorder default recording on a running Java application (stores in the the container-dir)", + Description: "Start a Java Flight Recorder default recording on a running Java application (stores in the container-dir)", RequiredTools: []string{"jcmd"}, GenerateFiles: false, NeedsFileName: true, FileExtension: ".jfr", FileLabel: "JFR recording", FileNamePart: "jfr", - SshCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + + SSHCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + `$JCMD_COMMAND $(pidof java) JFR.start settings=default.jfc filename=@FILE_NAME name=JFR | filter_jcmd_remote_message; echo "Use 'cf java jfr-stop @APP_NAME' to copy the file to the local folder"`, }, { Name: "jfr-start-profile", - Description: "Start a Java Flight Recorder profile recording on a running Java application (stores in the the container-dir))", + Description: "Start a Java Flight Recorder profile recording on a running Java application (stores in the container-dir))", RequiredTools: []string{"jcmd"}, GenerateFiles: false, NeedsFileName: true, FileExtension: ".jfr", FileLabel: "JFR recording", FileNamePart: "jfr", - SshCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + + SSHCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + `$JCMD_COMMAND $(pidof java) JFR.start settings=profile.jfc filename=@FILE_NAME name=JFR | filter_jcmd_remote_message; echo "Use 'cf java jfr-stop @APP_NAME' to copy the file to the local folder"`, }, { Name: "jfr-start-gc", - Description: "Start a Java Flight Recorder GC recording on a running Java application (stores in the the container-dir)", + Description: "Start a Java Flight Recorder GC recording on a running Java application (stores in the container-dir)", RequiredTools: []string{"jcmd"}, GenerateFiles: false, OnlyOnRecentSapMachine: true, @@ -329,13 +449,13 @@ fi`, FileExtension: ".jfr", FileLabel: "JFR recording", FileNamePart: "jfr", - SshCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + + SSHCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + `$JCMD_COMMAND $(pidof java) JFR.start settings=gc.jfc filename=@FILE_NAME name=JFR | filter_jcmd_remote_message; echo "Use 'cf java jfr-stop @APP_NAME' to copy the file to the local folder"`, }, { Name: "jfr-start-gc-details", - Description: "Start a Java Flight Recorder detailed GC recording on a running Java application (stores in the the container-dir)", + Description: "Start a Java Flight Recorder detailed GC recording on a running Java application (stores in the container-dir)", RequiredTools: []string{"jcmd"}, GenerateFiles: false, OnlyOnRecentSapMachine: true, @@ -343,7 +463,7 @@ fi`, FileExtension: ".jfr", FileLabel: "JFR recording", FileNamePart: "jfr", - SshCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + + SSHCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + `$JCMD_COMMAND $(pidof java) JFR.start settings=gc_details.jfc filename=@FILE_NAME name=JFR | filter_jcmd_remote_message; echo "Use 'cf java jfr-stop @APP_NAME' to copy the file to the local folder"`, }, @@ -355,12 +475,12 @@ fi`, FileExtension: ".jfr", FileLabel: "JFR recording", FileNamePart: "jfr", - SshCommand: FilterJCMDRemoteMessage + ` output=$($JCMD_COMMAND $(pidof java) JFR.stop name=JFR | filter_jcmd_remote_message); + SSHCommand: FilterJCMDRemoteMessage + ` output=$($JCMD_COMMAND $(pidof java) JFR.stop name=JFR | filter_jcmd_remote_message); echo "$output"; echo ""; filename=$(echo "$output" | grep /.*.jfr --only-matching); if [ -z "$filename" ]; then echo "No JFR recording created"; exit 1; fi; if [ ! -f "$filename" ]; then echo "JFR recording $filename does not exist"; exit 1; fi; if [ ! -s "$filename" ]; then echo "JFR recording $filename is empty"; exit 1; fi; - mvn "$filename" @FILE_NAME; + mv "$filename" @FILE_NAME; echo "JFR recording copied to @FILE_NAME"`, }, { @@ -371,7 +491,7 @@ fi`, FileExtension: ".jfr", FileLabel: "JFR recording", FileNamePart: "jfr", - SshCommand: FilterJCMDRemoteMessage + ` output=$($JCMD_COMMAND $(pidof java) JFR.dump name=JFR | filter_jcmd_remote_message); + SSHCommand: FilterJCMDRemoteMessage + ` output=$($JCMD_COMMAND $(pidof java) JFR.dump name=JFR | filter_jcmd_remote_message); echo "$output"; echo ""; filename=$(echo "$output" | grep /.*.jfr --only-matching); if [ -z "$filename" ]; then echo "No JFR recording created"; exit 1; fi; if [ ! -f "$filename" ]; then echo "JFR recording $filename does not exist"; exit 1; fi; @@ -385,21 +505,21 @@ fi`, Description: "Check the running Java Flight Recorder recording on a running Java application", RequiredTools: []string{"jcmd"}, GenerateFiles: false, - SshCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) JFR.check | filter_jcmd_remote_message`, + SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) JFR.check | filter_jcmd_remote_message`, }, { Name: "vm-version", Description: "Print the version of the Java Virtual Machine running a Java application", RequiredTools: []string{"jcmd"}, GenerateFiles: false, - SshCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.version | filter_jcmd_remote_message`, + SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.version | filter_jcmd_remote_message`, }, { Name: "vm-vitals", Description: "Print vital statistics about the Java Virtual Machine running a Java application", RequiredTools: []string{"jcmd"}, GenerateFiles: false, - SshCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.vitals | filter_jcmd_remote_message`, + SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.vitals | filter_jcmd_remote_message`, }, { Name: "asprof", @@ -409,7 +529,7 @@ fi`, GenerateFiles: false, GenerateArbitraryFiles: true, GenerateArbitraryFilesFolderName: "asprof", - SshCommand: `$ASPROF_COMMAND $(pidof java) @ARGS`, + SSHCommand: `$ASPROF_COMMAND $(pidof java) @ARGS`, }, { Name: "asprof-start-cpu", @@ -420,7 +540,7 @@ fi`, NeedsFileName: true, FileExtension: ".jfr", FileNamePart: "asprof", - SshCommand: `$ASPROF_COMMAND start $(pidof java) -e cpu -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, + SSHCommand: `$ASPROF_COMMAND start $(pidof java) -e cpu -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, }, { Name: "asprof-start-wall", @@ -431,7 +551,7 @@ fi`, NeedsFileName: true, FileExtension: ".jfr", FileNamePart: "asprof", - SshCommand: `$ASPROF_COMMAND start $(pidof java) -e wall -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, + SSHCommand: `$ASPROF_COMMAND start $(pidof java) -e wall -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, }, { Name: "asprof-start-alloc", @@ -442,7 +562,7 @@ fi`, NeedsFileName: true, FileExtension: ".jfr", FileNamePart: "asprof", - SshCommand: `$ASPROF_COMMAND start $(pidof java) -e alloc -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, + SSHCommand: `$ASPROF_COMMAND start $(pidof java) -e alloc -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, }, { Name: "asprof-start-lock", @@ -453,7 +573,7 @@ fi`, NeedsFileName: true, FileExtension: ".jfr", FileNamePart: "asprof", - SshCommand: `$ASPROF_COMMAND start $(pidof java) -e lock -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, + SSHCommand: `$ASPROF_COMMAND start $(pidof java) -e lock -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, }, { Name: "asprof-stop", @@ -464,7 +584,7 @@ fi`, FileExtension: ".jfr", FileLabel: "JFR recording", FileNamePart: "asprof", - SshCommand: `$ASPROF_COMMAND stop $(pidof java)`, + SSHCommand: `$ASPROF_COMMAND stop $(pidof java)`, }, { Name: "asprof-status", @@ -472,21 +592,11 @@ fi`, RequiredTools: []string{"asprof"}, OnlyOnRecentSapMachine: true, GenerateFiles: false, - SshCommand: `$ASPROF_COMMAND status $(pidof java)`, + SSHCommand: `$ASPROF_COMMAND status $(pidof java)`, }, } -func toSentenceCase(input string) string { - // Convert the first character to uppercase and the rest to lowercase - if len(input) == 0 { - return input - } - - // Convert the first letter to uppercase - return strings.ToUpper(string(input[0])) + strings.ToLower(input[1:]) -} - -func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator UUIDGenerator, args []string) (string, error) { +func (c *JavaPlugin) execute(cliConnection plugin.CliConnection, args []string) (string, error) { if len(args) == 0 { return "", &InvalidUsageError{message: "No command provided"} } @@ -505,61 +615,34 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator return "", errors.New("the environment variable CF_TRACE is set to true. This prevents download of the dump from succeeding") } - commandFlags := flags.New() - - commandFlags.NewIntFlagWithDefault("app-instance-index", "i", "application `instance` to connect to", 0) - commandFlags.NewBoolFlag("keep", "k", "whether to `keep` the heap-dump/JFR/... files on the container of the application instance after having downloaded it locally") - commandFlags.NewBoolFlag("no-download", "nd", "do not download the heap-dump/JFR/... file to the local machine") - commandFlags.NewBoolFlag("dry-run", "n", "triggers the `dry-run` mode to show only the cf-ssh command that would have been executed") - commandFlags.NewBoolFlag("verbose", "v", "enable verbose output for the plugin") - commandFlags.NewStringFlag("container-dir", "cd", "specify the folder path where the dump/JFR/... file should be stored in the container") - commandFlags.NewStringFlag("local-dir", "ld", "specify the folder where the dump/JFR/... file will be downloaded to, dump file wil not be copied to local if this parameter was not set") - commandFlags.NewStringFlag("args", "a", "Miscellaneous arguments to pass to the command in the container, be aware to end it with a space if it is a simple option") - - fileFlags := []string{"container-dir", "local-dir", "keep", "no-download"} - - parseErr := commandFlags.Parse(args[1:]...) + options, arguments, parseErr := c.parseOptions(args[1:]) if parseErr != nil { return "", &InvalidUsageError{message: fmt.Sprintf("Error while parsing command arguments: %v", parseErr)} } - miscArgs := "" - if commandFlags.IsSet("args") { - miscArgs = commandFlags.String("args") - } - - verbose := commandFlags.IsSet("verbose") - - // Helper function for verbose logging with format strings - logVerbose := func(format string, args ...any) { - if verbose { - fmt.Printf("[VERBOSE] "+format+"\n", args...) - } - } + fileFlags := []string{"container-dir", "local-dir", "keep", "no-download"} - logVerbose("Starting command execution") - logVerbose("Command arguments: %v", args) + c.logVerbose("Starting command execution") + c.logVerbose("Command arguments: %v", args) - applicationInstance := commandFlags.Int("app-instance-index") - noDownload := commandFlags.IsSet("no-download") - keepAfterDownload := commandFlags.IsSet("keep") || noDownload + noDownload := options.NoDownload + keepAfterDownload := options.Keep || noDownload - logVerbose("Application instance: %d", applicationInstance) - logVerbose("No download: %t", noDownload) - logVerbose("Keep after download: %t", keepAfterDownload) + c.logVerbose("Application instance: %d", options.AppInstanceIndex) + c.logVerbose("No download: %t", noDownload) + c.logVerbose("Keep after download: %t", keepAfterDownload) - remoteDir := commandFlags.String("container-dir") + remoteDir := options.ContainerDir // strip trailing slashes from remoteDir remoteDir = strings.TrimRight(remoteDir, "/") - localDir := commandFlags.String("local-dir") + localDir := options.LocalDir if localDir == "" { localDir = "." } - logVerbose("Remote directory: %s", remoteDir) - logVerbose("Local directory: %s", localDir) + c.logVerbose("Remote directory: %s", remoteDir) + c.logVerbose("Local directory: %s", localDir) - arguments := commandFlags.Args() argumentLen := len(arguments) if argumentLen < 1 { @@ -567,7 +650,7 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator } commandName := arguments[0] - logVerbose("Command name: %s", commandName) + c.logVerbose("Command name: %s", commandName) index := -1 for i, command := range commands { @@ -586,30 +669,33 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator } command := commands[index] - logVerbose("Found command: %s - %s", command.Name, command.Description) + c.logVerbose("Found command: %s - %s", command.Name, command.Description) if !command.GenerateFiles && !command.GenerateArbitraryFiles { - logVerbose("Command does not generate files, checking for invalid file flags") + c.logVerbose("Command does not generate files, checking for invalid file flags") for _, flag := range fileFlags { - if commandFlags.IsSet(flag) { - logVerbose("Invalid flag %q detected for command %s", flag, command.Name) + if (flag == "container-dir" && options.ContainerDir != "") || + (flag == "local-dir" && options.LocalDir != "") || + (flag == "keep" && options.Keep) || + (flag == "no-download" && options.NoDownload) { + c.logVerbose("Invalid flag %q detected for command %s", flag, command.Name) return "", &InvalidUsageError{message: fmt.Sprintf("The flag %q is not supported for %s", flag, command.Name)} } } } if command.Name == "asprof" { - trimmedMiscArgs := strings.TrimLeft(miscArgs, " ") + trimmedMiscArgs := strings.TrimLeft(options.Args, " ") if len(trimmedMiscArgs) > 6 && trimmedMiscArgs[:6] == "start " { noDownload = true - logVerbose("asprof start command detected, setting noDownload to true") + c.logVerbose("asprof start command detected, setting noDownload to true") } else { noDownload = trimmedMiscArgs == "start" if noDownload { - logVerbose("asprof start command detected, setting noDownload to true") + c.logVerbose("asprof start command detected, setting noDownload to true") } } } - if !command.HasMiscArgs() && commandFlags.IsSet("args") { - logVerbose("Command %s does not support --args flag", command.Name) + if !command.HasMiscArgs() && options.Args != "" { + c.logVerbose("Command %s does not support --args flag", command.Name) return "", &InvalidUsageError{message: fmt.Sprintf("The flag %q is not supported for %s", "args", command.Name)} } if argumentLen == 1 { @@ -619,18 +705,18 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator } applicationName := arguments[1] - logVerbose("Application name: %s", applicationName) + c.logVerbose("Application name: %s", applicationName) cfSSHArguments := []string{"ssh", applicationName} - if applicationInstance > 0 { - cfSSHArguments = append(cfSSHArguments, "--app-instance-index", strconv.Itoa(applicationInstance)) + if options.AppInstanceIndex > 0 { + cfSSHArguments = append(cfSSHArguments, "--app-instance-index", strconv.Itoa(options.AppInstanceIndex)) } - if applicationInstance < 0 { + if options.AppInstanceIndex < 0 { // indexes can't be negative, so fail with an error - return "", &InvalidUsageError{message: fmt.Sprintf("Invalid application instance index %d, must be >= 0", applicationInstance)} + return "", &InvalidUsageError{message: fmt.Sprintf("Invalid application instance index %d, must be >= 0", options.AppInstanceIndex)} } - logVerbose("CF SSH arguments: %v", cfSSHArguments) + c.logVerbose("CF SSH arguments: %v", cfSSHArguments) supported, err := utils.CheckRequiredTools(applicationName) @@ -638,24 +724,24 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator return "required tools checking failed", err } - logVerbose("Required tools check passed") + c.logVerbose("Required tools check passed") - var remoteCommandTokens = []string{JavaDetectionCommand} + remoteCommandTokens := []string{JavaDetectionCommand} - logVerbose("Building remote command tokens") - logVerbose("Java detection command: %s", JavaDetectionCommand) + c.logVerbose("Building remote command tokens") + c.logVerbose("Java detection command: %s", JavaDetectionCommand) for _, requiredTool := range command.RequiredTools { - logVerbose("Setting up required tool: %s", requiredTool) + c.logVerbose("Setting up required tool: %s", requiredTool) uppercase := strings.ToUpper(requiredTool) - var toolCommand = fmt.Sprintf(`%[1]s_TOOL_PATH=$(find -executable -name %[2]s | head -1 | tr -d [:space:]); if [ -z "$%[1]s_TOOL_PATH" ]; then echo "%[2]s not found"; exit 1; fi; %[1]s_COMMAND=$(realpath "$%[1]s_TOOL_PATH")`, uppercase, requiredTool) + toolCommand := fmt.Sprintf(`%[1]s_TOOL_PATH=$(find -executable -name %[2]s | head -1 | tr -d [:space:]); if [ -z "$%[1]s_TOOL_PATH" ]; then echo "%[2]s not found"; exit 1; fi; %[1]s_COMMAND=$(realpath "$%[1]s_TOOL_PATH")`, uppercase, requiredTool) if requiredTool == "jcmd" { // add code that first checks whether asprof is present and if so use `asprof jcmd` instead of `jcmd` remoteCommandTokens = append(remoteCommandTokens, toolCommand, "ASPROF_COMMAND=$(realpath $(find -executable -name asprof | head -1 | tr -d [:space:])); if [ -n \"${ASPROF_COMMAND}\" ]; then JCMD_COMMAND=\"${ASPROF_COMMAND} jcmd\"; fi") - logVerbose("Added jcmd with asprof fallback") + c.logVerbose("Added jcmd with asprof fallback") } else { remoteCommandTokens = append(remoteCommandTokens, toolCommand) - logVerbose("Added tool command for %s", requiredTool) + c.logVerbose("Added tool command for %s", requiredTool) } } fileName := "" @@ -664,7 +750,7 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator // Initialize fspath and fileName for commands that need them if command.GenerateFiles || command.NeedsFileName || command.GenerateArbitraryFiles { - logVerbose("Command requires file generation") + c.logVerbose("Command requires file generation") fspath, err = utils.GetAvailablePath(applicationName, remoteDir) if err != nil { return "", fmt.Errorf("failed to get available path: %w", err) @@ -672,23 +758,23 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator if fspath == "" { return "", fmt.Errorf("no available path found for file generation") } - logVerbose("Available path: %s", fspath) + c.logVerbose("Available path: %s", fspath) if command.GenerateArbitraryFiles { fspath = fspath + "/" + command.GenerateArbitraryFilesFolderName - logVerbose("Updated path for arbitrary files: %s", fspath) + c.logVerbose("Updated path for arbitrary files: %s", fspath) } - fileName = fspath + "/" + applicationName + "-" + command.FileNamePart + "-" + uuidGenerator.Generate() + command.FileExtension - staticFileName := fspath + "/" + applicationName + command.FileNamePart + command.FileExtension - logVerbose("Generated filename: %s", fileName) - logVerbose("Generated static filename without UUID: %s", staticFileName) + fileName = fspath + "/" + applicationName + "-" + command.FileNamePart + "-" + utils.GenerateUUID() + command.FileExtension + staticFileName = fspath + "/" + applicationName + command.FileNamePart + command.FileExtension + c.logVerbose("Generated filename: %s", fileName) + c.logVerbose("Generated static filename without UUID: %s", staticFileName) } - var commandText = command.SshCommand + commandText := command.SSHCommand // Perform variable replacements directly in Go code var err2 error - commandText, err2 = replaceVariables(commandText, applicationName, fspath, fileName, staticFileName, miscArgs) + commandText, err2 = c.replaceVariables(commandText, applicationName, fspath, fileName, staticFileName, options.Args) if err2 != nil { return "", fmt.Errorf("variable replacement failed: %w", err2) } @@ -696,32 +782,32 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator // For arbitrary files commands, insert mkdir and cd before the main command if command.GenerateArbitraryFiles { remoteCommandTokens = append(remoteCommandTokens, "mkdir -p "+fspath, "cd "+fspath, commandText) - logVerbose("Added directory creation and navigation before command execution") + c.logVerbose("Added directory creation and navigation before command execution") } else { remoteCommandTokens = append(remoteCommandTokens, commandText) } - logVerbose("Command text after replacements: %s", commandText) - logVerbose("Full remote command tokens: %v", remoteCommandTokens) + c.logVerbose("Command text after replacements: %s", commandText) + c.logVerbose("Full remote command tokens: %v", remoteCommandTokens) cfSSHArguments = append(cfSSHArguments, "--command") remoteCommand := strings.Join(remoteCommandTokens, "; ") - logVerbose("Final remote command: %s", remoteCommand) + c.logVerbose("Final remote command: %s", remoteCommand) - if commandFlags.IsSet("dry-run") { - logVerbose("Dry-run mode enabled, returning command without execution") + if options.DryRun { + c.logVerbose("Dry-run mode enabled, returning command without execution") // When printing out the entire command line for separate execution, we wrap the remote command in single quotes // to prevent the shell processing it from running it in local cfSSHArguments = append(cfSSHArguments, "'"+remoteCommand+"'") return "cf " + strings.Join(cfSSHArguments, " "), nil } - fullCommand := append(cfSSHArguments, remoteCommand) - logVerbose("Executing command: %v", fullCommand) - - output, err := commandExecutor.Execute(fullCommand) + fullCommand := append([]string{}, cfSSHArguments...) + fullCommand = append(fullCommand, remoteCommand) + c.logVerbose("Executing command: %v", fullCommand) + output, err := cliConnection.CliCommand(fullCommand...) if err != nil { if err.Error() == "unexpected EOF" { return "", fmt.Errorf("Command failed") @@ -733,26 +819,26 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator } if command.GenerateFiles { - logVerbose("Processing file generation and download") + c.logVerbose("Processing file generation and download") - finalFile := "" + var finalFile string var err error switch command.FileExtension { case ".hprof": - logVerbose("Finding heap dump file") + c.logVerbose("Finding heap dump file") finalFile, err = utils.FindHeapDumpFile(cfSSHArguments, fileName, fspath) case ".jfr": - logVerbose("Finding JFR file") + c.logVerbose("Finding JFR file") finalFile, err = utils.FindJFRFile(cfSSHArguments, fileName, fspath) default: return "", &InvalidUsageError{message: fmt.Sprintf("Unsupported file extension %q", command.FileExtension)} } if err == nil && finalFile != "" { fileName = finalFile - logVerbose("Found file: %s", finalFile) + c.logVerbose("Found file: %s", finalFile) fmt.Println("Successfully created " + command.FileLabel + " in application container at: " + fileName) } else if !noDownload { - logVerbose("Failed to find file, error: %v", err) + c.logVerbose("Failed to find file, error: %v", err) fmt.Println("Failed to find " + command.FileLabel + " in application container") return "", err } @@ -762,75 +848,75 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator return strings.Join(output, "\n"), nil } - localFileFullPath := localDir + "/" + applicationName + "-" + command.FileNamePart + "-" + uuidGenerator.Generate() + command.FileExtension - logVerbose("Downloading file to: %s", localFileFullPath) + localFileFullPath := localDir + "/" + applicationName + "-" + command.FileNamePart + "-" + utils.GenerateUUID() + command.FileExtension + c.logVerbose("Downloading file to: %s", localFileFullPath) err = utils.CopyOverCat(cfSSHArguments, fileName, localFileFullPath) if err == nil { - logVerbose("File download completed successfully") - fmt.Println(toSentenceCase(command.FileLabel) + " file saved to: " + localFileFullPath) + c.logVerbose("File download completed successfully") + fmt.Println(utils.ToSentenceCase(command.FileLabel) + " file saved to: " + localFileFullPath) } else { - logVerbose("File download failed: %v", err) + c.logVerbose("File download failed: %v", err) return "", err } if !keepAfterDownload { - logVerbose("Deleting remote file") + c.logVerbose("Deleting remote file") err = utils.DeleteRemoteFile(cfSSHArguments, fileName) if err != nil { - logVerbose("Failed to delete remote file: %v", err) + c.logVerbose("Failed to delete remote file: %v", err) return "", err } - logVerbose("Remote file deleted successfully") - fmt.Println(toSentenceCase(command.FileLabel) + " file deleted in application container") + c.logVerbose("Remote file deleted successfully") + fmt.Println(utils.ToSentenceCase(command.FileLabel) + " file deleted in application container") } else { - logVerbose("Keeping remote file as requested") + c.logVerbose("Keeping remote file as requested") } } if command.GenerateArbitraryFiles && !noDownload { - logVerbose("Processing arbitrary files download: %s", fspath) - logVerbose("cfSSHArguments: %v", cfSSHArguments) + c.logVerbose("Processing arbitrary files download: %s", fspath) + c.logVerbose("cfSSHArguments: %v", cfSSHArguments) // download all files in the generic folder files, err := utils.ListFiles(cfSSHArguments, fspath) for i, file := range files { - logVerbose("File %d: %s", i+1, file) + c.logVerbose("File %d: %s", i+1, file) } if err != nil { - logVerbose("Failed to list files: %v", err) + c.logVerbose("Failed to list files: %v", err) return "", err } - logVerbose("Found %d files to download", len(files)) + c.logVerbose("Found %d files to download", len(files)) if len(files) != 0 { for _, file := range files { - logVerbose("Downloading file: %s", file) + c.logVerbose("Downloading file: %s", file) localFileFullPath := localDir + "/" + file err = utils.CopyOverCat(cfSSHArguments, fspath+"/"+file, localFileFullPath) if err == nil { - logVerbose("File %s downloaded successfully", file) + c.logVerbose("File %s downloaded successfully", file) fmt.Printf("File %s saved to: %s\n", file, localFileFullPath) } else { - logVerbose("Failed to download file %s: %v", file, err) + c.logVerbose("Failed to download file %s: %v", file, err) return "", err } } if !keepAfterDownload { - logVerbose("Deleting remote file folder") + c.logVerbose("Deleting remote file folder") err = utils.DeleteRemoteFile(cfSSHArguments, fspath) if err != nil { - logVerbose("Failed to delete remote folder: %v", err) + c.logVerbose("Failed to delete remote folder: %v", err) return "", err } - logVerbose("Remote folder deleted successfully") + c.logVerbose("Remote folder deleted successfully") fmt.Println("File folder deleted in application container") } else { - logVerbose("Keeping remote files as requested") + c.logVerbose("Keeping remote files as requested") } } else { - logVerbose("No files found to download") + c.logVerbose("No files found to download") } } // We keep this around to make the compiler happy, but commandExecutor.Execute will cause an os.Exit - logVerbose("Command execution completed successfully") + c.logVerbose("Command execution completed successfully") return strings.Join(output, "\n"), err } @@ -838,16 +924,16 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator // defined by the core CLI. // // GetMetadata() returns a PluginMetadata struct. The first field, Name, -// determines the Name of the plugin which should generally be without spaces. -// If there are spaces in the Name a user will need to properly quote the Name -// during uninstall otherwise the Name will be treated as seperate arguments. +// determines the name of the plugin, which should generally be without spaces. +// If there are spaces in the name, a user will need to properly quote the name +// during uninstall; otherwise, the name will be treated as separate arguments. // The second value is a slice of Command structs. Our slice only contains one -// Command Struct, but could contain any number of them. The first field Name +// Command struct, but could contain any number of them. The first field Name // defines the command `cf heapdump` once installed into the CLI. The // second field, HelpText, is used by the core CLI to display help information // to the user in the core commands `cf help`, `cf`, or `cf -h`. func (c *JavaPlugin) GetMetadata() plugin.PluginMetadata { - var usageText = "cf java COMMAND APP_NAME [options]" + usageText := "cf java COMMAND APP_NAME [options]" for _, command := range commands { usageText += "\n\n " + command.Name if command.OnlyOnRecentSapMachine || command.HasMiscArgs() { @@ -863,7 +949,9 @@ func (c *JavaPlugin) GetMetadata() plugin.PluginMetadata { } usageText += ")" } - usageText += "\n " + command.Description + // Wrap the description with proper indentation + wrappedDescription := utils.WrapTextWithPrefix(command.Description, " ", 80, 0) + usageText += "\n" + wrappedDescription } return plugin.PluginMetadata{ Name: "java", @@ -885,17 +973,8 @@ func (c *JavaPlugin) GetMetadata() plugin.PluginMetadata { // UsageDetails is optional // It is used to show help of usage of each command UsageDetails: plugin.Usage{ - Usage: usageText, - Options: map[string]string{ - "app-instance-index": "-i [index], select to which instance of the app to connect", - "no-download": "-nd, don't download the heap dump/JFR/... file to local, only keep it in the container, implies '--keep'", - "keep": "-k, keep the heap dump in the container; by default the heap dump/JFR/... will be deleted from the container's filesystem after been downloaded", - "dry-run": "-n, just output to command line what would be executed", - "container-dir": "-cd, the directory path in the container that the heap dump/JFR/... file will be saved to", - "local-dir": "-ld, the local directory path that the dump/JFR/... file will be saved to, defaults to the current directory", - "args": "-a, Miscellaneous arguments to pass to the command (if supported) in the container, be aware to end it with a space if it is a simple option. For commands that create arbitrary files (jcmd, asprof), the environment variables @FSPATH, @ARGS, @APP_NAME, @FILE_NAME, and @STATIC_FILE_NAME are available in --args to reference the working directory path, arguments, application name, and generated file name respectively.", - "verbose": "-v, enable verbose output for the plugin", - }, + Usage: usageText, + Options: c.generateOptionsMapFromFlags(), }, }, }, @@ -903,7 +982,7 @@ func (c *JavaPlugin) GetMetadata() plugin.PluginMetadata { } // Unlike most Go programs, the `main()` function will not be used to run all of the -// commands provided in your plugin. Main will be used to initialize the plugin +// commands provided in your plugin. main will be used to initialize the plugin // process, as well as any dependencies you might require for your // plugin. func main() { diff --git a/cmd/cmd.go b/cmd/cmd.go index 335a9ed..9455529 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -6,8 +6,8 @@ package cmd -// CommandExecutor is an interface that encapsulates the execution of further cf cli commands. -// By "hiding" the cli command execution in this interface, we can mock the command cli execution in tests. +// CommandExecutor is an interface that encapsulates the execution of further CF CLI commands. +// By "hiding" the CLI command execution in this interface, we can mock the command CLI execution in tests. type CommandExecutor interface { Execute(args []string) ([]string, error) } diff --git a/go.mod b/go.mod index f50cf6b..9b52df6 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ toolchain go1.24.4 require ( code.cloudfoundry.org/cli v0.0.0-20250623142502-fb19e7a825ee github.com/lithammer/fuzzysearch v1.1.8 - github.com/satori/go.uuid v1.2.0 github.com/simonleung8/flags v0.0.0-20170704170018-8020ed7bcf1a ) diff --git a/go.sum b/go.sum index 8037e95..5878df1 100644 --- a/go.sum +++ b/go.sum @@ -158,8 +158,6 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sabhiram/go-gitignore v0.0.0-20171017070213-362f9845770f h1:FQZgA673tRGrrXIP/OPMO69g81ow4XsKlN/DLH8pSic= github.com/sabhiram/go-gitignore v0.0.0-20171017070213-362f9845770f/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ= -github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/simonleung8/flags v0.0.0-20170704170018-8020ed7bcf1a h1:3qgm+2S7MtAhH6xop4yeX/P5QGr+Ss9d+CLErszoCCs= github.com/simonleung8/flags v0.0.0-20170704170018-8020ed7bcf1a/go.mod h1:lfYEax1IvoGfNjgwTUYQXhLUry2sOHGH+3S7+imSCSI= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/scripts/README.md b/scripts/README.md index c587ad7..dc1f658 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,80 +5,162 @@ This directory contains centralized linting and code quality scripts for the CF ## Scripts Overview ### `lint-python.sh` + Python-specific linting and formatting script. **Usage:** + ```bash ./scripts/lint-python.sh [check|fix|ci] ``` **Modes:** + - `check` (default): Check code quality without making changes - `fix`: Auto-fix formatting and import sorting issues - `ci`: Strict checking for CI environments **Tools used:** + - `flake8`: Code linting (line length, style issues) - `black`: Code formatting - `isort`: Import sorting ### `lint-go.sh` + Go-specific linting and testing script. **Usage:** + ```bash ./scripts/lint-go.sh [check|test|ci] ``` **Modes:** + - `check` (default): Run linting checks only - `ci`: Run all checks for CI environments (lint + dependencies) **Tools used:** -- `go fmt`: Code formatting + +- `gofumpt`: Stricter Go code formatting (fallback to `go fmt`) - `go vet`: Static analysis +- `golangci-lint`: Comprehensive linting (detects unused interfaces, code smells, etc.) + +**Line Length Management:** + +The project enforces a 120-character line length limit via the `lll` linter. Note that Go +formatters (`gofumpt`/`go fmt`) do not automatically wrap long lines - this is by design +in the Go community. Manual line breaking is required for lines exceeding the limit. + +### `lint-markdown.sh` + +Markdown-specific linting and formatting script. + +**Usage:** + +```bash +./scripts/lint-markdown.sh [check|fix|ci] +``` + +**Modes:** + +- `check` (default): Check markdown quality without making changes +- `fix`: Auto-fix formatting issues +- `ci`: Strict checking for CI environments + +**Tools used:** + +- `markdownlint-cli`: Markdown linting (structure, style, consistency) +- `prettier`: Markdown formatting ### `lint-all.sh` + Comprehensive script that runs both Go and Python linting. **Usage:** + ```bash ./scripts/lint-all.sh [check|fix|ci] ``` **Features:** + - Runs Go linting first, then Python (if test suite exists) - Provides unified exit codes and summary - Color-coded output with status indicators +### `update-readme-help.py` + +Automatically updates README.md with current plugin help text. + +**Usage:** + +```bash +./scripts/update-readme-help.py +``` + +**Features:** + +- Extracts help text using `cf java help` +- Updates help section in README.md +- Stages changes for git commit +- Integrated into pre-commit hooks + +**Requirements:** CF CLI and CF Java plugin must be installed. + +## Tool Requirements + +### Go Linting Tools + +- **golangci-lint**: Install with `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest` +- **go**: Go compiler and tools (comes with Go installation) + +### Python Linting Tools + +- **flake8**: Install with `pip install flake8` +- **black**: Install with `pip install black` +- **isort**: Install with `pip install isort` + +### Markdown Linting Tools + +- **markdownlint-cli**: Install with `npm install -g markdownlint-cli` + +### Installation + +To install all linting tools at once, run: + +```bash +./setup-dev-env.sh +``` + +This will install all required linters and development tools. + ## Integration Points ### Pre-commit Hooks + - Uses `lint-go.sh check` for Go code - Uses `lint-python.sh fix` for Python code (auto-fixes issues) +- Uses `update-readme-help.py` to keep README help text current ### GitHub Actions CI + - **Build & Snapshot**: Uses `ci` mode for strict checking - **PR Validation**: Uses `ci` mode for comprehensive validation - **Release**: Uses `check` and `test` modes ### Development Workflow + - **Local development**: Use `check` mode for quick validation - **Before commit**: Use `fix` mode to auto-resolve formatting issues - **CI/CD**: Uses `ci` mode for strict validation -## Benefits - -1. **No Duplication**: Eliminates repeated linting commands across files -2. **Consistency**: Same linting rules applied everywhere -3. **Maintainability**: Single place to update linting configurations -4. **Flexibility**: Different modes for different use cases -5. **Error Handling**: Proper exit codes and error messages -6. **Auto-fixing**: Reduces manual intervention for formatting issues - ## Configuration All linting tools are configured via: + +- `.golangci.yml`: golangci-lint configuration (enables all linters except gochecknoglobals) - `test/pyproject.toml`: Python tool configurations - `test/requirements.txt`: Python tool dependencies - Project-level files: Go module and dependencies diff --git a/scripts/lint-all.sh b/scripts/lint-all.sh index fd3573e..99b2789 100755 --- a/scripts/lint-all.sh +++ b/scripts/lint-all.sh @@ -69,6 +69,15 @@ else print_warning "Python test suite not found - skipping Python linting" fi +# Run Markdown linting +print_header "Markdown Code Quality" +if "$SCRIPT_DIR/lint-markdown.sh" "$MODE"; then + print_status "Markdown linting passed" +else + print_error "Markdown linting failed" + OVERALL_SUCCESS=false +fi + # Final summary print_header "Summary" if [ "$OVERALL_SUCCESS" = true ]; then diff --git a/scripts/lint-go.sh b/scripts/lint-go.sh index 50ecf8e..31cb5ec 100755 --- a/scripts/lint-go.sh +++ b/scripts/lint-go.sh @@ -46,12 +46,28 @@ case "$MODE" in "check") print_info "Running Go code quality checks..." - echo "πŸ” Running go fmt..." - if ! go fmt .; then - print_error "Go formatting issues found. Run 'go fmt .' to fix." - exit 1 + echo "πŸ” Running gofumpt..." + if command -v gofumpt >/dev/null 2>&1; then + # Get only Git-tracked Go files + GO_FILES=$(git ls-files '*.go') + if [ -n "$GO_FILES" ]; then + if ! echo "$GO_FILES" | xargs gofumpt -l -w; then + print_error "Go formatting issues found with gofumpt" + exit 1 + fi + print_status "gofumpt formatting check passed on Git-tracked files" + else + print_warning "No Git-tracked Go files found" + fi + else + echo "πŸ” Running go fmt..." + if ! go fmt ./...; then + print_error "Go formatting issues found. Run 'go fmt ./...' to fix." + exit 1 + fi + print_status "Go formatting check passed" + print_info "For better formatting, install gofumpt: go install mvdan.cc/gofumpt@latest" fi - print_status "Go formatting check passed" echo "πŸ” Running go vet..." if ! go vet .; then @@ -60,6 +76,17 @@ case "$MODE" in fi print_status "Go vet check passed" + echo "πŸ” Running golangci-lint..." + if command -v golangci-lint >/dev/null 2>&1; then + if (! golangci-lint run --timeout=3m *.go || ! golangci-lint run utils/*.go); then + print_error "golangci-lint issues found" + exit 1 + fi + else + print_warning "golangci-lint not found, skipping comprehensive linting" + print_info "Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" + fi + print_status "All Go linting checks passed!" ;; @@ -69,10 +96,18 @@ case "$MODE" in echo "πŸ” Installing dependencies..." go mod tidy -e || true - echo "πŸ” Running go fmt..." - if ! go fmt .; then - print_error "Go formatting issues found" - exit 1 + echo "πŸ” Running gofumpt..." + if command -v gofumpt >/dev/null 2>&1; then + if ! gofumpt -l -w *.go cmd/ utils/; then + print_error "Go formatting issues found with gofumpt" + exit 1 + fi + else + echo "πŸ” Running go fmt..." + if ! go fmt ./...; then + print_error "Go formatting issues found" + exit 1 + fi fi echo "πŸ” Running go vet..." diff --git a/scripts/lint-markdown.sh b/scripts/lint-markdown.sh new file mode 100755 index 0000000..3b9885d --- /dev/null +++ b/scripts/lint-markdown.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +MODE="${1:-check}" + +cd "$PROJECT_ROOT" + +# Install markdownlint-cli if not available +if ! command -v markdownlint &> /dev/null; then + echo "Installing markdownlint-cli..." + npm install -g markdownlint-cli +fi + +# Install prettier if not available +if ! command -v npx &> /dev/null; then + echo "Error: npx is required for prettier. Please install Node.js" + exit 1 +fi + +# Get only git-tracked markdown files +MARKDOWN_FILES=$(git ls-files "*.md" | tr '\n' ' ') + +if [ -z "$MARKDOWN_FILES" ]; then + echo "No markdown files found in git repository" + exit 0 +fi + +case "$MODE" in + "check" | "ci") + echo "πŸ” Checking Markdown files..." + echo "Files to check: $MARKDOWN_FILES" + markdownlint $MARKDOWN_FILES + echo "βœ… Markdown files are properly formatted" + ;; + "fix") + echo "πŸ”§ Fixing Markdown files..." + echo "Files to fix: $MARKDOWN_FILES" + + # Function to preserve
 sections during prettier formatting
+        format_markdown_with_pre_protection() {
+            local file="$1"
+            local temp_file="${file}.tmp"
+            local pre_markers_file="${file}.pre_markers"
+            
+            # Create a unique marker for each 
 block
+            python3 "$temp_file" "$pre_markers_file" << EOF
+import sys
+import re
+import json
+
+file_path = "$file"
+temp_file = sys.argv[1] if len(sys.argv) > 1 else "${file}.tmp"
+markers_file = sys.argv[2] if len(sys.argv) > 2 else "${file}.pre_markers"
+
+with open(file_path, 'r') as f:
+    content = f.read()
+
+# Find all 
...
blocks and replace with markers +pre_blocks = {} +counter = 0 + +def replace_pre(match): + global counter + marker = f"__PRETTIER_PRE_BLOCK_{counter}__" + pre_blocks[marker] = match.group(0) + counter += 1 + return marker + +# Replace
 blocks with markers
+modified_content = re.sub(r'
.*?
', replace_pre, content, flags=re.DOTALL) + +# Write modified content to temp file +with open(temp_file, 'w') as f: + f.write(modified_content) + +# Save markers mapping +with open(markers_file, 'w') as f: + json.dump(pre_blocks, f) +EOF + + # Run prettier on the modified file + npx prettier --parser markdown --prose-wrap always --print-width 120 --write "$temp_file" + + # Restore
 blocks
+            python3 "$temp_file" "$pre_markers_file" "$file" << EOF
+import sys
+import json
+
+temp_file = sys.argv[1] if len(sys.argv) > 1 else "${file}.tmp"
+markers_file = sys.argv[2] if len(sys.argv) > 2 else "${file}.pre_markers"
+original_file = sys.argv[3] if len(sys.argv) > 3 else "$file"
+
+with open(temp_file, 'r') as f:
+    content = f.read()
+
+with open(markers_file, 'r') as f:
+    pre_blocks = json.load(f)
+
+# Restore 
 blocks
+for marker, pre_block in pre_blocks.items():
+    content = content.replace(marker, pre_block)
+
+# Write back to original file
+with open(original_file, 'w') as f:
+    f.write(content)
+EOF
+            
+            # Clean up temp files
+            rm -f "$temp_file" "$pre_markers_file"
+        }
+        
+        # Format each markdown file with 
 protection
+        for file in $MARKDOWN_FILES; do
+            echo "  Formatting $file with prettier (preserving 
 sections)..."
+            format_markdown_with_pre_protection "$file"
+        done
+        
+        # Then run markdownlint to fix any remaining issues
+        echo "Running markdownlint --fix..."
+        markdownlint $MARKDOWN_FILES --fix
+        echo "βœ… Markdown files have been fixed"
+        ;;
+    *)
+        echo "Usage: $0 [check|fix|ci]"
+        exit 1
+        ;;
+esac
diff --git a/scripts/update-readme-help.py b/scripts/update-readme-help.py
new file mode 100755
index 0000000..30de1a1
--- /dev/null
+++ b/scripts/update-readme-help.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+"""
+Script to update README.md with current plugin help text.
+Usage: ./scripts/update-readme-help.py
+"""
+
+import subprocess
+import sys
+import os
+import tempfile
+import shutil
+from pathlib import Path
+
+
+class Colors:
+    """ANSI color codes for terminal output."""
+    RED = '\033[0;31m'
+    GREEN = '\033[0;32m'
+    YELLOW = '\033[1;33m'
+    NC = '\033[0m'  # No Color
+
+
+def print_status(message: str) -> None:
+    """Print a success message with green checkmark."""
+    print(f"{Colors.GREEN}βœ…{Colors.NC} {message}")
+
+
+def print_warning(message: str) -> None:
+    """Print a warning message with yellow warning sign."""
+    print(f"{Colors.YELLOW}⚠️{Colors.NC} {message}")
+
+
+def print_error(message: str) -> None:
+    """Print an error message with red X."""
+    print(f"{Colors.RED}❌{Colors.NC} {message}")
+
+
+def check_repository_root() -> None:
+    """Check if we're in the correct repository root directory."""
+    if not Path("cf_cli_java_plugin.go").exists():
+        print_error("Not in CF Java Plugin root directory")
+        print("Please run this script from the repository root")
+        sys.exit(1)
+    
+    if not Path("README.md").exists():
+        print_error("README.md not found")
+        sys.exit(1)
+
+
+def ensure_plugin_installed() -> None:
+    """Ensure the java plugin is installed in cf CLI."""
+    print("πŸ” Checking if cf java plugin is installed...")
+    
+    try:
+        # Check if the java plugin is installed
+        result = subprocess.run(
+            ["cf", "plugins"],
+            capture_output=True,
+            text=True,
+            check=True
+        )
+        
+        if "java" not in result.stdout:
+            print_error("CF Java plugin is not installed")
+            print("Please install the plugin first with:")
+            print("  cf install-plugin ")
+            sys.exit(1)
+        else:
+            print_status("CF Java plugin is installed")
+            
+    except subprocess.CalledProcessError as e:
+        print_error("Failed to check cf plugins")
+        print("Make sure the cf CLI is installed and configured")
+        print(f"Error: {e}")
+        sys.exit(1)
+
+
+def get_plugin_help() -> str:
+    """Extract help text from the plugin, skipping first 3 lines."""
+    print("πŸ“ Extracting help text from plugin...")
+    
+    try:
+        # Use 'cf java help' to get the help text
+        result = subprocess.run(
+            ["cf", "java", "help"],
+            capture_output=True,
+            text=True
+        )
+        
+        # Combine stdout and stderr since plugin might write to stderr
+        output = result.stdout + result.stderr
+        
+        # Skip first 3 lines as requested
+        lines = output.splitlines()
+        help_text = '\n'.join(lines[3:]) if len(lines) > 3 else output
+        
+        # Validate that we got reasonable help text
+        if not help_text.strip():
+            print_error("Failed to get help text from plugin")
+            sys.exit(1)
+        
+        # Check if it looks like actual help text (should contain USAGE or similar)
+        if "USAGE:" not in help_text and "Commands:" not in help_text:
+            print_warning("Help text doesn't look like expected format")
+            print(f"Got: {help_text[:100]}...")
+            
+        return help_text
+        
+    except (subprocess.CalledProcessError, FileNotFoundError) as e:
+        print_error(f"Failed to run 'cf java help': {e}")
+        print("Make sure the cf CLI is installed and the java plugin is installed")
+        sys.exit(1)
+
+
+def update_readme_help(help_text: str) -> bool:
+    """Update README.md with the new help text."""
+    readme_path = Path("README.md")
+    
+    print("πŸ”„ Updating README.md...")
+    
+    with open(readme_path, 'r', encoding='utf-8') as f:
+        lines = f.readlines()
+    
+    # Create temporary file
+    with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as temp_file:
+        temp_path = temp_file.name
+        
+        in_help_section = False
+        help_updated = False
+        found_pre = False
+        
+        for i, line in enumerate(lines):
+            # Look for 
 tag after a line mentioning cf java --help
+            if not in_help_section and '
' in line.strip():
+                # Check if previous few lines mention "cf java"
+                context_start = max(0, i - 3)
+                context = ''.join(lines[context_start:i])
+                if 'cf java' in context:
+                    found_pre = True
+                    in_help_section = True
+                    temp_file.write(line)
+                    temp_file.write(help_text + '\n')
+                    help_updated = True
+                else:
+                    temp_file.write(line)
+            # Look for the end of help section
+            elif in_help_section and '
' in line: + in_help_section = False + temp_file.write(line) + # Write lines that are not in the help section + elif not in_help_section: + temp_file.write(line) + # Skip lines that are inside the help section (old help text) + + if help_updated: + # Replace the original file + shutil.move(temp_path, readme_path) + print_status("README.md help text updated successfully") + + # Stage changes if in git repository + try: + subprocess.run( + ["git", "rev-parse", "--git-dir"], + capture_output=True, + check=True + ) + subprocess.run(["git", "add", "README.md"], check=True) + print_status("Changes staged for commit") + except subprocess.CalledProcessError: + # Not in a git repository or git command failed + pass + + return True + else: + # Clean up temp file + os.unlink(temp_path) + print_warning("Help section not found in README.md") + print("Expected to find a
 tag following a mention of 'cf java'")
+        return False
+
+
+def main() -> None:
+    """Main function to orchestrate the README update process."""
+    try:
+        check_repository_root()
+        ensure_plugin_installed()
+        help_text = get_plugin_help()
+        
+        if update_readme_help(help_text):
+            print("\nπŸŽ‰ README help text update complete!")
+        else:
+            sys.exit(1)
+            
+    except KeyboardInterrupt:
+        print_error("\nOperation cancelled by user")
+        sys.exit(1)
+    except Exception as e:
+        print_error(f"Unexpected error: {e}")
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/setup-dev-env.sh b/setup-dev-env.sh
index 1b5fed2..a55d4c0 100755
--- a/setup-dev-env.sh
+++ b/setup-dev-env.sh
@@ -40,6 +40,43 @@ echo "πŸ“¦ Installing Go dependencies..."
 go mod tidy
 echo "βœ… Go dependencies installed"
 
+# Install linting tools
+echo "πŸ” Installing linting tools..."
+
+# Install golangci-lint
+if ! command -v golangci-lint &> /dev/null; then
+    echo "Installing golangci-lint..."
+    go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
+    echo "βœ… golangci-lint installed"
+else
+    echo "βœ… golangci-lint already installed"
+fi
+
+# Install gofumpt for stricter formatting
+if ! command -v gofumpt &> /dev/null; then
+    echo "Installing gofumpt..."
+    go install mvdan.cc/gofumpt@latest
+    echo "βœ… gofumpt installed"
+else
+    echo "βœ… gofumpt already installed"
+fi
+
+# Install markdownlint (if npm is available)
+if command -v npm &> /dev/null; then
+    if ! command -v markdownlint &> /dev/null; then
+        echo "Installing markdownlint-cli..."
+        npm install -g markdownlint-cli
+        echo "βœ… markdownlint-cli installed"
+    else
+        echo "βœ… markdownlint-cli already installed"
+    fi
+else
+    echo "⚠️  npm not found - skipping markdownlint installation"
+    echo "   Install Node.js and npm to enable markdown linting"
+fi
+
+echo "βœ… Linting tools setup complete"
+
 # Setup Python environment (if test suite exists)
 if [ -f "test/requirements.txt" ]; then
     echo "🐍 Setting up Python test environment..."
@@ -89,6 +126,7 @@ echo ""
 echo "πŸ“‹ What's configured:"
 echo "  βœ… Pre-commit hooks (run on every git commit)"
 echo "  βœ… Go development environment"
+echo "  βœ… Linting tools (golangci-lint, markdownlint)"
 if [ -f "test/requirements.txt" ]; then
     echo "  βœ… Python test suite environment"
 else
diff --git a/test/.gitignore b/test/.gitignore
index bcdd819..1e248fa 100644
--- a/test/.gitignore
+++ b/test/.gitignore
@@ -65,3 +65,6 @@ coverage.xml
 
 # pytest
 test_report.html
+
+# go
+pkg
\ No newline at end of file
diff --git a/test/README.md b/test/README.md
index cb749d3..6caf964 100644
--- a/test/README.md
+++ b/test/README.md
@@ -35,7 +35,6 @@ The following error might occur when connected to the SAP internal network:
 
 Just connect directly to the internet without the VPN.
 
-
 ## State of Testing
 
 - `heap-dump` is thoroughly tested, including all flags, so that less has to be tested for the other commands.
@@ -75,7 +74,7 @@ Example output:
 - **`test_asprof.py`** - Async-profiler tests (SapMachine only)
 - **`test_cf_java_plugin.py`** - Integration and workflow tests
 - **`test_disk_full.py`** - Tests for disk full scenarios (e.g., heap dump with no space left)
-- ** `test_jre21.py`** - JRE21/non-SapMachine21-specific tests (e.g., heap dump, thread dump, etc.)
+- **`test_jre21.py`** - JRE21/non-SapMachine21-specific tests (e.g., heap dump, thread dump, etc.)
 
 ## Test Selection & Execution
 
@@ -177,7 +176,6 @@ class TestExample(TestBase):
             .should_create_file(f"{app}-heapdump-*.hprof")
 ```
 
-
 ## Tips
 
 1. **Start with `./test.py list`** to see all available tests
diff --git a/test/test_asprof.py b/test/test_asprof.py
index ea9a023..429204e 100644
--- a/test/test_asprof.py
+++ b/test/test_asprof.py
@@ -25,7 +25,7 @@ def test_status_no_profiling(self, t, app):
     def test_start_provides_stop_instruction(self, t, app):
         """Test that asprof-start provides clear stop instructions."""
         t.run(f"asprof-start-cpu {app}").should_succeed().should_contain(f"Use 'cf java asprof-stop {app}'")
-
+        time.sleep(1)  # Allow some time for the command to register
         # Clean up
         t.run(f"asprof-stop {app} --no-download").should_succeed().should_create_remote_file(
             "*.jfr"
diff --git a/test/test_jfr.py b/test/test_jfr.py
index f62c036..02bbfa7 100644
--- a/test/test_jfr.py
+++ b/test/test_jfr.py
@@ -53,9 +53,9 @@ def test_jfr_dump(self, t, app):
         t.run(f"jfr-status {app}").should_succeed().should_contain("Recording ").no_files()
 
         # Clean up
-        t.run(f"jfr-stop {app} --no-download").should_succeed().should_create_file(
+        t.run(f"jfr-stop {app} --no-download").should_succeed().should_create_remote_file(
             "*.jfr"
-        ).should_create_no_remote_files()
+        ).should_create_no_files()
 
     @test()
     def test_concurrent_recordings_prevention(self, t, app):
diff --git a/utils/cf_java_plugin_util.go b/utils/cf_java_plugin_util.go
deleted file mode 100644
index 50a6bc1..0000000
--- a/utils/cf_java_plugin_util.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package utils
-
-import (
-	"github.com/lithammer/fuzzysearch/fuzzy"
-	"sort"
-	"strings"
-)
-
-// FuzzySearch returns up to `max` words from `words` that are closest in
-// Levenshtein distance to `needle`.
-func FuzzySearch(needle string, words []string, max int) []string {
-	type match struct {
-		distance int
-		word     string
-	}
-
-	matches := make([]match, 0, len(words))
-	for _, w := range words {
-		matches = append(matches, match{
-			distance: fuzzy.LevenshteinDistance(needle, w),
-			word:     w,
-		})
-	}
-
-	sort.Slice(matches, func(i, j int) bool {
-		return matches[i].distance < matches[j].distance
-	})
-
-	if max > len(matches) {
-		max = len(matches)
-	}
-
-	results := make([]string, 0, max)
-	for i := range max {
-		results = append(results, matches[i].word)
-	}
-
-	return results
-}
-
-// "x, y, or z"
-func JoinWithOr(a []string) string {
-	if len(a) == 0 {
-		return ""
-	}
-	if len(a) == 1 {
-		return a[0]
-	}
-	return strings.Join(a[:len(a)-1], ", ") + ", or " + a[len(a)-1]
-}
diff --git a/utils/cfutils.go b/utils/cfutils.go
index 805e6fd..ed77a3a 100644
--- a/utils/cfutils.go
+++ b/utils/cfutils.go
@@ -1,15 +1,30 @@
+// Package utils provides utility functions for Cloud Foundry CLI Java plugin operations.
 package utils
 
 import (
+	"crypto/rand"
+	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"os"
 	"os/exec"
 	"slices"
+	"sort"
 	"strings"
+
+	"github.com/lithammer/fuzzysearch/fuzzy"
 )
 
+// Version represents a semantic version with major, minor, and build numbers.
+type Version struct {
+	Major int
+	Minor int
+	Build int
+}
+
+// CFAppEnv represents the environment configuration for a Cloud Foundry application,
+// including environment variables, staging configuration, and system services.
 type CFAppEnv struct {
 	EnvironmentVariables struct {
 		JbpConfigSpringAutoReconfiguration string `json:"JBP_CONFIG_SPRING_AUTO_RECONFIGURATION"`
@@ -62,13 +77,34 @@ type CFAppEnv struct {
 	} `json:"application_env_json"`
 }
 
+// GenerateUUID generates a new RFC 4122 Version 4 UUID using Go's built-in crypto/rand.
+func GenerateUUID() string {
+	// Generate 16 random bytes
+	bytes := make([]byte, 16)
+	if _, err := rand.Read(bytes); err != nil {
+		panic(err) // This should never happen with crypto/rand
+	}
+
+	// Set version (4) and variant bits according to RFC 4122
+	bytes[6] = (bytes[6] & 0x0f) | 0x40 // Version 4
+	bytes[8] = (bytes[8] & 0x3f) | 0x80 // Variant bits
+
+	// Format as UUID string: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
+	return fmt.Sprintf("%s-%s-%s-%s-%s",
+		hex.EncodeToString(bytes[0:4]),
+		hex.EncodeToString(bytes[4:6]),
+		hex.EncodeToString(bytes[6:8]),
+		hex.EncodeToString(bytes[8:10]),
+		hex.EncodeToString(bytes[10:16]))
+}
+
 func readAppEnv(app string) ([]byte, error) {
 	guid, err := exec.Command("cf", "app", app, "--guid").Output()
 	if err != nil {
 		return nil, err
 	}
 
-	env, err := exec.Command("cf", "curl", fmt.Sprintf("/v3/apps/%s/env", strings.Trim(string(guid[:]), "\n"))).Output()
+	env, err := exec.Command("cf", "curl", fmt.Sprintf("/v3/apps/%s/env", strings.Trim(string(guid), "\n"))).Output()
 	if err != nil {
 		return nil, err
 	}
@@ -81,20 +117,22 @@ func checkUserPathAvailability(app string, path string) (bool, error) {
 		return false, err
 	}
 
-	if strings.Contains(string(output[:]), "exists and read-writeable") {
+	if strings.Contains(string(output), "exists and read-writeable") {
 		return true, nil
 	}
 
 	return false, nil
 }
 
+// FindReasonForAccessError determines the reason why accessing a Cloud Foundry app failed,
+// providing helpful diagnostic information and suggestions.
 func FindReasonForAccessError(app string) string {
 	out, err := exec.Command("cf", "apps").Output()
 	if err != nil {
 		return "cf is not logged in, please login and try again"
 	}
-	// find all app names
-	lines := strings.Split(string(out[:]), "\n")
+	// Find all app names
+	lines := strings.Split(string(out), "\n")
 	appNames := []string{}
 	foundHeader := false
 	for _, line := range lines {
@@ -114,6 +152,8 @@ func FindReasonForAccessError(app string) string {
 	return "Could not find " + app + ". Did you mean " + matches[0] + "?"
 }
 
+// CheckRequiredTools verifies that the necessary tools and permissions are available
+// for the specified Cloud Foundry application, including SSH access.
 func CheckRequiredTools(app string) (bool, error) {
 	guid, err := exec.Command("cf", "app", app, "--guid").Output()
 	if err != nil {
@@ -124,7 +164,7 @@ func CheckRequiredTools(app string) (bool, error) {
 		return false, err
 	}
 	var result map[string]any
-	if err := json.Unmarshal([]byte(output), &result); err != nil {
+	if err := json.Unmarshal(output, &result); err != nil {
 		return false, err
 	}
 
@@ -135,6 +175,8 @@ func CheckRequiredTools(app string) (bool, error) {
 	return true, nil
 }
 
+// GetAvailablePath determines the best available path for operations on the Cloud Foundry app,
+// preferring user-specified paths and falling back to volume mounts or /tmp.
 func GetAvailablePath(data string, userpath string) (string, error) {
 	if len(userpath) > 0 {
 		valid, _ := checkUserPathAvailability(data, userpath)
@@ -147,7 +189,7 @@ func GetAvailablePath(data string, userpath string) (string, error) {
 
 	env, err := readAppEnv(data)
 	if err != nil {
-		return "/tmp", nil
+		return "/tmp", err
 	}
 
 	var cfAppEnv CFAppEnv
@@ -166,12 +208,18 @@ func GetAvailablePath(data string, userpath string) (string, error) {
 	return "/tmp", nil
 }
 
+// CopyOverCat copies a remote file to a local destination using the cf ssh command and cat.
 func CopyOverCat(args []string, src string, dest string) error {
-	f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
+	f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
 	if err != nil {
 		return errors.New("Error creating local file at  " + dest + ". Please check that you are allowed to create files at the given local path.")
 	}
-	defer f.Close()
+	defer func() {
+		if closeErr := f.Close(); closeErr != nil {
+			// Log the error, but don't override the main function's error
+			fmt.Fprintf(os.Stderr, "Warning: failed to close file %s: %v\n", dest, closeErr)
+		}
+	}()
 
 	args = append(args, "cat "+src)
 	cat := exec.Command("cf", args...)
@@ -180,56 +228,69 @@ func CopyOverCat(args []string, src string, dest string) error {
 
 	err = cat.Start()
 	if err != nil {
-		return errors.New("error occured during copying dump file: " + src + ", please try again.")
+		return errors.New("error occurred during copying dump file: " + src + ", please try again.")
 	}
 
 	err = cat.Wait()
 	if err != nil {
-		return errors.New("error occured while waiting for the copying complete")
+		return errors.New("error occurred while waiting for the copying complete")
 	}
 
 	return nil
 }
 
+// DeleteRemoteFile removes a file from the remote Cloud Foundry application container.
 func DeleteRemoteFile(args []string, path string) error {
 	args = append(args, "rm -fr "+path)
 	_, err := exec.Command("cf", args...).Output()
 	if err != nil {
-		return errors.New("error occured while removing dump file generated")
+		return errors.New("error occurred while removing dump file generated")
 	}
 
 	return nil
 }
 
+// FindHeapDumpFile locates heap dump files (*.hprof) in the specified path on the remote container.
 func FindHeapDumpFile(args []string, fullpath string, fspath string) (string, error) {
-	return FindFile(args, fullpath, fspath, "java_pid*.hprof")
+	return FindFile(args, fullpath, fspath, "*.hprof")
 }
 
+// FindJFRFile locates Java Flight Recorder files (*.jfr) in the specified path on the remote container.
 func FindJFRFile(args []string, fullpath string, fspath string) (string, error) {
 	return FindFile(args, fullpath, fspath, "*.jfr")
 }
 
+// FindFile searches for files matching the given pattern in the remote container,
+// returning the most recently modified file that matches.
 func FindFile(args []string, fullpath string, fspath string, pattern string) (string, error) {
 	cmd := " [ -f '" + fullpath + "' ] && echo '" + fullpath + "' ||  find " + fspath + " -name '" + pattern + "' -printf '%T@ %p\\0' | sort -zk 1nr | sed -z 's/^[^ ]* //' | tr '\\0' '\\n' | head -n 1 "
 
 	args = append(args, cmd)
 	output, err := exec.Command("cf", args...).Output()
 	if err != nil {
-		return "", errors.New("error while checking the generated file")
+		// Check for SSH authentication errors
+		errorStr := err.Error()
+		if strings.Contains(errorStr, "Error getting one time auth code") ||
+			strings.Contains(errorStr, "Error getting SSH code") ||
+			strings.Contains(errorStr, "Authentication failed") {
+			return "", errors.New("SSH authentication failed - this may be a Cloud Foundry platform issue. Error: " + errorStr)
+		}
+		return "", errors.New("error while checking the generated file: " + errorStr)
 	}
 
-	return strings.Trim(string(output[:]), "\n"), nil
+	return strings.Trim(string(output), "\n"), nil
 }
 
+// ListFiles retrieves a list of files in the specified directory on the remote container.
 func ListFiles(args []string, path string) ([]string, error) {
 	cmd := "ls " + path
 	args = append(args, cmd)
 	output, err := exec.Command("cf", args...).Output()
 	if err != nil {
-		return nil, errors.New("error occured while listing files: " + string(output[:]))
+		return nil, errors.New("error occurred while listing files: " + string(output))
 	}
-	files := strings.Split(strings.Trim(string(output[:]), "\n"), "\n")
-	// filter all empty strings
+	files := strings.Split(strings.Trim(string(output), "\n"), "\n")
+	// Filter all empty strings
 	j := 0
 	for _, s := range files {
 		if s != "" {
@@ -239,3 +300,107 @@ func ListFiles(args []string, path string) ([]string, error) {
 	}
 	return files[:j], nil
 }
+
+// FuzzySearch returns up to `maxResults` words from `words` that are closest in
+// Levenshtein distance to `needle`.
+func FuzzySearch(needle string, words []string, maxResults int) []string {
+	type match struct {
+		distance int
+		word     string
+	}
+
+	matches := make([]match, 0, len(words))
+	for _, w := range words {
+		matches = append(matches, match{
+			distance: fuzzy.LevenshteinDistance(needle, w),
+			word:     w,
+		})
+	}
+
+	sort.Slice(matches, func(i, j int) bool {
+		return matches[i].distance < matches[j].distance
+	})
+
+	if maxResults > len(matches) {
+		maxResults = len(matches)
+	}
+
+	results := make([]string, 0, maxResults)
+	for i := range maxResults {
+		results = append(results, matches[i].word)
+	}
+
+	return results
+}
+
+// JoinWithOr joins strings with commas and "or" for the last element: "x, y, or z".
+func JoinWithOr(a []string) string {
+	if len(a) == 0 {
+		return ""
+	}
+	if len(a) == 1 {
+		return a[0]
+	}
+	return strings.Join(a[:len(a)-1], ", ") + ", or " + a[len(a)-1]
+}
+
+// WrapTextWithPrefix wraps text to fit within maxWidth characters per line,
+// with the first line using the given prefix and subsequent lines indented to match the prefix length.
+func WrapTextWithPrefix(text, prefix string, maxWidth int, miscLineIndent int) string {
+	maxDescLength := maxWidth - len(prefix)
+
+	if len(text) <= maxDescLength {
+		return prefix + text
+	}
+
+	// Split text into multiple lines if too long.
+	words := strings.Fields(text)
+	var lines []string
+	var currentLine string
+
+	for _, word := range words {
+		testLine := currentLine
+		if testLine != "" {
+			testLine += " "
+		}
+		testLine += word
+
+		if len(testLine) <= maxDescLength {
+			currentLine = testLine
+		} else {
+			if currentLine != "" {
+				lines = append(lines, currentLine)
+			}
+			currentLine = word
+		}
+	}
+	if currentLine != "" {
+		lines = append(lines, currentLine)
+	}
+
+	// Join lines with proper indentation.
+	if len(lines) > 0 {
+		result := prefix + lines[0]
+		indent := strings.Repeat(" ", len(prefix))
+		for i := 1; i < len(lines); i++ {
+			result += "\n" + indent + strings.Repeat(" ", miscLineIndent) + lines[i]
+		}
+		return result
+	}
+
+	// Fallback if no lines were created.
+	return prefix + text
+}
+
+// ToSentenceCase returns the input string with the first character uppercased and the rest lowercased.
+// Handles Unicode and empty strings safely.
+func ToSentenceCase(input string) string {
+	if input == "" {
+		return input
+	}
+	runes := []rune(input)
+	if len(runes) == 1 {
+		return strings.ToUpper(string(runes[0]))
+	}
+	return strings.ToUpper(string(runes[0])) + strings.ToLower(string(runes[1:]))
+}