diff --git a/.github/workflows/build-and-snapshot.yml b/.github/workflows/build-and-snapshot.yml index 3c8059d..be0eb43 100644 --- a/.github/workflows/build-and-snapshot.yml +++ b/.github/workflows/build-and-snapshot.yml @@ -231,14 +231,13 @@ jobs: ```sh # on Mac arm64 cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-macos-arm64 - # on Windows x86 - cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-windows-amd64 - # on Linux x86 + # on Windows amd64 + cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-windows-amd64.exe + # on Linux amd64 cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/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`. diff --git a/.github/workflows/build.py b/.github/workflows/build.py index 74b8a06..5cf110b 100644 --- a/.github/workflows/build.py +++ b/.github/workflows/build.py @@ -1,5 +1,10 @@ import os import platform +import subprocess +import sys +import tarfile +import urllib.request +import zipfile os.makedirs('dist', exist_ok=True) @@ -19,6 +24,47 @@ arch = arch_map[platform.machine().lower()] print(f"Building for {os_name} {arch}") -import sys -rc = os.system(f"go build -o dist/cf-cli-java-plugin-{os_name}-{arch}") -sys.exit(rc >> 8 if os.name != 'nt' else rc) \ No newline at end of file +HPROF_BASE = "https://github.com/parttimenerd/hprof-analyzer/releases/download/nightly" + +# All platform binaries must exist before go build (go:embed requires them all). +hprof_targets = [ + ("hprof-analyzer-x86_64-unknown-linux-musl.tar.gz", "hprof-analyzer-x86_64-unknown-linux-musl/hprof-redact", "dist/hprof-redact-linux-amd64", False), + ("hprof-analyzer-aarch64-unknown-linux-musl.tar.gz", "hprof-analyzer-aarch64-unknown-linux-musl/hprof-redact", "dist/hprof-redact-linux-arm64", False), + ("hprof-analyzer-aarch64-apple-darwin.tar.gz", "hprof-analyzer-aarch64-apple-darwin/hprof-redact", "dist/hprof-redact-darwin-arm64", False), + ("hprof-analyzer-x86_64-pc-windows-msvc.zip", "hprof-analyzer-x86_64-pc-windows-msvc/hprof-redact.exe", "dist/hprof-redact-windows-amd64.exe", True), + ("hprof-analyzer-aarch64-pc-windows-msvc.zip", "hprof-analyzer-aarch64-pc-windows-msvc/hprof-redact.exe", "dist/hprof-redact-windows-arm64.exe", True), +] + +for archive_name, member, dest, is_zip in hprof_targets: + if os.path.exists(dest) and os.path.getsize(dest) > 0: + print(f" {dest} already present, skipping") + continue + url = f"{HPROF_BASE}/{archive_name}" + print(f" Downloading {archive_name} -> {dest}") + tmp = dest + ".tmp" + try: + urllib.request.urlretrieve(url, tmp) + if is_zip: + with zipfile.ZipFile(tmp) as zf: + data = zf.read(member) + else: + with tarfile.open(tmp, "r:gz") as tf: + data = tf.extractfile(member).read() + with open(dest, "wb") as f: + f.write(data) + os.chmod(dest, 0o755) + except Exception as e: + # windows-arm64 may not always have a release; create an empty placeholder + # so go:embed compiles (the plugin will report "not available" at runtime). + print(f" Warning: could not download {archive_name}: {e}; creating empty placeholder") + open(dest, "wb").close() + finally: + if os.path.exists(tmp): + os.remove(tmp) + +if "--deps-only" in sys.argv: + sys.exit(0) + +exe = ".exe" if os_name == "windows" else "" +rc = subprocess.call(f"go build -o dist/cf-cli-java-plugin-{os_name}-{arch}{exe}", shell=True) +sys.exit(rc) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index e39f333..1e96bde 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -10,8 +10,8 @@ permissions: contents: read jobs: - validate-pr: - name: Validate Pull Request + validate: + name: Lint & Test runs-on: ubuntu-latest steps: @@ -38,6 +38,9 @@ jobs: mkdir -p dist curl -fsSL -o dist/jstall-minimal.jar https://github.com/parttimenerd/jstall/releases/latest/download/jstall-minimal.jar + - name: Download hprof-redact binaries for go:embed + run: python3 .github/workflows/build.py --deps-only + - name: Install Go dependencies run: go mod tidy -e || true @@ -49,9 +52,6 @@ jobs: - name: Run govulncheck run: | go install golang.org/x/vuln/cmd/govulncheck@latest - # Run in JSON mode and emit GitHub Actions warning annotations for each finding. - # govulncheck outputs multi-line JSON objects (not line-delimited), so we use - # raw_decode to parse successive top-level objects from the output stream. govulncheck -json . 2>/dev/null | python3 -c " import sys, json decoder = json.JSONDecoder() @@ -87,15 +87,16 @@ jobs: - name: Lint Go code run: ./scripts/lint-go.sh ci + - name: Run Go tests + run: go test -v -race ./... + - name: Check Python test suite id: check-python run: | if [ -f "test/requirements.txt" ] && [ -f "test/setup.sh" ]; then echo "python_tests_exist=true" >> $GITHUB_OUTPUT - echo "βœ… Python test suite found" else echo "python_tests_exist=false" >> $GITHUB_OUTPUT - echo "⚠️ Python test suite not found - skipping Python validation" fi - name: Setup Python environment @@ -114,48 +115,86 @@ jobs: - 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' - # run: | - # cd test - # source venv/bin/activate - # echo "πŸ§ͺ Running Python tests..." - # if ! pytest -v --tb=short; then - # echo "❌ Python tests failed." - # exit 1 - # fi - # echo "βœ… Python tests passed!" - # env: - # CF_API: ${{ secrets.CF_API }} - # CF_USERNAME: ${{ secrets.CF_USERNAME }} - # CF_PASSWORD: ${{ secrets.CF_PASSWORD }} - # CF_ORG: ${{ secrets.CF_ORG }} - # CF_SPACE: ${{ secrets.CF_SPACE }} + build: + name: Build (${{ matrix.os }}) + needs: validate + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: ">=1.23.5" + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Download JStall minimal JAR for go:embed + shell: bash + run: | + mkdir -p dist + curl -fsSL -o dist/jstall-minimal.jar https://github.com/parttimenerd/jstall/releases/latest/download/jstall-minimal.jar + + - name: Download hprof-redact binaries for go:embed + shell: bash + run: python3 .github/workflows/build.py --deps-only + + - name: Install Go dependencies + run: go mod tidy -e || true + + - name: Run Go tests + shell: bash + run: go test -race ./... - name: Build plugin + shell: bash + run: python3 .github/workflows/build.py + + - name: Install CF CLI (Windows only) + if: runner.os == 'Windows' + shell: bash run: | - echo "πŸ”¨ Building plugin..." - if ! python3 .github/workflows/build.py; then - echo "❌ Build failed." + choco install cloudfoundry-cli -y --no-progress + cf version + + - name: Smoke-test jstall on Windows + if: runner.os == 'Windows' + shell: bash + run: | + plugin_bin=$(ls dist/cf-cli-java-plugin-windows-* | head -1) + # The binary must have a .exe extension; cf install-plugin rejects files without it. + if [[ "$plugin_bin" != *.exe ]]; then + echo "FAIL: Windows plugin binary missing .exe extension: $plugin_bin" exit 1 fi - echo "βœ… Build successful!" + cf install-plugin "$plugin_bin" -f - - name: Validation Summary - run: | - echo "" - echo "πŸŽ‰ Pull Request Validation Summary" - 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" - else - echo "⚠️ Python tests skipped (not found)" + # Run cf java status β€” not logged in so CF will respond with an auth + # error. That means jstall successfully invoked cf.exe via ProcessBuilder. + # Any Windows invocation bug (sh not found, unknown flag, etc.) would + # produce a different error and fail this check. + output=$(cf java status fake-app 2>&1 || true) + echo "Output: $output" + + if ! echo "$output" | grep -qiE "not logged in|login|token|authenticate"; then + echo "FAIL: expected CF auth error, got: $output" + exit 1 fi - echo "βœ… Plugin build" - echo "" - echo "πŸš€ Ready for merge!" + echo "OK: jstall reached CF as expected" + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: cf-cli-java-plugin-${{ matrix.os }} + path: | + dist/* + !dist/jstall-minimal.jar + !dist/hprof-redact-* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf6e408..3072d3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: run: python3 .github/workflows/build.py - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: binaries-${{ matrix.os }} path: dist/ @@ -130,7 +130,7 @@ jobs: git push origin "$VERSION" - name: Download all build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: artifacts/ diff --git a/.gitignore b/.gitignore index 83d268c..4bc518c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,13 +26,16 @@ _testmain.go # Tools counterfeiter -# Built binaries +# Build output directories build/ pkg/ # built project binary (go build) cf-java-plugin +cf-cli-java-plugin +# Local tool binaries (downloaded during development, e.g. gofumpt, gopls) +bin/ # Testing directory - sensitive config and test results test/test_config.yml test/*.hprof @@ -49,8 +52,55 @@ test/snapshots/ # Heap dump files *.hprof -# Build artifacts +# Embedded/downloaded build artifacts dist -# go -pkg \ No newline at end of file +# Local project binaries +requires + +# OS/editor/local tooling +.DS_Store +.claude/ +.playwright-mcp/ +.test_success_cache.json +*.log +*.tmp +*.bak +*~ + +# Python local caches / coverage +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ +venv/ + +# Local investigation / scratch artifacts +BUG*.md +FIX*.md +*_REPORT.md +*analysis*.py +append_*.py +discover*.py +investigate*.py +*_test_output.txt +*_results.txt +*.out +out.zip + +# Local runtime artifacts +sapmachine21-status/ +sapmachine21-status.zip +sapmachine21-heapdump-*.hprof.gz +sapmachine21-heapdump-*-redacted.hprof.gz + +# Local ad-hoc test helpers/artifacts +test/doc_bugs_finder.py +test/sapmachine21-status.zip +test/test_bugs.py +test_bugs.sh +test_edge_cases.py +test_fixes.py + +# Internal planning docs (superpowers skill artifacts) +docs/superpowers/ diff --git a/.tool.yaml b/.tool.yaml index 8aade3c..234c538 100644 --- a/.tool.yaml +++ b/.tool.yaml @@ -2,7 +2,8 @@ tag: ready github_url: https://github.com/SAP/cf-cli-java-plugin tagline: Cloud Foundry CLI plugin to troubleshoot Java apps running on CF without SSH. Trigger heap dumps, thread dumps, and async-profiler or JFR recordings from the cf command line, with results - streamed back to your machine. Also embeds jstall for full JVM inspection via `cf java jstall`. + streamed back to your machine. Heap dumps can be redacted during download, kept compressed, or + opened directly in hprof-analyzer. Also embeds jstall for full JVM inspection via `cf java jstall`. tagline_short: Trigger heap dumps, thread dumps, and profiles from the CF CLI β€” no SSH needed. when_to_use: - You run Java apps on Cloud Foundry and need heap dumps, thread dumps, or CPU profiles @@ -23,7 +24,8 @@ install: code: | # Pick the binary for your platform: cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-macos-arm64 - # linux-amd64 / linux-arm64 / windows-amd64 also available + # linux-amd64 / linux-arm64 / windows-amd64 / windows-arm64 also available + # macOS requires Apple Silicon; macOS Intel (darwin/amd64) is not supported - label: CF Community lang: bash code: | @@ -34,8 +36,19 @@ usage: lang: bash code: | cf java heap-dump my-app + cf java heap-dump my-app --redact --open + cf java heap-dump my-app --redact --compress cf java thread-dump my-app cf java jstall my-app +features: +- title: Stream heap dumps directly to your machine + body: Capture heap dumps and thread dumps from Cloud Foundry apps without manual SSH sessions. +- title: Redact sensitive heap-dump data during download + body: Use `--redact` or `--redact-complete` to zero sensitive primitive data while the dump is being streamed, so an unredacted heap dump is never written locally. +- title: Keep dumps compressed or open them immediately + body: Use `--compress` to save `.hprof.gz` files directly, or `--open` / `--open-url` to inspect the heap dump in hprof-analyzer right after download. +- title: Run bundled jstall diagnostics + body: Inspect deadlocks, hot threads, flame graphs, and more via `cf java jstall` without separately installing jstall. how_to: - title: My CF app is not responding β€” find what it is stuck on body: | @@ -80,10 +93,14 @@ how_to: Take a heap dump from the running (or restarted) instance and download it: ```bash cf java heap-dump $APP_NAME - # Downloads $APP_NAME-heapdump-.hprof to current directory ``` Analyse with hprof-analyzer for Leak Suspects and Top Consumers: ```bash + cf java heap-dump $APP_NAME --open + # Opens hprof-analyzer in the browser with the dump pre-loaded + ``` + Or download and open manually: + ```bash hprof-analyzer $APP_NAME-heapdump-*.hprof report.html # Open report.html β†’ "Leak Suspects" and "Top Consumers" tabs ``` @@ -97,8 +114,22 @@ how_to: cf java heap-dump $APP_NAME ``` Downloads `$APP_NAME-heapdump-.hprof` to your current directory. - Open it in VisualVM, Eclipse MAT, or IntelliJ's heap analyzer. - + Open it in VisualVM, Eclipse MAT, IntelliJ's heap analyzer, or directly in the browser: + ```bash + cf java heap-dump $APP_NAME --open + # Spins up a local server and opens hprof-analyzer in the browser automatically. + # On macOS with the Application Firewall enabled, click Allow when prompted. + # In the browsers, allow the page to access local services when prompted. + ``` + To remove sensitive data (passwords, tokens) before saving: + ```bash + cf java heap-dump $APP_NAME --redact # lean: zeros primitive arrays + cf java heap-dump $APP_NAME --redact-complete # complete: zeros all primitive values + ``` + To keep the local file compressed as .hprof.gz (transfer is already gzip-compressed on JDK 17+): + ```bash + cf java heap-dump $APP_NAME --compress # saves as .hprof.gz + ``` **Note:** requires jmap, which is not bundled by default in the CF Java Buildpack. Add a full JDK via `JBP_CONFIG_OPEN_JDK_JRE: '[jre: {version: 21.+}, jdk: {include: true}]'` to your app's environment if you see a "jmap not found" error. @@ -163,5 +194,62 @@ how_to: ```bash jstall --cf $APP_NAME status all ``` + +- title: Redact sensitive data from a heap dump + body: | + The plugin can zero out sensitive values (passwords, tokens, personal data) before + saving the dump locally, using the bundled hprof-redact tool: + ```bash + # Lean redaction β€” zeros primitive arrays (byte[], char[], etc.): + cf java heap-dump $APP_NAME --redact + + # Complete redaction β€” zeros all primitive arrays and individual primitive fields: + cf java heap-dump $APP_NAME --redact-complete + + # Redact and compress: + cf java heap-dump $APP_NAME --redact --compress + + # Redact and open in browser: + cf java heap-dump $APP_NAME --redact --open + ``` + The unredacted dump is never written to disk β€” redaction happens in-memory during download. + +- title: Capture a safer heap dump for sharing or browser analysis + body: | + If you need to inspect a heap dump locally or share it with others, combine redaction, + compression, and browser opening as needed: + ```bash + # Redact sensitive values and keep the dump compressed: + cf java heap-dump $APP_NAME --redact --compress + + # Redact and open immediately in hprof-analyzer: + cf java heap-dump $APP_NAME --redact --open + + # Use complete redaction for maximum privacy: + cf java heap-dump $APP_NAME --redact-complete --compress + ``` + Use `--redact-keep-on-error` only if you explicitly want to keep a partially written redacted + file when local redaction fails. + +- title: Open a heap dump in hprof-analyzer + body: | + After downloading, the plugin can spin up a temporary local server and open + [hprof-analyzer](https://parttimenerd.github.io/hprof-analyzer) in the browser automatically: + ```bash + cf java heap-dump $APP_NAME --open + ``` + The server binds to `127.0.0.1` (loopback only), serves the file exactly once under a + random token URL, then shuts down automatically after the browser fetches it. + + **macOS:** if the Application Firewall is enabled, click **Allow** when asked whether + `cf-cli-java-plugin` may accept incoming network connections. + + **Browsers:** click **Allow** when the browser asks for permission to access local services. + + To use a locally running hprof-analyzer instance instead of the hosted one: + ```bash + cf java heap-dump $APP_NAME --open-url http://localhost:8080 + ``` + note: Requires cf ssh to be enabled on the app (`cf enable-ssh my-app`, then restart). The heap-dump command additionally needs jmap β€” see the How To entry above if it is missing. diff --git a/CHANGELOG.md b/CHANGELOG.md index 20831d2..ab02ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,56 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added -- Bundle [jstall](https://github.com/parttimenerd/jstall) (jstall-minimal.jar) for one-shot JVM inspection via - `cf java jstall APP_NAME`. Requires Java 17+ locally. Supports all jstall subcommands via `jstall APP --args`. +- Bundle [jstall](https://github.com/parttimenerd/jstall) v0.8.2 (jstall-minimal.jar) for one-shot JVM + inspection via `cf java jstall APP_NAME`. Requires Java 17+ locally. Key subcommands: `status` (thread + analysis, metaspace, GC, compiler queue), `record-status` (repeated sampling), `flame-graph`, `heap-info`. + Supports all jstall subcommands via `--args`. + `vm-vitals` improvements in v0.8.2: robust parser covering all SapMachine 11–27 text and CSV variants; + **Trends** table showing first/last value and direction arrow per column; **Observations** section with + automatic signals (heap pressure, growing heap/metaspace, thread growth, CPU steal, swap/RSS growth); + fixed duplicate-column-name data loss (`heap-comm` and `meta-comm` previously both rendered as `comm`). +- `heap-dump --redact`: zeros primitive arrays (`byte[]`, `char[]`, etc.) in the downloaded dump before saving + (lean redaction mode), using the bundled [hprof-redact](https://github.com/parttimenerd/hprof-analyzer) binary. + Supported on Linux (amd64, arm64), macOS (Apple Silicon), and Windows (amd64, arm64). +- `heap-dump --redact-complete`: zeros all primitive arrays and individual primitive fields (complete redaction mode, + maximum privacy). Mutually exclusive with `--redact`. +- `heap-dump --compress`: saves the local file as `.hprof.gz` instead of decompressing it after transfer + (requires JDK 17+ on the container). Useful when you want to store or share the compressed dump directly. +- Transparent compressed transfer: on JDK 17+ containers, the plugin always uses `jmap gz=1` to compress + the dump during SSH transfer (faster on slow connections), then decompresses on the fly so the local file + is a plain `.hprof`. Use `--compress` to keep the file compressed locally. +- `heap-dump --open`: after downloading (and optionally redacting/compressing) the dump, spins up a temporary local + HTTP server and opens the [hprof-analyzer](https://parttimenerd.github.io/hprof-analyzer) web app in the default + browser with the dump pre-loaded. The server serves the file exactly once via a random token URL and shuts down + automatically after the browser fetches it. +- `heap-dump --open-url `: override the hprof-analyzer base URL (e.g. a locally running instance). Implies + `--open`. +- JRE-only container support: `heap-dump`, `thread-dump`, `vm-info`, `vm-version`, and `jcmd` now work on + containers without JDK tools (`jmap`, `jstack`, `jcmd`) by falling back to the HotSpot attach socket via + `nc -U`. Requires `netcat-openbsd` or `nmap-ncat` on the container. Supports JDK 9–25 on Linux and macOS. + The `jstall`-based commands (`status`, `record-status`, etc.) use the same attach-socket path and also work + on JRE-only containers. ### Changed -- Improved SSH error messages for better clarity and debugging -- Enhanced documentation and README with better clarity +- macOS plugin support now requires Apple Silicon. macOS Intel (`darwin/amd64`) is not supported. +- SSH errors now include actionable diagnostics: connection reset/refused errors suggest retrying, + "instance does not exist" errors explain why (app stopped/scaled down), and permission errors + indicate SSH is disabled for the space. + +### Fixed + +- Windows: `cf install-plugin ` now works β€” the Windows binary is released with the required `.exe` extension + (`cf-cli-java-plugin-windows-amd64.exe`). Previously, CF CLI rejected the downloaded temp file with + *"temp/...exe doesn't exist"*. +- Windows: `jstall`-based commands (e.g. `cf java status`) now work correctly β€” the JVM system property + `jdk.lang.Process.allowAmbiguousCommands=false` is set so embedded quotes in the remote shell payload are + properly escaped when CF CLI is invoked via Java's `ProcessBuilder`. +- Better error when app name and subcommand are accidentally swapped (e.g. `cf java my-app heap-dump` instead + of `cf java heap-dump my-app`): if the first argument matches a known app in the current space, the error now + suggests the corrected command. +- Bumped Go toolchain to 1.26.8 to resolve six standard-library vulnerabilities + (GO-2026-5856, GO-2026-5972, GO-2026-5039, GO-2026-5037, GO-2026-6089, GO-2026-6090). ## [4.0.2] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..659882a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,127 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a [Cloud Foundry CLI](https://github.com/cloudfoundry/cli) plugin written in Go that provides Java +diagnostics for CF-deployed applications. It exposes `cf java ` and communicates with the +JVM exclusively via `cf ssh`. Two external tools are embedded as binary blobs at build time: + +- **jstall** (`dist/jstall-minimal.jar`) β€” JVM inspection tool (deadlock detection, hot threads, flame + graphs). Requires Java 17+ locally. +- **hprof-redact** (`dist/hprof-redact-{platform}`) β€” strips sensitive data from heap dumps before writing + to disk. + +Both are extracted to `~/.cache/cf-java-plugin/` at runtime with SHA-256 change detection to avoid redundant extraction. + +## Build Commands + +Before the first build, download embedded binaries: + +```bash +make download-jstall # downloads dist/jstall-minimal.jar from GitHub releases +make download-hprof-redact # downloads hprof-redact binaries for all platforms +``` + +Then build: + +```bash +make compile # builds build/cf-cli-java-plugin for current platform +make compile-all # cross-compiles for Linux/macOS/Windows Γ— amd64/arm64 +make install # compile + cf install-plugin (requires cf CLI logged in) +``` + +To use a development jstall build instead of the released version: + +```bash +JSTALL_DEV=1 make compile # latest GitHub Actions artifact +JSTALL_DEV= make compile # specific GHA run +``` + +## Running Tests + +Go unit tests (no CF connection needed): + +```bash +go test ./... # all Go tests +go test -run TestFoo ./... # single test by name +``` + +Integration tests (require a live CF environment configured in `test/test_config.yml`): + +```bash +cd test && ./setup.sh # one-time venv setup +cd test && ./test.py all # run all Python integration tests +cd test && ./test.py --start-with TestClass::test_method all # resume from a specific test +``` + +## Linting + +```bash +./scripts/lint-all.sh check # check Go + Python + Markdown +./scripts/lint-all.sh fix # auto-fix formatting issues +./scripts/lint-all.sh ci # same as check, used in CI +``` + +Individual linters: + +```bash +./scripts/lint-go.sh check|fix +./scripts/lint-python.sh check|fix +./scripts/lint-markdown.sh check|fix +``` + +Run `./setup-dev-env.sh` once to install pre-commit hooks that auto-run formatting on commit. + +## Architecture + +### Code Layout + +| File | Role | +| --- | --- | +| `cf_cli_java_plugin.go` | Core plugin: `Run`/`GetMetadata` entry points, `Command` struct definitions, `execute()` dispatch, `Options`/flag parsing, SSH error wrapping | +| `jstall.go` | `jstall`/`status`/`record-status` commands: Java 17+ discovery, JAR extraction, `buildJstallArgs`, `executeJstall` | +| `redact.go` | Heap dump redaction: embedded binary extraction, `pipeHeapDumpThroughRedact` pipeline | +| `open.go` | `--open`/`--open-url`: one-shot local HTTP server (`serveFileOnce`), `buildOpenURL`, `openBrowser` | +| `utils/cfutils.go` | CF SSH helpers: `CopyOverCat`, `StreamOverCat`, `CopyOverCatGunzip`, `ProbeRemoteFileGzip`, `GetAvailablePath`, `FindReasonForAccessError`, fuzzy app-name search | +| `cmd/cmd.go` | `CommandExecutor` interface used to inject mock CF CLI calls in tests | + +### Command Execution Flow + +1. `Run()` β†’ `DoRun()` β†’ `execute()` +2. `execute()` parses flags into `Options`, resolves command name against the `commands []Command` slice, + then dispatches based on `Command` fields. +3. Remote shell snippets are embedded as multi-line strings in each `Command.SSHCommand`. Variables + (`@FILE_NAME`, `@FSPATH`, `@APP_NAME`, `@ARGS`, `@STATIC_FILE_NAME`) are expanded by + `replaceVariables()` before execution. +4. File transfer from container β†’ local machine goes over `cf ssh` via `cat` piped to stdout (see + `utils.CopyOverCat` / `StreamOverCat`). There is no distinct stderr from `cf ssh`; both streams are + merged. +5. For heap dumps: the plugin auto-detects whether the remote JDK supports `gz=1` (JDK 17+) by inspecting + `jmap -h` output, enabling transparent compressed transfer. `--compress` keeps the local file as + `.hprof.gz`; otherwise it decompresses on the fly via `CopyOverCatGunzip`. + +### Embedded Binaries + +`redact.go` and `jstall.go` use `//go:embed dist/...` directives β€” those `dist/` files **must exist +before `go build`**. The `compile` Make target lists them as prerequisites. If they are missing, the build +fails with "no matching files found". + +### `Command` Struct Fields + +The `commands` slice in `cf_cli_java_plugin.go` is the sole source of truth for available subcommands. Key fields: + +- `GenerateFiles` β€” command creates a file on the container that must be downloaded +- `GenerateArbitraryFiles` / `GenerateArbitraryFilesFolderName` β€” command may create files in a named + subdirectory (e.g. `jcmd`); all files in that folder are downloaded +- `RequiredTools` β€” tools (`jcmd`, `asprof`) that must be resolved on the container before running +- `IsLocal` β€” command runs locally (jstall, status, record-status) rather than via SSH +- `AcceptsTrailingArgs` β€” command takes positional arguments after the app name (e.g. `record-status APP outfile.zip`) +- `OnlyOnRecentSapMachine` β€” marks SapMachine-specific commands + +### Testing Approach + +Go unit tests in `cf_cli_java_plugin_test.go` and `open_test.go`/`redact_test.go` use a `fakeConn` stub +for `plugin.CliConnection`. They test flag parsing, error messages, and local logic without a real CF +connection. Integration tests in `test/` are Python (pytest) and require a configured CF environment. diff --git a/Makefile b/Makefile index 9fd9e4b..700b06d 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,8 @@ JAVA_PLUGIN_INSTALLED = $(cf plugins | grep -q) JSTALL_JAR = dist/jstall-minimal.jar # Set JSTALL_DEV=1 to pull the latest GitHub Actions build instead of the latest release JSTALL_DEV ?= +# Set JSTALL_LOCAL=/path/to/jstall.jar to use a locally built JAR instead of downloading +JSTALL_LOCAL ?= all: install @@ -9,7 +11,9 @@ build: compile $(JSTALL_JAR): mkdir -p dist -ifdef JSTALL_DEV +ifdef JSTALL_LOCAL + cp $(JSTALL_LOCAL) $@ +else ifdef JSTALL_DEV ifeq ($(JSTALL_DEV),1) gh run download -R parttimenerd/jstall -n jstall-minimal-jar --dir dist else @@ -25,24 +29,80 @@ update-jstall: rm -f $(JSTALL_JAR) $(MAKE) download-jstall -.PHONY: build compile compile-all update-jstall download-jstall install remove clean vclean +.PHONY: build compile compile-all update-jstall download-jstall download-hprof-redact update-hprof-redact install remove clean vclean -# When JSTALL_DEV=1, always re-download the jar (skip file existence check) -ifdef JSTALL_DEV +# When JSTALL_LOCAL or JSTALL_DEV is set, always re-copy/re-download (skip file existence check) +ifdef JSTALL_LOCAL +JSTALL_DEP = update-jstall +else ifdef JSTALL_DEV JSTALL_DEP = update-jstall else JSTALL_DEP = $(JSTALL_JAR) endif -compile: $(JSTALL_DEP) +# ── hprof-redact embedded binaries ─────────────────────────────────────────── +# Downloaded at compile time from hprof-analyzer GitHub releases. +# Uses musl-static Linux builds so the binary runs in CF containers without +# glibc version constraints. +HPROF_REDACT_BASE = https://github.com/parttimenerd/hprof-analyzer/releases/download/nightly + +dist/hprof-redact-linux-amd64: + mkdir -p dist + curl -sL $(HPROF_REDACT_BASE)/hprof-analyzer-x86_64-unknown-linux-musl.tar.gz \ + | tar -xz --strip-components=1 -C dist hprof-analyzer-x86_64-unknown-linux-musl/hprof-redact + mv dist/hprof-redact $@ + +dist/hprof-redact-linux-arm64: + mkdir -p dist + curl -sL $(HPROF_REDACT_BASE)/hprof-analyzer-aarch64-unknown-linux-musl.tar.gz \ + | tar -xz --strip-components=1 -C dist hprof-analyzer-aarch64-unknown-linux-musl/hprof-redact + mv dist/hprof-redact $@ + +dist/hprof-redact-darwin-arm64: + mkdir -p dist + curl -sL $(HPROF_REDACT_BASE)/hprof-analyzer-aarch64-apple-darwin.tar.gz \ + | tar -xz --strip-components=1 -C dist hprof-analyzer-aarch64-apple-darwin/hprof-redact + mv dist/hprof-redact $@ + +dist/hprof-redact-windows-amd64.exe: + mkdir -p dist + $(eval WINTMP := $(shell mktemp -d)) + curl -sL $(HPROF_REDACT_BASE)/hprof-analyzer-x86_64-pc-windows-msvc.zip -o $(WINTMP)/win.zip + cd $(WINTMP) && unzip -o win.zip hprof-analyzer-x86_64-pc-windows-msvc/hprof-redact.exe + cp $(WINTMP)/hprof-analyzer-x86_64-pc-windows-msvc/hprof-redact.exe $@ + rm -rf $(WINTMP) + +dist/hprof-redact-windows-arm64.exe: + mkdir -p dist + $(eval WINTMP := $(shell mktemp -d)) + curl -sL -f $(HPROF_REDACT_BASE)/hprof-analyzer-aarch64-pc-windows-msvc.zip -o $(WINTMP)/win.zip \ + && cd $(WINTMP) && unzip -o win.zip hprof-analyzer-aarch64-pc-windows-msvc/hprof-redact.exe \ + && cp $(WINTMP)/hprof-analyzer-aarch64-pc-windows-msvc/hprof-redact.exe $@ \ + || touch $@ + rm -rf $(WINTMP) + +HPROF_REDACT_BINS = \ + dist/hprof-redact-linux-amd64 \ + dist/hprof-redact-linux-arm64 \ + dist/hprof-redact-darwin-arm64 \ + dist/hprof-redact-windows-amd64.exe \ + dist/hprof-redact-windows-arm64.exe + +download-hprof-redact: $(HPROF_REDACT_BINS) + +update-hprof-redact: + rm -f $(HPROF_REDACT_BINS) + $(MAKE) download-hprof-redact + +compile: $(JSTALL_DEP) $(HPROF_REDACT_BINS) go build -o build/cf-cli-java-plugin . -compile-all: $(JSTALL_DEP) +compile-all: $(JSTALL_DEP) $(HPROF_REDACT_BINS) GOOS=linux GOARCH=amd64 go build -o build/cf-cli-java-plugin-linux64 . GOOS=linux GOARCH=arm64 go build -o build/cf-cli-java-plugin-linux-arm64 . - GOOS=darwin GOARCH=amd64 go build -o build/cf-cli-java-plugin-osx . GOOS=darwin GOARCH=arm64 go build -o build/cf-cli-java-plugin-osx-arm64 . GOOS=windows GOARCH=amd64 go build -o build/cf-cli-java-plugin-win64.exe . + GOOS=windows GOARCH=arm64 go build -o build/cf-cli-java-plugin-win-arm64.exe . clean: rm -r build diff --git a/README.md b/README.md index b114fb7..0fc5d09 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,20 @@ work with Java applications deployed on Cloud Foundry by the [SapMachine](https: Currently, it allows you to: -- Trigger and retrieve a heap dump and a thread dump from a Cloud Foundry Java application -- Run jcmd remotely on your application -- 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 +- Capture heap dumps and thread dumps from a running Cloud Foundry Java application +- Run `jcmd` remotely against your application +- Start, stop, and retrieve JFR and [async-profiler](https://github.com/jvm-profiling-tools/async-profiler) + ([SapMachine](https://sapmachine.io) only) profiles - Run [jstall](https://github.com/parttimenerd/jstall) for one-shot JVM inspection (deadlock detection, hot threads, dependency graphs, and more): bundled directly in the plugin, requires Java 17+ locally +- Redact heap dumps before saving to remove sensitive data (`--redact`, `--redact-complete`) using the bundled + [`hprof-redact`](https://github.com/parttimenerd/hprof-analyzer) binary from the + [`hprof-analyzer`](https://github.com/parttimenerd/hprof-analyzer) project +- Automatically compress heap dump transfers over SSH on JDK 17+ containers; + use `--compress` to keep the local file as `.hprof.gz` +- Open heap dumps directly in the hosted + [`hprof-analyzer`](https://parttimenerd.github.io/hprof-analyzer) web app after downloading (`--open`) or point the + plugin at another `hprof-analyzer` instance via `--open-url` ## Installation @@ -39,16 +47,18 @@ Download the latest release from [GitHub](https://github.com/SAP/cf-cli-java-plu To install a new version of the plugin, run the following: ```sh -# on Mac arm64 +# on Mac arm64 (Apple Silicon only) cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-macos-arm64 -# on Windows x64 -cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-windows-amd64 -# on Linux x64 +# on Windows amd64 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-windows-amd64.exe +# on Linux amd64 cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-linux-amd64 # on Linux arm64 cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-linux-arm64 ``` +macOS plugin binaries currently require Apple Silicon; macOS Intel (`darwin/amd64`) is not supported. + You can verify that the plugin is successfully installed by looking for `java` in the output of `cf plugins`. ### Manual Installation of Snapshot Release @@ -59,16 +69,18 @@ This is intended for experimentation and might fail. To install a new version of the plugin, run the following: ```sh -# on Mac arm64 +# on Mac arm64 (Apple Silicon only) cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-macos-arm64 -# on Windows x64 -cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-windows-amd64 -# on Linux x64 +# on Windows amd64 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-windows-amd64.exe +# on Linux amd64 cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-linux-amd64 # on Linux arm64 cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-linux-arm64 ``` +macOS snapshot binaries currently require Apple Silicon; macOS Intel (`darwin/amd64`) is not supported. + ## Common Tasks ### My CF app is not responding β€” find what it is stuck on @@ -135,43 +147,61 @@ hprof-analyzer $APP_NAME-heapdump-*.hprof report.html # Open report.html β†’ "Leak Suspects" and "Top Consumers" tabs ``` -**Note:** requires jmap β€” see [Prerequisites](#prerequisites) if you see a "jmap not found" error. +**Note:** On JRE-only containers without `jmap`, heap dumps are taken via the HotSpot attach socket β€” +see [JRE-only containers](#jre-only-containers) below. ## Usage ### Prerequisites -#### JDK Tools (for `heap-dump` only) +#### Container Requirements + +Most commands work out of the box on any Java container. The table below shows what each command needs: + +| Command | JDK tools needed | JRE-only fallback | +| --- | --- | --- | +| `heap-dump` | `jmap` (or `jvmmon` on SapMachine) | `nc -U` (netcat-openbsd / nmap-ncat) | +| `thread-dump` | `jstack` (or `jvmmon` on SapMachine) | `nc -U` | +| `vm-info`, `vm-version` | `jcmd` | `nc -U` | +| `jcmd` | `jcmd` | `nc -U` | +| `jfr-*` | `jcmd` | β€” (JFR requires JDK) | +| `asprof-*` | `asprof` (SapMachine / manual install) | β€” | +| `status`, `jstall`, `record-status` | none (runs locally via jstall) | works on any JRE | + +#### JRE-only containers + +The Cloud Foundry Java Buildpack deploys a JRE by default (no `jmap`, `jstack`, or `jcmd`). Most `cf java` commands +now work on JRE-only containers via the HotSpot attach socket: the plugin sends jcmd-protocol requests directly to +the JVM over a Unix-domain socket using `nc`. + +**Requirement:** `netcat-openbsd` or `nmap-ncat` must be installed in the container (provides `nc` with `-U` support). +Most Debian/Ubuntu-based containers include `netcat-openbsd` by default. If it is missing, install it via: + +```yaml +env: + VCAP_SERVICES_PACKAGE_INSTALL: "netcat-openbsd" +``` + +Or in your Dockerfile/buildpack configuration: `apt-get install -y netcat-openbsd`. -The `heap-dump` command uses `jmap`, which is not shipped by default in the -[Cloud Foundry Java Buildpack](https://github.com/cloudfoundry/java-buildpack). Other commands (`thread-dump`, `jcmd`, -`jfr-*`, `asprof-*`, `status`, `jstall`, `record-status`) use `jcmd` or `asprof`, which are available in SapMachine and -most JDK distributions without extra configuration. +Commands that require a full JDK and have no nc fallback are `jfr-*` (JFR is a JDK feature) and `asprof-*` +(async-profiler must be installed separately). Use `jstall`-based commands for diagnostics on JRE-only containers. -To ensure that `jmap` is available for heap dumps, you can request a full JDK in your application manifest via the -`JBP_CONFIG_OPEN_JDK_JRE` environment variable. This could be done like this: +To use a full JDK instead (gives access to all commands), set `JBP_CONFIG_OPEN_JDK_JRE` in your manifest: ```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/jammy/x86_64", version: 11.+ } }' - JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints']" + β€˜{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64", version: 21.+ } }’ + JBP_CONFIG_JAVA_OPTS: "[java_opts: β€˜-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints’]" ``` -`-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 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. +Note: this requires an online buildpack (`buildpack` property), not a system buildpack (system buildpacks don’t +cache JDK artifacts and will fail staging). #### SSH Access @@ -192,43 +222,67 @@ is not in `cf java`, but in whatever makes `cf ssh` fail. ### Examples -Getting a heap-dump: +Getting a heap dump: ```sh -> cf java heap-dump $APP_NAME --> ./$APP_NAME-heapdump-$RANDOM.hprof +# Basic β€” plain .hprof saved locally. +# On JDK 17+ containers, transfer is always gzip-compressed automatically (faster on slow connections). +cf java heap-dump $APP_NAME + +# Redact sensitive values (passwords, tokens, personal data) before saving +cf java heap-dump $APP_NAME --redact # lean: zeros primitive arrays +cf java heap-dump $APP_NAME --redact-complete # complete: zeros all primitive values + +# Keep the local file compressed as .hprof.gz (transfer is already compressed on JDK 17+) +cf java heap-dump $APP_NAME --compress + +# Redact and keep compressed +cf java heap-dump $APP_NAME --redact --compress + +# Open in hprof-analyzer web app after downloading (spins up a local server, opens browser) +cf java heap-dump $APP_NAME --open + +# Open with redaction and compression applied first +cf java heap-dump $APP_NAME --open --redact --compress + +# Open using a locally running hprof-analyzer instance +cf java heap-dump $APP_NAME --open-url http://localhost:8080 ``` -Getting a thread-dump: +The browser integration uses the [`hprof-analyzer`](https://github.com/parttimenerd/hprof-analyzer) project. By +default, `--open` launches the hosted web app at . Use `--open-url` if +you run your own local or internal `hprof-analyzer` deployment. + +> **macOS note:** On macOS with the Application Firewall enabled, a dialog will appear asking +> *"Do you want the application 'cf-cli-java-plugin' to accept incoming network connections?"* +> Click **Allow** β€” the plugin binds a temporary local server on `127.0.0.1` to serve the file +> to the browser. The server serves only the exact one-time heap-dump URL generated for that download, +> rejects alternate paths or query parameters, and shuts down automatically after one successful fetch. + +Getting a thread dump: ```sh -> cf java thread-dump $APP_NAME -... -Full thread dump OpenJDK 64-Bit Server VM ... -... +cf java thread-dump $APP_NAME ``` -Creating a CPU-time profile via async-profiler: +Creating a CPU profile via async-profiler: ```sh -> cf java asprof-start-cpu $APP_NAME -Profiling started +cf java asprof-start-cpu $APP_NAME # wait some time to gather data -> cf java asprof-stop $APP_NAME --> ./$APP_NAME-asprof-$RANDOM.jfr +cf java asprof-stop $APP_NAME ``` -Running arbitrary JCMD commands, like `VM.uptime`: +Running arbitrary jcmd commands, like `VM.uptime`: ```sh -> cf java jcmd $APP_NAME --args 'VM.uptime' -$TIME s +cf java jcmd $APP_NAME --args 'VM.uptime' ``` Quick status check of the remote JVM (requires Java 17+ locally): ```sh -> cf java status $APP_NAME +cf java status $APP_NAME ``` Running [JStall](https://github.com/parttimenerd/jstall) for more specific JVM inspection (requires Java 17+ locally): @@ -313,27 +367,90 @@ The `--args` parameter passes values directly into remote shell commands via `cf shell features like environment variable expansion and piping. **Do not pass untrusted input to `--args`** β€” treat it with the same caution as a shell command. +### File Output + The heap dumps and profiles will be downloaded to a local file automatically (to the current directory by default). Use `--local-dir` to specify a different download location. 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 that path +inside the application container. Without it, the file is created at `/tmp` or at the mount point of an attached +file system service. ```shell cf java [heap-dump|jfr-stop|jfr-dump|asprof-stop] [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: +Thread dumps are streamed to stdout. To save one to a file: ```shell cf java thread-dump [my_app] -i [my_instance_index] > thread-dump.txt ``` -The `--keep` 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 `--keep` flag is not applicable to commands that stream output directly (e.g., `thread-dump`). + +Heap dumps support additional local post-processing and analysis options: + +- `--redact`: lean redaction mode; streams the heap dump through `hprof-redact` and zeros primitive arrays such as + `byte[]`, `char[]`, and similar bulk buffers +- `--redact-complete`: complete redaction mode; streams the heap dump through `hprof-redact` and zeros primitive arrays + and individual primitive fields +- `--redact-keep-on-error`: keeps a partially written redacted output file if local redaction fails; otherwise failed + redaction leaves no local heap dump behind +- `--compress`: keeps the local output as `.hprof.gz` instead of transparently decompressing it +- `--open`: starts a temporary local HTTP server on `127.0.0.1`, serves the downloaded heap dump once, and opens + [`hprof-analyzer`](https://parttimenerd.github.io/hprof-analyzer) automatically in your browser +- `--open-url `: same as `--open`, but targets a custom hosted or self-managed `hprof-analyzer` instance + +These features can be combined, for example: `cf java heap-dump APP --redact --compress --open`. +`--open` requires a local file and therefore cannot be used with `--no-download`. + +### Heap Dump Privacy + +Heap dumps contain the full in-memory state of a JVM, including strings, byte arrays, and field values, which can +hold passwords, tokens, session data, or personal information. Before sharing a dump outside a trusted environment, +use `--redact` or `--redact-complete` to zero out sensitive values. + +| Flag | What gets zeroed | +| ------------------- | -------------------------------------------------------------------------------------------- | +| `--redact` | Primitive arrays (`byte[]`, `char[]`, `int[]`, …) β€” covers most strings and serialized data | +| `--redact-complete` | All primitive arrays **and** individual primitive fields β€” maximum privacy | + +Both modes preserve the full object graph (class names, references, instance counts), so the dump remains useful for +memory analysis. The two flags are mutually exclusive. + +The redacted file is saved to the requested local heap-dump path with no extra suffix. When redaction is enabled, the +heap dump is streamed directly into `hprof-redact` exactly as downloaded, including gzip-compressed `.hprof.gz` +streams, so the unredacted dump is never written to local disk. +Use `--redact --compress` to also compress the output (produces a `.hprof.gz`). + +Redaction runs locally via the bundled [hprof-redact](https://github.com/parttimenerd/hprof-analyzer) binary while the +dump is being downloaded. The binary is embedded from the +[`hprof-analyzer`](https://github.com/parttimenerd/hprof-analyzer) project, so no separate installation is required. + +### Compressed Transfer + +When bandwidth or container disk space is a concern, use `--compress` to transfer the dump in gzip format. + +- On **JDK 17+**: `jmap` (or the nc fallback) compresses the dump on the container before transfer; the + local file is saved as `.hprof.gz`. +- On **JDK < 17**: the container JDK does not support `gz=1`; a warning is printed and the dump is downloaded + uncompressed as usual. + +Without `--compress`, the plugin still uses `gz=1` automatically when the remote JDK supports it β€” the transfer is +compressed but the local file is transparently decompressed to a plain `.hprof`. This is the default behaviour +starting from JDK 17 and costs nothing from the user's perspective. + +### Opening a Heap Dump in hprof-analyzer + +Use `--open` to inspect the downloaded heap dump immediately in +[`hprof-analyzer`](https://github.com/parttimenerd/hprof-analyzer), either via the hosted instance at + or via your own deployment with `--open-url`. + +For safety, the plugin does **not** expose an arbitrary local directory. Instead, it starts a temporary local HTTP +server bound to `127.0.0.1`, serves only the exact generated heap-dump for that one download, rejects alternate +paths and query parameters, and shuts the server down automatically after one successful browser fetch or after a +timeout if the browser never connects. ## Limitations diff --git a/cf_cli_java_plugin.go b/cf_cli_java_plugin.go index eaab765..f19c5d5 100644 --- a/cf_cli_java_plugin.go +++ b/cf_cli_java_plugin.go @@ -9,13 +9,16 @@ package main import ( + "compress/gzip" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "strconv" "strings" + "time" "code.cloudfoundry.org/cli/cf/terminal" "code.cloudfoundry.org/cli/cf/trace" @@ -31,19 +34,31 @@ var _ plugin.Plugin = (*JavaPlugin)(nil) // String constants extracted to satisfy goconst linter. const ( - cmdSSH = "ssh" - cmdJava = "java" - flagKeep = "keep" - flagNoDownload = "no-download" - flagContainerDir = "container-dir" - flagLocalDir = "local-dir" - typeBool = "bool" - typeString = "string" - toolJcmd = "jcmd" - toolAsprof = "asprof" - extJFR = ".jfr" - labelJFR = "JFR recording" - partJFR = "jfr" + cmdSSH = "ssh" + cmdJava = "java" + flagKeep = "keep" + flagNoDownload = "no-download" + flagContainerDir = "container-dir" + flagLocalDir = "local-dir" + flagRedact = "redact" + flagRedactComplete = "redact-complete" + flagRedactKeepOnError = "redact-keep-on-error" + flagCompress = "compress" + flagOpen = "open" + flagOpenURL = "open-url" + defaultOpenURL = "https://parttimenerd.github.io/hprof-analyzer" + osWindows = "windows" + cmdHeapDump = "heap-dump" + cmdVmVitals = "vm-vitals" + typeBool = "bool" + typeString = "string" + toolJcmd = "jcmd" + toolAsprof = "asprof" + extJFR = ".jfr" + labelJFR = "JFR recording" + partJFR = "jfr" + extHprof = ".hprof" + extHprofGz = ".hprof.gz" ) // JavaPlugin is a CF CLI plugin that supports taking heap and thread dumps on demand @@ -198,15 +213,22 @@ func (c *JavaPlugin) checkSSHConnectivity(appName string, appInstanceIndex int) // Options holds all command-line options for the Java plugin type Options struct { - AppInstanceIndex int - Keep bool - NoDownload bool - DryRun bool - Verbose bool - Full bool - ContainerDir string - LocalDir string - Args string + AppInstanceIndex int + Keep bool + NoDownload bool + DryRun bool + Verbose bool + Full bool + ContainerDir string + LocalDir string + Args string + Redact bool + RedactComplete bool + RedactKeepOnError bool + Compress bool + Open bool + OpenURL string + OpenURLExplicit bool // true only when --open-url was explicitly passed } // FlagDefinition holds metadata for a command-line flag @@ -285,6 +307,42 @@ var flagDefinitions = []FlagDefinition{ 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: typeString, }, + { + Name: flagRedact, + Usage: "redact heap dump (lean mode: zero primitive arrays only) before saving locally", + Description: "redact heap dump before saving locally (lean mode: zero primitive arrays only)", + Type: typeBool, + }, + { + Name: flagRedactComplete, + Usage: "redact heap dump (complete mode: zero all primitive values) before saving locally", + Description: "redact heap dump before saving locally (complete mode: zero all primitive values)", + Type: typeBool, + }, + { + Name: flagRedactKeepOnError, + Usage: "keep partially-written redacted file if redaction fails (default: delete it)", + Description: "keep partially-written redacted file if redaction fails (default: delete it)", + Type: typeBool, + }, + { + Name: flagCompress, + Usage: "compress heap dump on container using jmap gz=1 (JDK 17+) to reduce transfer size; output is .hprof.gz", + Description: "compress heap dump on the container before downloading (JDK 17+, reduces transfer size); output file will be .hprof.gz", + Type: typeBool, + }, + { + Name: flagOpen, + Usage: "open the heap dump in the hprof-analyzer web app after downloading", + Description: "open the heap dump in the hprof-analyzer web app after downloading", + Type: typeBool, + }, + { + Name: flagOpenURL, + Usage: "base URL of the hprof-analyzer instance to open (implies --open)", + Description: "base URL of the hprof-analyzer instance to open (implies --open)", + Type: typeString, + }, } func (c *JavaPlugin) createOptionsParser() flags.FlagContext { @@ -315,7 +373,10 @@ func (c *JavaPlugin) parseOptions(args []string) (*Options, []string, error) { } appInstanceIndex := commandFlags.Int("app-instance-index") - appInstanceIndexSet := commandFlags.IsSet("app-instance-index") + // simonleung8/flags registers flags with non-zero defaults in flagsets at init time, + // so IsSet() returns true even when the flag was not explicitly provided. + // Check against the known default (-1) to detect actual user-provided values. + appInstanceIndexSet := commandFlags.IsSet("app-instance-index") && appInstanceIndex != -1 keep := commandFlags.IsSet("keep") noDownload := commandFlags.IsSet("no-download") @@ -341,15 +402,39 @@ func (c *JavaPlugin) parseOptions(args []string) (*Options, []string, error) { } options := &Options{ - AppInstanceIndex: appInstanceIndex, - Keep: keep, - NoDownload: noDownload, - DryRun: commandFlags.IsSet("dry-run"), - Verbose: commandFlags.IsSet("verbose"), - Full: commandFlags.IsSet("full"), - ContainerDir: commandFlags.String("container-dir"), - LocalDir: commandFlags.String("local-dir"), - Args: commandFlags.String("args"), + AppInstanceIndex: appInstanceIndex, + Keep: keep, + NoDownload: noDownload, + DryRun: commandFlags.IsSet("dry-run"), + Verbose: commandFlags.IsSet("verbose"), + Full: commandFlags.IsSet("full"), + ContainerDir: commandFlags.String("container-dir"), + LocalDir: commandFlags.String("local-dir"), + Args: commandFlags.String("args"), + Redact: commandFlags.IsSet(flagRedact), + RedactComplete: commandFlags.IsSet(flagRedactComplete), + RedactKeepOnError: commandFlags.IsSet(flagRedactKeepOnError), + Compress: commandFlags.IsSet(flagCompress), + Open: commandFlags.IsSet(flagOpen) || commandFlags.IsSet(flagOpenURL), + OpenURL: func() string { + if u := commandFlags.String(flagOpenURL); u != "" { + return u + } + return defaultOpenURL + }(), + OpenURLExplicit: commandFlags.IsSet(flagOpenURL), + } + + if options.Redact && options.RedactComplete { + return nil, nil, &InvalidUsageError{ + message: "Error: flags '--redact' and '--redact-complete' are mutually exclusive", + } + } + + if options.Open && options.NoDownload { + return nil, nil, &InvalidUsageError{ + message: "Error: flag '--open' requires a local file and cannot be used with '--no-download'", + } } return options, commandFlags.Args(), nil @@ -361,15 +446,23 @@ func (c *JavaPlugin) generateOptionsMapFromFlags() 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]" + var prefix string + if flagDef.ShortName != "" { + prefix = "-" + flagDef.ShortName + if flagDef.Name == "app-instance-index" { + prefix += " [index]" + } + prefix += ", " } - prefix += ", " - // Use the Description field for detailed help text - options[flagDef.Name] = utils.WrapTextWithPrefix(flagDef.Description, prefix, 80, 27) + // Use the Description field for detailed help text. + // miscLineIndent aligns continuation lines: prefix + indent must equal the + // widest prefix used ("-i [index], " = 12 chars, indent 19 β†’ total 31). + indent := 31 - len(prefix) + if indent < 0 { + indent = 0 + } + options[flagDef.Name] = utils.WrapTextWithPrefix(flagDef.Description, prefix, 80, indent) } return options @@ -386,6 +479,39 @@ const ( cat # fallback: just pass through the input unchanged fi };` + + // AttachSocketNCHelper defines a shell function `nc_jcmd PID CMDLINE...` that sends one + // HotSpot attach-protocol request to the JVM's Unix-domain socket via nc. + // This is the same protocol jcmd uses internally (JDK 9+) and works on JRE-only containers. + // Socket location: /tmp/.java_pid (Linux) or ${TMPDIR%/}/.java_pid (macOS). + // If the socket does not exist yet, it is triggered via the standard attach handshake + // (write .attach_pid into the JVM's cwd and send SIGQUIT), then polled for 5 s. + // Requires nc with -U support (netcat-openbsd or nmap-ncat). + // + // Protocol: the entire command line (command + args) goes in the first field, NUL-separated: + // printf '1\0jcmd\0GC.heap_dump /tmp/out.hprof\0\0\0' + // Subsequent fields are unused by HotSpot; the JVM splits the first field on spaces. + AttachSocketNCHelper = `nc_jcmd() { + _pid=$1; shift; _cmdline="$*" + _sock=$(if [ -S "/tmp/.java_pid${_pid}" ]; then echo "/tmp/.java_pid${_pid}"; \ + elif [ -n "$TMPDIR" ] && [ -S "${TMPDIR%/}/.java_pid${_pid}" ]; then echo "${TMPDIR%/}/.java_pid${_pid}"; fi) + if [ -z "$_sock" ]; then + _cwd=$(readlink /proc/${_pid}/cwd 2>/dev/null || echo /tmp) + touch "${_cwd}/.attach_pid${_pid}" 2>/dev/null; kill -QUIT "${_pid}" 2>/dev/null + for _i in 1 2 3 4 5 6 7 8 9 10; do sleep 0.5 + _sock=$(if [ -S "/tmp/.java_pid${_pid}" ]; then echo "/tmp/.java_pid${_pid}"; \ + elif [ -n "$TMPDIR" ] && [ -S "${TMPDIR%/}/.java_pid${_pid}" ]; then echo "${TMPDIR%/}/.java_pid${_pid}"; fi) + [ -n "$_sock" ] && break + done + fi + [ -z "$_sock" ] && { echo >&2 "nc_jcmd: attach socket not found for PID ${_pid}"; return 1; } + _raw=$(printf '1\0jcmd\0%s\0\0\0' "$_cmdline" | nc -w 2 -U "$_sock" 2>/dev/null) + _rc=$(echo "$_raw" | head -n 1) + _body=$(echo "$_raw" | tail -n +2) + if [ "$_rc" != "0" ]; then echo >&2 "nc_jcmd: command '$_cmdline' failed (rc=$_rc): $_body"; return 1; fi + echo "$_body" +}; +nc_available() { command -v nc >/dev/null 2>&1 && nc -h 2>&1 | grep -q '\-U'; };` ) // Run must be implemented by any plugin because it is part of the @@ -516,10 +642,10 @@ func (c *JavaPlugin) replaceVariables(command, appName, fspath, fileName, static var commands = []Command{ { - Name: "heap-dump", + Name: cmdHeapDump, Description: "Generate a heap dump from a running Java application", GenerateFiles: true, - FileExtension: ".hprof", + FileExtension: extHprof, /* If there is not enough space on the filesystem to write the dump, jmap will create a file with size 0, output something about not enough space left on the device, and exit with status code 0. @@ -530,37 +656,37 @@ 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: AttachSocketNCHelper + `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:]) -# if we have neither jmap nor jvmmon, we cannot generate a heap dump and should exit with an error -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: - --- - 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/jammy/x86_64", version: 21.+ } }' - - " - exit 1 -fi +_pid=$(pidof java) if [ -n "${JMAP_COMMAND}" ]; then -OUTPUT=$( ${JMAP_COMMAND} -dump:format=b,file=@FILE_NAME $(pidof java) ) || STATUS_CODE=$? +GZ_ARG="" +if ${JMAP_COMMAND} -h 2>&1 | grep -q "gz="; then GZ_ARG=",gz=1"; fi +OUTPUT=$( ${JMAP_COMMAND} -dump:format=b${GZ_ARG},file=@FILE_NAME ${_pid} ) || STATUS_CODE=$? if [ ! -s @FILE_NAME ]; then echo >&2 ${OUTPUT}; exit 1; fi if [ ${STATUS_CODE:-0} -gt 0 ]; then echo >&2 ${OUTPUT}; exit ${STATUS_CODE}; fi elif [ -n "${JVMMON_COMMAND}" ]; then -echo -e 'change command line flag flags=-XX:HeapDumpOnDemandPath=@FSPATH\ndump heap' > setHeapDumpOnDemandPath.sh -OUTPUT=$( ${JVMMON_COMMAND} -pid $(pidof java) -cmd "setHeapDumpOnDemandPath.sh" ) || STATUS_CODE=$? +echo -e 'change command line flag flags=-XX:HeapDumpOnDemandPath=@FSPATH\ndump heap' > @FSPATH/setHeapDumpOnDemandPath.sh +OUTPUT=$( ${JVMMON_COMMAND} -pid ${_pid} -cmd "@FSPATH/setHeapDumpOnDemandPath.sh" ) || STATUS_CODE=$? +rm -f @FSPATH/setHeapDumpOnDemandPath.sh sleep 5 # Writing the heap dump is triggered asynchronously -> give the JVM some time to create the file HEAP_DUMP_NAME=$(find @FSPATH -name 'java_pid*.hprof' -printf '%T@ %p\0' | sort -zk 1nr | sed -z 's/^[^ ]* //' | tr '\0' '\n' | head -n 1) SIZE=-1; OLD_SIZE=$(stat -c '%s' "${HEAP_DUMP_NAME}"); while [ ${SIZE} != ${OLD_SIZE} ]; do OLD_SIZE=${SIZE}; sleep 3; SIZE=$(stat -c '%s' "${HEAP_DUMP_NAME}"); done if [ ! -s "${HEAP_DUMP_NAME}" ]; then echo >&2 ${OUTPUT}; exit 1; fi if [ ${STATUS_CODE:-0} -gt 0 ]; then echo >&2 ${OUTPUT}; exit ${STATUS_CODE}; fi +if [ -n "@COMPRESS_FLAG" ]; then gzip -1 "${HEAP_DUMP_NAME}" && HEAP_DUMP_NAME="${HEAP_DUMP_NAME}.gz"; fi +elif nc_available; then +nc_jcmd ${_pid} GC.heap_dump @FILE_NAME || exit 1 +if [ ! -s @FILE_NAME ]; then echo >&2 "Heap dump file not created or empty"; exit 1; fi +else + echo >&2 "jvmmon, jmap, or nc (netcat-openbsd / nmap-ncat) are required for generating a heap dump. +To use a JDK image, set JBP_CONFIG_OPEN_JDK_JRE in your manifest.yaml: + env: + JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: \"https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64\", version: 21.+ } }' +Or install netcat on the container (apt-get install netcat-openbsd)." + exit 1 fi`, FileLabel: "heap dump", FileNamePart: "heapdump", @@ -569,40 +695,43 @@ 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); - JVMMON_COMMAND=$(find -executable -name jvmmon | head -1) - if [ -z "${JVMMON_COMMAND}" ] && [ -z "${JSTACK_COMMAND}" ]; then - echo >&2 "jstack or jvmmon are required for generating thread dump, you can modify your application manifest.yaml on the 'JBP_CONFIG_OPEN_JDK_JRE' environment variable. This could be done like this: - --- - 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/jammy/x86_64", version: 21.+ } }' - - " - exit 1 - fi - if [ -n \"${JSTACK_COMMAND}\" ]; then ${JSTACK_COMMAND} $(pidof java); exit 0; fi; - if [ -n \"${JVMMON_COMMAND}\" ]; then ${JVMMON_COMMAND} -pid $(pidof java) -c \"print stacktrace\"; fi`, + SSHCommand: AttachSocketNCHelper + ` +JSTACK_COMMAND=$(find -executable -name jstack | head -1); +JVMMON_COMMAND=$(find -executable -name jvmmon | head -1) +_pid=$(pidof java) +if [ -n "${JSTACK_COMMAND}" ]; then ${JSTACK_COMMAND} ${_pid}; exit 0; fi +if [ -n "${JVMMON_COMMAND}" ]; then ${JVMMON_COMMAND} -pid ${_pid} -c "print stacktrace"; exit 0; fi +if nc_available; then nc_jcmd ${_pid} Thread.print; exit 0; fi +echo >&2 "jstack, jvmmon, or nc (netcat-openbsd / nmap-ncat) are required for generating a thread dump. +To use a JDK image, set JBP_CONFIG_OPEN_JDK_JRE in your manifest.yaml: + env: + JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: \"https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64\", version: 21.+ } }' +Or install netcat on the container (apt-get install netcat-openbsd)." +exit 1`, }, { Name: "vm-info", Description: "Print information about the Java Virtual Machine running a Java application", - RequiredTools: []string{toolJcmd}, GenerateFiles: false, - SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.info | filter_jcmd_remote_message`, + SSHCommand: AttachSocketNCHelper + FilterJCMDRemoteMessage + ` +_pid=$(pidof java) +JCMD_COMMAND=$(find -executable -name jcmd | head -1) +if [ -n "${JCMD_COMMAND}" ]; then ${JCMD_COMMAND} ${_pid} VM.info | filter_jcmd_remote_message; exit 0; fi +if nc_available; then nc_jcmd ${_pid} VM.info; exit 0; fi +echo >&2 "jcmd not found. Install netcat (netcat-openbsd / nmap-ncat) for JRE-only containers."; exit 1`, }, { Name: toolJcmd, Description: "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 with UUID for file operations), and @STATIC_FILE_NAME (without UUID). Use single quotes around --args to prevent shell expansion.", - RequiredTools: []string{toolJcmd}, + RequiredTools: []string{}, GenerateFiles: false, GenerateArbitraryFiles: true, GenerateArbitraryFilesFolderName: toolJcmd, - SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) @ARGS | filter_jcmd_remote_message`, + SSHCommand: AttachSocketNCHelper + FilterJCMDRemoteMessage + `_pid=$(pidof java) +JCMD_COMMAND=$(find -executable -name jcmd | head -1) +if [ -n "${JCMD_COMMAND}" ]; then ${JCMD_COMMAND} ${_pid} @ARGS | filter_jcmd_remote_message; exit 0; fi +if nc_available; then nc_jcmd ${_pid} @ARGS; exit 0; fi +echo >&2 "jcmd not found. Install netcat (netcat-openbsd / nmap-ncat) for JRE-only containers."; exit 1`, }, { Name: "jfr-start", @@ -701,12 +830,16 @@ fi`, { Name: "vm-version", Description: "Print the version of the Java Virtual Machine running a Java application", - RequiredTools: []string{toolJcmd}, GenerateFiles: false, - SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.version | filter_jcmd_remote_message`, + SSHCommand: AttachSocketNCHelper + FilterJCMDRemoteMessage + ` +_pid=$(pidof java) +JCMD_COMMAND=$(find -executable -name jcmd | head -1) +if [ -n "${JCMD_COMMAND}" ]; then ${JCMD_COMMAND} ${_pid} VM.version | filter_jcmd_remote_message; exit 0; fi +if nc_available; then nc_jcmd ${_pid} VM.version; exit 0; fi +echo >&2 "jcmd not found. Install netcat (netcat-openbsd / nmap-ncat) for JRE-only containers."; exit 1`, }, { - Name: "vm-vitals", + Name: cmdVmVitals, Description: "Print vital statistics about the Java Virtual Machine running a Java application", RequiredTools: []string{toolJcmd}, GenerateFiles: false, @@ -809,7 +942,7 @@ fi`, }, } -func (c *JavaPlugin) execute(_ plugin.CliConnection, 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"} } @@ -895,6 +1028,19 @@ func (c *JavaPlugin) execute(_ plugin.CliConnection, args []string) (string, err for _, command := range commands { avCommands = append(avCommands, command.Name) } + // Detect swapped order: cf java MY-APP heap-dump instead of cf java heap-dump MY-APP + if argumentLen >= 2 && cliConnection != nil { + secondArg := strings.ToLower(arguments[1]) + for _, cmd := range commands { + if cmd.Name == secondArg { + // Confirm the first arg looks like an app name via CF API. + if _, appErr := cliConnection.GetApp(commandName); appErr == nil { + return "", &InvalidUsageError{message: fmt.Sprintf("Did you mean: cf java %s %s? (app and command appear to be swapped)", secondArg, commandName)} + } + break + } + } + } matches := utils.FuzzySearch(lowerCommandName, avCommands, 3) return "", &InvalidUsageError{message: fmt.Sprintf("Unrecognized command %q, did you mean: %s?", commandName, utils.JoinWithOr(matches))} } @@ -902,6 +1048,25 @@ func (c *JavaPlugin) execute(_ plugin.CliConnection, args []string) (string, err command := commands[index] c.logVerbosef("Found command: %s - %s", command.Name, command.Description) + // Warn about heap-dump-only flags used on other commands. + if command.Name != cmdHeapDump { + heapOnlyFlags := []struct { + set bool + name string + }{ + {options.Redact, flagRedact}, + {options.RedactComplete, flagRedactComplete}, + {options.Compress, flagCompress}, + {options.Open, flagOpen}, + {options.OpenURLExplicit, flagOpenURL}, + } + for _, f := range heapOnlyFlags { + if f.set { + fmt.Fprintf(os.Stderr, "Warning: flag '--%s' is only supported by heap-dump and has no effect here.\n", f.name) + } + } + } + // Only block CF_TRACE for commands that download files (not for read-only commands like vm-version) if os.Getenv("CF_TRACE") == "true" && (command.GenerateFiles || command.GenerateArbitraryFiles) { return "", errors.New("the environment variable CF_TRACE is set to true. This prevents download of the dump from succeeding") @@ -985,13 +1150,15 @@ func (c *JavaPlugin) execute(_ plugin.CliConnection, args []string) (string, err c.logVerbosef("CF SSH arguments: %v", cfSSHArguments) - supported, err := utils.CheckRequiredTools(applicationName) + if !options.DryRun { + supported, err := utils.CheckRequiredTools(applicationName) - if err != nil || !supported { - return "required tools checking failed", err - } + if err != nil || !supported { + return "required tools checking failed", err + } - c.logVerbosef("Required tools check passed") + c.logVerbosef("Required tools check passed") + } if command.IsLocal { c.logVerbosef("Executing local command: %s", command.Name) @@ -1066,6 +1233,12 @@ func (c *JavaPlugin) execute(_ plugin.CliConnection, args []string) (string, err fileName := "" staticFileName := "" fspath := remoteDir + fileExt := command.FileExtension + var err error + if command.Name == cmdHeapDump && options.Compress { + // Only set .hprof.gz for jvmmon path (explicit compress); jmap always writes .hprof on remote + fileExt = extHprof + } // Initialize fspath and fileName for commands that need them if command.GenerateFiles || command.NeedsFileName || command.GenerateArbitraryFiles { @@ -1098,13 +1271,21 @@ func (c *JavaPlugin) execute(_ plugin.CliConnection, args []string) (string, err if command.FileNamePart != "" { namePart = "-" + command.FileNamePart } - fileName = fspath + "/" + applicationName + namePart + "-" + utils.GenerateUUID() + command.FileExtension - staticFileName = fspath + "/" + applicationName + namePart + command.FileExtension + fileName = fspath + "/" + applicationName + namePart + "-" + utils.GenerateUUID() + fileExt + staticFileName = fspath + "/" + applicationName + namePart + fileExt c.logVerbosef("Generated filename: %s", fileName) c.logVerbosef("Generated static filename without UUID: %s", staticFileName) } commandText := command.SSHCommand + // Expand @COMPRESS_FLAG for jvmmon path in heap-dump (jmap uses shell-level gz probe) + if command.Name == cmdHeapDump { + if options.Compress { + commandText = strings.ReplaceAll(commandText, "@COMPRESS_FLAG", "1") + } else { + commandText = strings.ReplaceAll(commandText, "@COMPRESS_FLAG", "") + } + } // Perform variable replacements directly in Go code var err2 error commandText, err2 = c.replaceVariables(commandText, applicationName, fspath, fileName, staticFileName, options.Args) @@ -1134,6 +1315,13 @@ func (c *JavaPlugin) execute(_ plugin.CliConnection, args []string) (string, err // to prevent the shell processing it from running it in local escapedCommand := strings.ReplaceAll(remoteCommand, "'", "'\\''") cfSSHArguments = append(cfSSHArguments, "'"+escapedCommand+"'") + if command.Name == cmdHeapDump && options.Open { + ext := extHprof + if options.Compress { + ext = extHprofGz + } + fmt.Printf("Would open: %s\n", buildOpenURL(options.OpenURL, 0, "TOKEN"+ext)) + } return "cf " + strings.Join(cfSSHArguments, " "), nil } @@ -1161,15 +1349,18 @@ func (c *JavaPlugin) execute(_ plugin.CliConnection, args []string) (string, err var finalFile string var err error - switch command.FileExtension { - case ".hprof": + switch fileExt { + case extHprof: c.logVerbosef("Finding heap dump file") finalFile, err = utils.FindHeapDumpFile(cfSSHArguments, fileName, fspath, applicationName+"-"+command.FileNamePart) + case extHprofGz: + c.logVerbosef("Finding compressed heap dump file") + finalFile, err = utils.FindHeapDumpGzFile(cfSSHArguments, fileName, fspath, applicationName+"-"+command.FileNamePart) case ".jfr": c.logVerbosef("Finding JFR file") finalFile, err = utils.FindJFRFile(cfSSHArguments, fileName, fspath, applicationName+"-"+command.FileNamePart) default: - return "", &InvalidUsageError{message: fmt.Sprintf("Unsupported file extension %q", command.FileExtension)} + return "", &InvalidUsageError{message: fmt.Sprintf("Unsupported file extension %q", fileExt)} } if err == nil && finalFile != "" { fileName = finalFile @@ -1189,19 +1380,112 @@ func (c *JavaPlugin) execute(_ plugin.CliConnection, args []string) (string, err return output, nil } - localFileFullPath := localDir + "/" + applicationName + "-" + command.FileNamePart + "-" + utils.GenerateUUID() + command.FileExtension + // For heap-dump via jmap: probe whether the remote file is gzip-compressed. + // jmap writes a .hprof filename but may fill it with gzip content when gz=1 is supported. + localFileExt := fileExt + remoteIsGz := false + if command.Name == cmdHeapDump && fileExt == extHprof { + remoteIsGz, _ = utils.ProbeRemoteFileGzip(cfSSHArguments, fileName) + c.logVerbosef("Remote file is gzip-compressed: %t", remoteIsGz) + switch { + case remoteIsGz && options.Compress: + // User asked for .hprof.gz locally β†’ keep compressed + localFileExt = extHprofGz + case !remoteIsGz && options.Compress: + fmt.Fprintf(os.Stderr, "Warning: remote jmap does not support gz compression (JDK 17+ required); downloading uncompressed\n") + } + } + + localFileFullPath := localDir + "/" + applicationName + "-" + command.FileNamePart + "-" + utils.GenerateUUID() + localFileExt c.logVerbosef("Downloading file to: %s", localFileFullPath) - err = utils.CopyOverCat(cfSSHArguments, fileName, localFileFullPath) - if err == nil { - c.logVerbosef("File download completed successfully") - fmt.Println(utils.ToSentenceCase(command.FileLabel) + " file saved to: " + localFileFullPath) + + redactingHeapDump := command.Name == cmdHeapDump && (options.Redact || options.RedactComplete) + if command.Name == cmdHeapDump && remoteIsGz && localFileExt == extHprof && !redactingHeapDump { + fmt.Println("Note: remote jmap used gz compression; decompressing during transfer...") + } + + if redactingHeapDump { + mode := "lean" + if options.RedactComplete { + mode = "complete" + } + redactBin, rerr := ensureHprofRedact() + if rerr != nil { + return "", fmt.Errorf("hprof-redact unavailable: %w", rerr) + } + + var reader io.ReadCloser + var waitRemote func() error + reader, waitRemote, err = utils.StreamOverCat(cfSSHArguments, fileName) + if err != nil { + return "", err + } + if remoteIsGz { + // Remote jmap used gz=1; decompress before passing to hprof-redact + // which expects uncompressed HPROF format. + gz, gzErr := gzip.NewReader(reader) + if gzErr != nil { + _ = reader.Close() + _ = waitRemote() + return "", fmt.Errorf("gzip header error on remote heap dump: %w", gzErr) + } + reader = gz + } + + finalPath, rerr := pipeHeapDumpThroughRedact(redactBin, reader, localFileFullPath, mode, options.RedactKeepOnError) + closeErr := reader.Close() + waitErr := waitRemote() + if rerr != nil { + return "", combineHeapDumpStreamErrors(rerr, closeErr, waitErr) + } + if combinedErr := combineHeapDumpStreamErrors(nil, closeErr, waitErr); combinedErr != nil { + return "", combinedErr + } + + c.logVerbosef("Redacted heap dump stream completed successfully") + fmt.Println("Redacted heap dump saved to: " + finalPath) + + if command.Name == cmdHeapDump && options.Open { + port, urlFile, done, serveErr := serveFileOnce(finalPath, 10*time.Minute) + if serveErr != nil { + return "", fmt.Errorf("could not start local file server: %w", serveErr) + } + openURL := buildOpenURL(options.OpenURL, port, urlFile) + fmt.Printf("Opening heap dump in browser: %s\n", openURL) + openBrowser(openURL) + <-done + } } else { - c.logVerbosef("File download failed: %v", err) - fmt.Fprintf(os.Stderr, "The %s was created successfully in the container at: %s\n", command.FileLabel, fileName) - fmt.Fprintf(os.Stderr, "However, downloading to local failed: %v\n", err) - fmt.Fprintf(os.Stderr, "The remote file is still available. Retry with:\n") - fmt.Fprintf(os.Stderr, " cf ssh %s -c 'cat %s' > %s\n", applicationName, fileName, localFileFullPath) - return "", fmt.Errorf("download failed (remote file intact): %w", err) + if command.Name == cmdHeapDump && remoteIsGz && localFileExt == extHprof { + // Transparent decompression: stream gz from remote, write plain .hprof locally + err = utils.CopyOverCatGunzip(cfSSHArguments, fileName, localFileFullPath) + } else { + err = utils.CopyOverCat(cfSSHArguments, fileName, localFileFullPath) + } + + if err == nil { + c.logVerbosef("File download completed successfully") + fmt.Println(utils.ToSentenceCase(command.FileLabel) + " file saved to: " + localFileFullPath) + + finalLocalPath := localFileFullPath + if command.Name == cmdHeapDump && options.Open { + port, urlFile, done, serveErr := serveFileOnce(finalLocalPath, 10*time.Minute) + if serveErr != nil { + return "", fmt.Errorf("could not start local file server: %w", serveErr) + } + openURL := buildOpenURL(options.OpenURL, port, urlFile) + fmt.Printf("Opening heap dump in browser: %s\n", openURL) + openBrowser(openURL) + <-done + } + } else { + c.logVerbosef("File download failed: %v", err) + fmt.Fprintf(os.Stderr, "The %s was created successfully in the container at: %s\n", command.FileLabel, fileName) + fmt.Fprintf(os.Stderr, "However, downloading to local failed: %v\n", err) + fmt.Fprintf(os.Stderr, "The remote file is still available. Retry with:\n") + fmt.Fprintf(os.Stderr, " cf ssh %s -c 'cat %s' > %s\n", applicationName, fileName, localFileFullPath) + return "", fmt.Errorf("download failed (remote file intact): %w", err) + } } if !keepAfterDownload { diff --git a/cf_cli_java_plugin_test.go b/cf_cli_java_plugin_test.go new file mode 100644 index 0000000..b2c6afe --- /dev/null +++ b/cf_cli_java_plugin_test.go @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2024 SAP SE or an SAP affiliate company. All rights reserved. + * This file is licensed under the Apache Software License, v. 2 except as noted + * otherwise in the LICENSE file at the root of the repository. + */ + +package main + +import ( + "strings" + "testing" + + plugin_models "code.cloudfoundry.org/cli/plugin/models" +) + +const testAppName = "myapp" + +// fakeConn is a stub CliConnection that knows about one app by name. +type fakeConn struct { + knownApp string +} + +func (f *fakeConn) GetApp(name string) (plugin_models.GetAppModel, error) { + if name == f.knownApp { + return plugin_models.GetAppModel{}, nil + } + return plugin_models.GetAppModel{}, ¬FoundError{name} +} + +type notFoundError struct{ name string } + +func (e *notFoundError) Error() string { return "app not found: " + e.name } + +func (f *fakeConn) CliCommandWithoutTerminalOutput(args ...string) ([]string, error) { + return nil, nil +} +func (f *fakeConn) CliCommand(args ...string) ([]string, error) { return nil, nil } +func (f *fakeConn) GetCurrentOrg() (plugin_models.Organization, error) { + return plugin_models.Organization{}, nil +} +func (f *fakeConn) GetCurrentSpace() (plugin_models.Space, error) { return plugin_models.Space{}, nil } +func (f *fakeConn) Username() (string, error) { return "", nil } +func (f *fakeConn) UserGuid() (string, error) { return "", nil } +func (f *fakeConn) UserEmail() (string, error) { return "", nil } +func (f *fakeConn) IsLoggedIn() (bool, error) { return false, nil } +func (f *fakeConn) IsSSLDisabled() (bool, error) { return false, nil } +func (f *fakeConn) HasOrganization() (bool, error) { return false, nil } +func (f *fakeConn) HasSpace() (bool, error) { return false, nil } +func (f *fakeConn) ApiEndpoint() (string, error) { return "", nil } +func (f *fakeConn) ApiVersion() (string, error) { return "", nil } +func (f *fakeConn) HasAPIEndpoint() (bool, error) { return false, nil } +func (f *fakeConn) LoggregatorEndpoint() (string, error) { return "", nil } +func (f *fakeConn) DopplerEndpoint() (string, error) { return "", nil } +func (f *fakeConn) AccessToken() (string, error) { return "", nil } +func (f *fakeConn) GetApps() ([]plugin_models.GetAppsModel, error) { return nil, nil } +func (f *fakeConn) GetOrgs() ([]plugin_models.GetOrgs_Model, error) { return nil, nil } +func (f *fakeConn) GetSpaces() ([]plugin_models.GetSpaces_Model, error) { return nil, nil } +func (f *fakeConn) GetOrgUsers(string, ...string) ([]plugin_models.GetOrgUsers_Model, error) { + return nil, nil +} + +func (f *fakeConn) GetSpaceUsers(string, string) ([]plugin_models.GetSpaceUsers_Model, error) { + return nil, nil +} +func (f *fakeConn) GetServices() ([]plugin_models.GetServices_Model, error) { return nil, nil } +func (f *fakeConn) GetService(string) (plugin_models.GetService_Model, error) { + return plugin_models.GetService_Model{}, nil +} + +func (f *fakeConn) GetOrg(string) (plugin_models.GetOrg_Model, error) { + return plugin_models.GetOrg_Model{}, nil +} + +func (f *fakeConn) GetSpace(string) (plugin_models.GetSpace_Model, error) { + return plugin_models.GetSpace_Model{}, nil +} + +func TestExecute_SwappedAppAndCommand(t *testing.T) { + p := &JavaPlugin{} + conn := &fakeConn{knownApp: testAppName} + // User typed: cf java myapp heap-dump (app and command swapped) + _, err := p.execute(conn, []string{cmdJava, testAppName, cmdHeapDump}) + if err == nil { + t.Fatal("expected error, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "cf java "+cmdHeapDump+" "+testAppName) { + t.Errorf("expected corrected command in error message, got: %s", msg) + } +} + +func TestExecute_UnrecognizedCommand_NoSwapHint_WhenAppUnknown(t *testing.T) { + p := &JavaPlugin{} + conn := &fakeConn{knownApp: "other-app"} + _, err := p.execute(conn, []string{cmdJava, "bogus-command", testAppName}) + if err == nil { + t.Fatal("expected error, got nil") + } + if strings.Contains(err.Error(), "swapped") { + t.Errorf("should not suggest swap when first arg is not a known app: %s", err.Error()) + } +} + +func TestExecute_SwappedAppAndCommand_AppNotInCF(t *testing.T) { + // cf java sapmachine17 vm-vitals where sapmachine17 IS a known app β€” should suggest swap + p := &JavaPlugin{} + conn := &fakeConn{knownApp: "sapmachine17"} + _, err := p.execute(conn, []string{cmdJava, "sapmachine17", cmdVmVitals}) + if err == nil { + t.Fatal("expected error, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "cf java "+cmdVmVitals+" sapmachine17") { + t.Errorf("expected swap suggestion in error message, got: %s", msg) + } +} + +func TestExecute_NoSwapHint_WhenFirstArgNotKnownApp(t *testing.T) { + // cf java sapmachiasdfadfadfne17 vm-vitals β€” CF doesn't know this app, no swap hint + p := &JavaPlugin{} + conn := &fakeConn{knownApp: "other-app"} + _, err := p.execute(conn, []string{cmdJava, "sapmachiasdfadfadfne17", cmdVmVitals}) + if err == nil { + t.Fatal("expected error, got nil") + } + msg := err.Error() + if strings.Contains(msg, "swapped") { + t.Errorf("should not suggest swap when CF doesn't know the first arg as an app: %s", msg) + } +} + +func TestParseOptions_Open(t *testing.T) { + p := &JavaPlugin{} + opts, _, err := p.parseOptions([]string{cmdHeapDump, testAppName, "--" + flagOpen}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !opts.Open { + t.Error("expected Open=true") + } + if opts.OpenURL != defaultOpenURL { + t.Errorf("expected OpenURL=%q, got %q", defaultOpenURL, opts.OpenURL) + } +} + +func TestParseOptions_OpenURL_ImpliesOpen(t *testing.T) { + p := &JavaPlugin{} + opts, _, err := p.parseOptions([]string{cmdHeapDump, testAppName, "--" + flagOpenURL, "http://localhost:8080"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !opts.Open { + t.Error("--open-url should imply Open=true") + } + if opts.OpenURL != "http://localhost:8080" { + t.Errorf("unexpected OpenURL: %q", opts.OpenURL) + } +} + +func TestParseOptions_Open_NoDownload_Error(t *testing.T) { + p := &JavaPlugin{} + _, _, err := p.parseOptions([]string{cmdHeapDump, testAppName, "--" + flagOpen, "--" + flagNoDownload}) + if err == nil { + t.Fatal("expected error for --open + --no-download, got nil") + } +} + +func TestParseOptions_Open_DryRun_NoError(t *testing.T) { + p := &JavaPlugin{} + opts, _, err := p.parseOptions([]string{cmdHeapDump, testAppName, "--" + flagOpen, "--dry-run"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !opts.Open { + t.Error("expected Open=true") + } + if !opts.DryRun { + t.Error("expected DryRun=true") + } +} diff --git a/dist/jstall-minimal.jar b/dist/jstall-minimal.jar new file mode 100644 index 0000000..1e52c10 Binary files /dev/null and b/dist/jstall-minimal.jar differ diff --git a/go.mod b/go.mod index ebd3fee..98ce3c4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module cf.plugin.ref/requires -go 1.25.0 +go 1.26.8 require ( code.cloudfoundry.org/cli v0.0.0-20250623142502-fb19e7a825ee @@ -33,10 +33,10 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/vito/go-interact v0.0.0-20171111012221-fa338ed9e9ec // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 8a0da8a..23378ac 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= @@ -211,6 +213,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -243,11 +246,15 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -256,6 +263,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -267,6 +276,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/jstall.go b/jstall.go index e5ef136..732c7c3 100644 --- a/jstall.go +++ b/jstall.go @@ -10,6 +10,7 @@ import ( "crypto/sha256" _ "embed" "encoding/hex" + "errors" "fmt" "os" "os/exec" @@ -19,13 +20,15 @@ import ( "strings" "github.com/google/shlex" + + "cf.plugin.ref/requires/utils" ) //go:embed dist/jstall-minimal.jar var jstallJarBytes []byte func javaExecutable() string { - if runtime.GOOS == "windows" { + if runtime.GOOS == osWindows { return "java.exe" } return cmdJava @@ -76,7 +79,7 @@ func platformJavaCandidates() []string { case "linux": matches, _ := filepath.Glob("/usr/lib/jvm/*/bin/" + exe) candidates = append(candidates, matches...) - case "windows": + case osWindows: for _, envVar := range []string{"ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"} { base := os.Getenv(envVar) if base == "" { @@ -170,9 +173,41 @@ func formatCommandForDisplay(command string, args []string) string { return command + " " + strings.Join(displayArgs, " ") } -// shellQuote wraps s in single quotes, escaping any single quotes within. -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +// jstallWindowsQuotingFix is a JVM system property that fixes `cf java status`/`jstall` on Windows. +// jstall sends its remote shell snippets to the app container by running +// `ProcessBuilder("cf", "ssh", APP, "-c", payload)`. The payload contains embedded double quotes +// (e.g. `if [ -n "$JAVA_HOME" ] ...; jcmd "123" "Thread.print"`). With the JDK default +// (jdk.lang.Process.allowAmbiguousCommands=true -> VERIFICATION_WIN32), Java wraps such an argument +// in quotes WITHOUT escaping the embedded quotes, so cf.exe's Windows argv parser shreds it into +// many broken tokens (quotes lost, payload split at spaces) and the command fails. +// Setting allowAmbiguousCommands=false switches Java to VERIFICATION_WIN32_SAFE, which escapes +// embedded quotes with backslashes, and the payload arrives at cf.exe exactly as jstall built it. +// The property only affects process creation on Windows; it is inert on other platforms. +const jstallWindowsQuotingFix = "-Djdk.lang.Process.allowAmbiguousCommands=false" + +// buildJstallArgs assembles the arguments for the jstall JVM invocation (without the java binary). +func buildJstallArgs(jarPath, appName, jstallArgs string, appInstanceIndex int) ([]string, error) { + args := []string{jstallWindowsQuotingFix, "-jar", jarPath} + + // Use --cf which jstall translates to "cf ssh -c" internally via ProcessBuilder + // (no sh -c wrapper since v0.7.2). For instance index, fall back to --ssh + // since --cf doesn't support it. See jstallWindowsQuotingFix for why remote + // execution additionally needs the system property set above on Windows. + if appInstanceIndex != -1 { + sshCmd := "cf ssh " + utils.ShellSingleQuote(appName) + " --app-instance-index " + strconv.Itoa(appInstanceIndex) + " -c" + args = append(args, "--ssh", sshCmd) + } else { + args = append(args, "--cf", appName) + } + + if jstallArgs != "" { + splitArgs, err := shlex.Split(jstallArgs) + if err != nil { + return nil, fmt.Errorf("invalid jstall arguments: %w", err) + } + args = append(args, splitArgs...) + } + return args, nil } func (c *JavaPlugin) executeJstall(appName string, jstallArgs string, appInstanceIndex int, dryRun bool) (string, error) { @@ -188,26 +223,9 @@ func (c *JavaPlugin) executeJstall(appName string, jstallArgs string, appInstanc } c.logVerbosef("JStall JAR at: %s", jarPath) - args := []string{"-jar", jarPath} - - // Build SSH command with PATH setup so jps/jcmd are discoverable on remote container - // SAP Java Buildpack puts JDK tools at deep paths not on $PATH - pathSetup := `JDK_BIN=$(dirname "$(find . -executable -name jps 2>/dev/null | head -1)" 2>/dev/null); if [ -n "$JDK_BIN" ]; then export PATH="$JDK_BIN:$PATH"; fi;` - // Shell-quote appName to prevent command injection via a maliciously named CF app. - sshCmd := "cf ssh " + shellQuote(appName) - if appInstanceIndex != -1 { - sshCmd += " --app-instance-index " + strconv.Itoa(appInstanceIndex) - } - sshCmd += " -c" - args = append(args, "--ssh", sshCmd) - args = append(args, "--ssh-prefix", pathSetup) - - if jstallArgs != "" { - splitArgs, err := shlex.Split(jstallArgs) - if err != nil { - return "", fmt.Errorf("invalid jstall arguments: %w", err) - } - args = append(args, splitArgs...) + args, err := buildJstallArgs(jarPath, appName, jstallArgs, appInstanceIndex) + if err != nil { + return "", err } displayCmd := formatCommandForDisplay(javaPath, args) @@ -241,6 +259,12 @@ func (c *JavaPlugin) executeJstall(appName string, jstallArgs string, appInstanc cmd.Stdin = os.Stdin if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 10 { + // Exit code 10 means jstall ran successfully but found a warning condition + // (e.g. outdated JVM). Output was already printed; treat as success. + return "", nil + } return "", fmt.Errorf("jstall execution failed: %w", err) } return "", nil diff --git a/open.go b/open.go new file mode 100644 index 0000000..e67cebe --- /dev/null +++ b/open.go @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2024 SAP SE or an SAP affiliate company. All rights reserved. + * This file is licensed under the Apache Software License, v. 2 except as noted + * otherwise in the LICENSE file at the root of the repository. + */ + +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "time" +) + +// serveFileOnce starts a local HTTP server on a random port that serves path +// exactly once. The file is exposed under a random token path (e.g. /a3f9c2.hprof) +// so the local filename is never leaked and only the holder of the URL can fetch it. +// Returns the bound port, the randomised URL path segment, and a channel that +// closes when the first GET request completes or timeout elapses. +// timeout 0 means no timeout. +func serveFileOnce(path string, timeout time.Duration) (port int, urlFile string, done <-chan struct{}, err error) { + info, statErr := os.Stat(path) + if statErr != nil { + return 0, "", nil, fmt.Errorf("file not found: %w", statErr) + } + if !info.Mode().IsRegular() { + return 0, "", nil, fmt.Errorf("path is not a regular file: %s", path) + } + + // Build a random token + preserve only the file extension (.hprof or .hprof.gz). + var tokenBytes [8]byte + if _, err = rand.Read(tokenBytes[:]); err != nil { + return 0, "", nil, fmt.Errorf("could not generate token: %w", err) + } + token := hex.EncodeToString(tokenBytes[:]) + ext := extHprof + if strings.HasSuffix(path, extHprofGz) { + ext = extHprofGz + } + urlFile = token + ext + exactPath := "/" + urlFile + exactEscapedPath := (&url.URL{Path: exactPath}).EscapedPath() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, "", nil, fmt.Errorf("could not bind local port: %w", err) + } + port = ln.Addr().(*net.TCPAddr).Port + + doneCh := make(chan struct{}) + var shutdownOnce sync.Once + var srv *http.Server + srv = &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != exactPath || r.URL.EscapedPath() != exactEscapedPath || r.URL.RawQuery != "" { + http.NotFound(w, r) + return + } + w.Header().Set("Access-Control-Allow-Origin", "*") + // Answer CORS preflight without serving the file or triggering shutdown. + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + f, ferr := os.Open(path) //nolint:gosec // path comes from plugin internals, not user input + if ferr != nil { + http.Error(w, "file unavailable", http.StatusInternalServerError) + return + } + defer func() { _ = f.Close() }() + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + if _, copyErr := io.Copy(w, f); copyErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed while serving heap dump: %v\n", copyErr) + return + } + // Use a fresh context: r.Context() is canceled when the handler returns, + // but Shutdown must outlive the request. + // sync.Once ensures concurrent GETs can't double-close doneCh (panic). + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) //nolint:contextcheck + go func(ctx context.Context, cancel context.CancelFunc) { //nolint:contextcheck + defer cancel() + shutdownOnce.Do(func() { + close(doneCh) + _ = srv.Shutdown(ctx) + }) + }(shutdownCtx, shutdownCancel) + }), + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + // No WriteTimeout: heap dumps can be several GB; a deadline would abort + // the in-progress transfer to the browser on slow machines or large files. + } + + go func() { _ = srv.Serve(ln) }() + + if timeout > 0 { + go func() { + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-doneCh: + case <-timer.C: + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) //nolint:contextcheck + defer shutdownCancel() + shutdownOnce.Do(func() { + fmt.Fprintf(os.Stderr, "Timed out waiting for browser to fetch heap dump; closing local server.\n") + close(doneCh) + _ = srv.Shutdown(shutdownCtx) + }) + } + }() + } + + return port, urlFile, doneCh, nil +} + +// buildOpenURL constructs the hprof-analyzer URL with the ?file= parameter. +// base is the analyzer base URL (trailing slash optional). +// Use port=0 to produce a PORT placeholder (for dry-run output). +func buildOpenURL(base string, port int, filename string) string { + hadTrailingSlash := strings.HasSuffix(base, "/") + base = strings.TrimRight(base, "/") + portStr := fmt.Sprintf("%d", port) + if port == 0 { + portStr = "PORT" + } + + parsedBase, err := url.Parse(base) + if err != nil || parsedBase.Scheme == "" || parsedBase.Host == "" { + return fmt.Sprintf("%s/?file=%s", base, url.QueryEscape(fmt.Sprintf("http://localhost:%s/%s", portStr, filename))) + } + + fileURL := &url.URL{ + Scheme: "http", + Host: "localhost:" + portStr, + Path: "/" + filename, + } + query := parsedBase.Query() + query.Set("file", fileURL.String()) + parsedBase.RawQuery = query.Encode() + if hadTrailingSlash && !strings.HasSuffix(parsedBase.Path, "/") { + parsedBase.Path += "/" + } + if parsedBase.RawPath == "" { + parsedBase.RawPath = parsedBase.Path + } + if !hadTrailingSlash && parsedBase.RawQuery != "" && !strings.HasSuffix(parsedBase.Path, "/") && parsedBase.RawPath == parsedBase.Path { + parsedBase.Path += "/" + parsedBase.RawPath += "/" + } + return parsedBase.String() +} + +// openBrowser opens url in the system default browser. +// If launch fails, prints the URL to stdout so the user can open it manually. +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case osWindows: + cmd = exec.Command("cmd", "/c", "start", "", url) + default: + cmd = exec.Command("xdg-open", url) + } + if err := cmd.Start(); err != nil { + fmt.Printf("Could not open browser (%v). Open manually: %s\n", err, url) + } +} diff --git a/open_test.go b/open_test.go new file mode 100644 index 0000000..097c725 --- /dev/null +++ b/open_test.go @@ -0,0 +1,397 @@ +package main + +import ( + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestServeFileOnce_TimeoutClosesServer(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "test.hprof") + if err := os.WriteFile(p, []byte("DATA"), 0o600); err != nil { + t.Fatal(err) + } + + port, urlFile, done, err := serveFileOnce(p, 100*time.Millisecond) + if err != nil { + t.Fatalf("serveFileOnce: %v", err) + } + + // done must close within a reasonable time without any GET + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("done channel not closed after timeout elapsed") + } + + // server must be gone β€” further requests should fail + url := fmt.Sprintf("http://localhost:%d/%s", port, urlFile) + resp, gerr := http.Get(url) //nolint:noctx,gosec + if gerr == nil { + _ = resp.Body.Close() + t.Error("expected connection refused after server shutdown, but got a response") + } +} + +func TestServeFileOnce(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "test.hprof") + if err := os.WriteFile(p, []byte("HEAP_CONTENT"), 0o600); err != nil { + t.Fatal(err) + } + + port, urlFile, done, err := serveFileOnce(p, 30*time.Second) + if err != nil { + t.Fatalf("serveFileOnce: %v", err) + } + if port <= 0 { + t.Fatalf("expected positive port, got %d", port) + } + + // urlFile must have .hprof extension and contain only the token (no path separators) + if !strings.HasSuffix(urlFile, ".hprof") { + t.Errorf("urlFile %q does not end with .hprof", urlFile) + } + if strings.ContainsAny(urlFile, "/\\") { + t.Errorf("urlFile %q contains path separators", urlFile) + } + + url := fmt.Sprintf("http://localhost:%d/%s", port, urlFile) + resp, err := http.Get(url) //nolint:noctx,gosec + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + t.Errorf("body close: %v", closeErr) + } + }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/octet-stream" { + t.Errorf("Content-Type: want application/octet-stream, got %s", ct) + } + if acao := resp.Header.Get("Access-Control-Allow-Origin"); acao != "*" { + t.Errorf("Access-Control-Allow-Origin: want *, got %s", acao) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "HEAP_CONTENT" { + t.Errorf("body: want HEAP_CONTENT, got %s", body) + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("done channel not closed after successful GET") + } +} + +func TestServeFileOnce_WrongPath404(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "test.hprof") + if err := os.WriteFile(p, []byte("SECRET"), 0o600); err != nil { + t.Fatal(err) + } + + port, _, _, err := serveFileOnce(p, 30*time.Second) + if err != nil { + t.Fatalf("serveFileOnce: %v", err) + } + + // Any path other than the exact random token must return 404 + for _, badPath := range []string{"/test.hprof", "/", "/other.hprof", "/../../etc/passwd"} { + url := fmt.Sprintf("http://localhost:%d%s", port, badPath) + resp, gerr := http.Get(url) //nolint:noctx,gosec + if gerr != nil { + continue // server may have shut down already, that's fine + } + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + t.Errorf("GET %s: expected non-200, got 200", url) + } + } +} + +func TestServeFileOnce_GzExtension(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "test.hprof.gz") + if err := os.WriteFile(p, []byte("GZ"), 0o600); err != nil { + t.Fatal(err) + } + + _, urlFile, _, err := serveFileOnce(p, 30*time.Second) + if err != nil { + t.Fatalf("serveFileOnce: %v", err) + } + if !strings.HasSuffix(urlFile, ".hprof.gz") { + t.Errorf("urlFile %q does not end with .hprof.gz", urlFile) + } +} + +func TestServeFileOnce_MissingFile(t *testing.T) { + _, _, _, err := serveFileOnce("/nonexistent/path/dump.hprof", 30*time.Second) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestServeFileOnce_OptionsPreflight(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "test.hprof") + if err := os.WriteFile(p, []byte("DATA"), 0o600); err != nil { + t.Fatal(err) + } + + port, urlFile, done, err := serveFileOnce(p, 30*time.Second) + if err != nil { + t.Fatalf("serveFileOnce: %v", err) + } + + url := fmt.Sprintf("http://localhost:%d/%s", port, urlFile) + + // OPTIONS preflight must not trigger shutdown + req, _ := http.NewRequest(http.MethodOptions, url, nil) //nolint:noctx + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("OPTIONS %s: %v", url, err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("OPTIONS: want 204, got %d", resp.StatusCode) + } + select { + case <-done: + t.Error("done channel closed after OPTIONS β€” server shut down prematurely") + default: + } + + // Subsequent GET must still succeed and close done + resp2, err := http.Get(url) //nolint:noctx,gosec + if err != nil { + t.Fatalf("GET after OPTIONS: %v", err) + } + defer func() { _ = resp2.Body.Close() }() + if resp2.StatusCode != http.StatusOK { + t.Errorf("GET after OPTIONS: want 200, got %d", resp2.StatusCode) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("done channel not closed after GET") + } +} + +func TestBuildOpenURL(t *testing.T) { + cases := []struct { + base string + port int + filename string + want string + }{ + { + "https://parttimenerd.github.io/hprof-analyzer", + 54321, + "myapp-heapdump-abc.hprof", + "https://parttimenerd.github.io/hprof-analyzer/?file=http%3A%2F%2Flocalhost%3A54321%2Fmyapp-heapdump-abc.hprof", + }, + { + "https://parttimenerd.github.io/hprof-analyzer/", + 9000, + "dump.hprof.gz", + "https://parttimenerd.github.io/hprof-analyzer/?file=http%3A%2F%2Flocalhost%3A9000%2Fdump.hprof.gz", + }, + { + "https://parttimenerd.github.io/hprof-analyzer", + 0, + "dump.hprof", + "https://parttimenerd.github.io/hprof-analyzer/?file=http%3A%2F%2Flocalhost%3APORT%2Fdump.hprof", + }, + } + for _, tc := range cases { + got := buildOpenURL(tc.base, tc.port, tc.filename) + if got != tc.want { + t.Errorf("buildOpenURL(%q, %d, %q)\n want %q\n got %q", tc.base, tc.port, tc.filename, tc.want, got) + } + } +} + +func TestBuildOpenURL_TrailingSlash(t *testing.T) { + url := buildOpenURL("https://example.com/analyzer/", 1234, "dump.hprof") + if strings.Contains(url, "//?") { + t.Errorf("double slash before ?: %s", url) + } +} + +func TestBuildOpenURL_EscapesFilenameAndPreservesExistingQuery(t *testing.T) { + got := buildOpenURL("https://example.com/analyzer/?theme=dark", 1234, "dump name+#1.hprof.gz") + want := "https://example.com/analyzer/?file=http%3A%2F%2Flocalhost%3A1234%2Fdump%2520name%2B%25231.hprof.gz&theme=dark" + if got != want { + t.Fatalf("buildOpenURL escaped URL mismatch\nwant: %s\n got: %s", want, got) + } +} + +func TestServeFileOnce_RejectsDirectory(t *testing.T) { + tmp := t.TempDir() + _, _, _, err := serveFileOnce(tmp, time.Second) + if err == nil { + t.Fatal("expected error for directory input, got nil") + } + if !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("expected regular file error, got: %v", err) + } +} + +func TestServeFileOnce_RejectsPathVariants(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "test.hprof") + if err := os.WriteFile(p, []byte("SECRET"), 0o600); err != nil { + t.Fatal(err) + } + + port, urlFile, done, err := serveFileOnce(p, 30*time.Second) + if err != nil { + t.Fatalf("serveFileOnce: %v", err) + } + + badURLs := []string{ + fmt.Sprintf("http://localhost:%d//%s", port, urlFile), + fmt.Sprintf("http://localhost:%d/%s/", port, urlFile), + fmt.Sprintf("http://localhost:%d/%s%%2f", port, urlFile), + fmt.Sprintf("http://localhost:%d/%s?extra=1", port, url.QueryEscape(urlFile)), + } + + for _, rawURL := range badURLs { + resp, gerr := http.Get(rawURL) //nolint:noctx,gosec + if gerr != nil { + t.Fatalf("GET %s: %v", rawURL, gerr) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("GET %s: want 404, got %d", rawURL, resp.StatusCode) + } + } + + select { + case <-done: + t.Fatal("done channel closed after non-exact path variant request") + default: + } + + goodURL := fmt.Sprintf("http://localhost:%d/%s", port, urlFile) + resp, err := http.Get(goodURL) //nolint:noctx,gosec + if err != nil { + t.Fatalf("GET %s: %v", goodURL, err) + } + _, _ = io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s: want 200, got %d", goodURL, resp.StatusCode) + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("done channel not closed after exact path GET") + } +} + +func TestServeFileOnce_ConcurrentGETsNoPanic(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "test.hprof") + if err := os.WriteFile(p, []byte("CONCURRENT"), 0o600); err != nil { + t.Fatal(err) + } + + port, urlFile, done, err := serveFileOnce(p, 30*time.Second) + if err != nil { + t.Fatalf("serveFileOnce: %v", err) + } + + url := fmt.Sprintf("http://localhost:%d/%s", port, urlFile) + + // Fire two GETs simultaneously; neither should panic and done must close exactly once. + errs := make(chan error, 2) + for range 2 { + go func() { + resp, gerr := http.Get(url) //nolint:noctx,gosec + if gerr == nil { + _, _ = io.ReadAll(resp.Body) + _ = resp.Body.Close() + } + errs <- gerr + }() + } + + // Collect both results β€” one may get a connection-refused after server shuts down + for range 2 { + <-errs + } + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("done channel not closed after concurrent GETs") + } +} + +func TestServeFileOnce_ClientDisconnectDoesNotConsumeSingleServe(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "test.hprof") + // 16 MB β€” must exceed the combined TCP send+receive buffer so io.Copy blocks + // when the client disconnects mid-transfer, preventing a spurious shutdown trigger. + content := strings.Repeat("HEAP_CONTENT", 1<<20) + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + port, urlFile, done, err := serveFileOnce(p, 30*time.Second) + if err != nil { + t.Fatalf("serveFileOnce: %v", err) + } + + conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + t.Fatalf("Dial: %v", err) + } + _, _ = fmt.Fprintf(conn, "GET /%s HTTP/1.1\r\nHost: localhost\r\n\r\n", urlFile) + _ = conn.Close() + + select { + case <-done: + t.Fatal("done channel closed after client disconnected before successful transfer") + case <-time.After(200 * time.Millisecond): + } + + goodURL := fmt.Sprintf("http://localhost:%d/%s", port, urlFile) + resp, err := http.Get(goodURL) //nolint:noctx,gosec + if err != nil { + t.Fatalf("GET %s: %v", goodURL, err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s: want 200, got %d", goodURL, resp.StatusCode) + } + if string(body) != content { + t.Fatalf("unexpected body length/content after retry") + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("done channel not closed after successful retry GET") + } +} diff --git a/redact.go b/redact.go new file mode 100644 index 0000000..f97fc89 --- /dev/null +++ b/redact.go @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2024 SAP SE or an SAP affiliate company. All rights reserved. + * This file is licensed under the Apache Software License, v. 2 except as noted + * otherwise in the LICENSE file at the root of the repository. + */ + +package main + +import ( + "crypto/sha256" + _ "embed" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +//go:embed dist/hprof-redact-linux-amd64 +var hprofRedactLinuxAmd64 []byte + +//go:embed dist/hprof-redact-linux-arm64 +var hprofRedactLinuxArm64 []byte + +//go:embed dist/hprof-redact-darwin-arm64 +var hprofRedactDarwinArm64 []byte + +//go:embed dist/hprof-redact-windows-amd64.exe +var hprofRedactWindowsAmd64 []byte + +//go:embed dist/hprof-redact-windows-arm64.exe +var hprofRedactWindowsArm64 []byte + +// hprofRedactBytes returns the embedded hprof-redact binary for the current platform, +// or (nil, false) if this platform is not supported. +func hprofRedactBytes() ([]byte, bool) { + switch runtime.GOOS + "/" + runtime.GOARCH { + case "linux/amd64": + return hprofRedactLinuxAmd64, true + case "linux/arm64": + return hprofRedactLinuxArm64, true + case "darwin/arm64": + return hprofRedactDarwinArm64, true + case osWindows + "/amd64": + return hprofRedactWindowsAmd64, true + case osWindows + "/arm64": + return hprofRedactWindowsArm64, true + default: + return nil, false + } +} + +// ensureHprofRedact extracts the embedded hprof-redact binary to the plugin cache +// directory (same location as jstall) and returns its path. +func ensureHprofRedact() (string, error) { + data, ok := hprofRedactBytes() + if !ok { + return "", fmt.Errorf("hprof-redact is not available for %s/%s; install manually: https://github.com/parttimenerd/hprof-analyzer/releases", runtime.GOOS, runtime.GOARCH) + } + if len(data) == 0 { + return "", fmt.Errorf("hprof-redact for %s/%s was not available at build time; install manually: https://github.com/parttimenerd/hprof-analyzer/releases", runtime.GOOS, runtime.GOARCH) + } + + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + pluginCacheDir := filepath.Join(cacheDir, "cf-java-plugin") + if err := os.MkdirAll(pluginCacheDir, 0o755); err != nil { //nolint:gosec // 0755 is correct for a cache dir + return "", err + } + + h := sha256.Sum256(data) + hash := hex.EncodeToString(h[:8]) + binPath := filepath.Join(pluginCacheDir, fmt.Sprintf("hprof-redact-%s", hash)) + if runtime.GOOS == osWindows { + binPath += ".exe" + } + + // Re-use if already extracted + if _, err := os.Stat(binPath); err == nil { + return binPath, nil + } + + if err := os.WriteFile(binPath, data, 0o755); err != nil { //nolint:gosec // 0755: binary must be executable + return "", fmt.Errorf("failed to extract hprof-redact: %w", err) + } + return binPath, nil +} + +// pipeHeapDumpThroughRedact streams heap dump bytes through hprof-redact using +// stdin (`hprof-redact -`) and writes only the requested output path. +// +// mode must be "lean" or "complete". outputBasePath must end in .hprof or .hprof.gz. +func pipeHeapDumpThroughRedact(redactBin string, input io.Reader, outputBasePath, mode string, keepOnError bool) (string, error) { + if !strings.HasSuffix(outputBasePath, extHprof) && !strings.HasSuffix(outputBasePath, extHprofGz) { + return "", fmt.Errorf("unsupported heap dump path %q: expected %s or %s suffix", outputBasePath, extHprof, extHprofGz) + } + outputPath := outputBasePath + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { //nolint:gosec // local output dir for plugin-managed file + return "", fmt.Errorf("cannot create local directory %s: %w", filepath.Dir(outputPath), err) + } + + var args []string + if mode == "complete" { + args = append(args, "--complete") + } + args = append(args, "-", outputPath) + + cmd := exec.Command(redactBin, args...) //nolint:gosec // redactBin comes from ensureHprofRedact, not user input + cmd.Stdin = input + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Run() + if err != nil { + if !keepOnError { + if rmErr := os.Remove(outputPath); rmErr != nil && !os.IsNotExist(rmErr) { + fmt.Fprintf(os.Stderr, "warning: could not remove partial redacted file %s: %v\n", outputPath, rmErr) + } + } + return "", fmt.Errorf("hprof-redact failed: %w", err) + } + + return outputPath, nil +} + +func combineHeapDumpStreamErrors(redactErr, closeErr, waitErr error) error { + parts := make([]string, 0, 3) + joined := make([]error, 0, 3) + + if redactErr != nil { + parts = append(parts, "redaction failed") + joined = append(joined, redactErr) + } + if closeErr != nil { + parts = append(parts, "closing redaction input stream failed") + joined = append(joined, closeErr) + } + if waitErr != nil { + parts = append(parts, "remote heap dump stream failed") + joined = append(joined, waitErr) + } + if len(joined) == 0 { + return nil + } + + return fmt.Errorf("%s: %w", strings.Join(parts, "; "), errors.Join(joined...)) +} diff --git a/redact_test.go b/redact_test.go new file mode 100644 index 0000000..e0005c1 --- /dev/null +++ b/redact_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "bytes" + "compress/gzip" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// makeFakeBin writes a script that exits with the given code and returns its path. +// On Windows it writes a .bat file; on Unix a shell script. +func makeFakeBin(t *testing.T, exitCode int) string { + t.Helper() + tmp := t.TempDir() + if runtime.GOOS == osWindows { + bin := filepath.Join(tmp, "fake-redact.bat") + script := fmt.Sprintf("@echo off\r\nexit /b %d\r\n", exitCode) + if err := os.WriteFile(bin, []byte(script), 0o600); err != nil { + t.Fatalf("write fake bin: %v", err) + } + return bin + } + bin := filepath.Join(tmp, "fake-redact") + script := fmt.Sprintf("#!/bin/sh\nexit %d\n", exitCode) + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { //nolint:gosec // test binary must be executable + t.Fatalf("write fake bin: %v", err) + } + return bin +} + +// makeCopyBin writes a script that copies stdin to $2 (Unix) or %2 (Windows). +func makeCopyBin(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + if runtime.GOOS == osWindows { + bin := filepath.Join(tmp, "fake-redact.bat") + // Use PowerShell to copy stdin in binary mode; `more` corrupts non-text bytes. + script := "@echo off\r\npowershell -Command \"$in=[System.Console]::OpenStandardInput();$out=[System.IO.File]::OpenWrite('%2');$in.CopyTo($out);$out.Close()\"\r\nexit /b 0\r\n" + if err := os.WriteFile(bin, []byte(script), 0o600); err != nil { + t.Fatalf("write copy bin: %v", err) + } + return bin + } + bin := filepath.Join(tmp, "fake-redact") + script := "#!/bin/sh\ncat - > \"$2\"\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { //nolint:gosec // test binary must be executable + t.Fatalf("write copy bin: %v", err) + } + return bin +} + +// makeWriteAndFailBin writes a script that writes PARTIAL to $2/%2 then exits 1. +func makeWriteAndFailBin(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + if runtime.GOOS == osWindows { + bin := filepath.Join(tmp, "fake-redact.bat") + script := "@echo off\r\necho PARTIAL> \"%2\"\r\nexit /b 1\r\n" + if err := os.WriteFile(bin, []byte(script), 0o600); err != nil { + t.Fatalf("write write-and-fail bin: %v", err) + } + return bin + } + bin := filepath.Join(tmp, "fake-redact") + script := "#!/bin/sh\necho PARTIAL > \"$2\"\nexit 1\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { //nolint:gosec // test binary must be executable + t.Fatalf("write write-and-fail bin: %v", err) + } + return bin +} + +func TestPipeHeapDumpThroughRedact_ErrorDeletesPartial(t *testing.T) { + tmp := t.TempDir() + outputPath := filepath.Join(tmp, "dump.hprof") + + failBin := makeFakeBin(t, 1) + _, err := pipeHeapDumpThroughRedact(failBin, bytes.NewBufferString("FAKE"), outputPath, "lean", false) + if err == nil { + t.Fatal("expected error from failing redact binary, got nil") + } + + if _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) { + t.Error("partial redacted file should have been deleted on error, but still exists") + } +} + +func TestPipeHeapDumpThroughRedact_KeepOnError(t *testing.T) { + tmp := t.TempDir() + outputPath := filepath.Join(tmp, "dump.hprof") + + bin := makeWriteAndFailBin(t) + _, err := pipeHeapDumpThroughRedact(bin, bytes.NewBufferString("FAKE"), outputPath, "lean", true) + if err == nil { + t.Fatal("expected error from failing redact binary, got nil") + } + + if _, statErr := os.Stat(outputPath); statErr != nil { + t.Error("partial file should have been kept with keepOnError=true, but is gone") + } +} + +func TestPipeHeapDumpThroughRedact_HappyPath(t *testing.T) { + tmp := t.TempDir() + outputPath := filepath.Join(tmp, "dump.hprof") + + bin := makeCopyBin(t) + out, err := pipeHeapDumpThroughRedact(bin, bytes.NewBufferString("HEAP"), outputPath, "lean", false) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + + if out != outputPath { + t.Errorf("output path: want %q, got %q", outputPath, out) + } + if _, statErr := os.Stat(out); statErr != nil { + t.Errorf("output file missing: %v", statErr) + } + data, readErr := os.ReadFile(out) //nolint:gosec // test reads file path produced by helper under test + if readErr != nil { + t.Fatalf("read output: %v", readErr) + } + // Windows `more` appends \r\n; strip for comparison + content := strings.TrimRight(string(data), "\r\n") + if content != "HEAP" { + t.Fatalf("unexpected output content: %q", string(data)) + } +} + +func TestPipeHeapDumpThroughRedact_PassesCompressedInputUnchanged(t *testing.T) { + tmp := t.TempDir() + outputPath := filepath.Join(tmp, "dump.hprof") + + var compressed bytes.Buffer + gz := gzip.NewWriter(&compressed) + if _, err := gz.Write([]byte("HEAP")); err != nil { + t.Fatalf("gzip write: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + + bin := makeCopyBin(t) + out, err := pipeHeapDumpThroughRedact(bin, bytes.NewReader(compressed.Bytes()), outputPath, "lean", false) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + + data, err := os.ReadFile(out) //nolint:gosec // test reads file path produced by helper under test + if err != nil { + t.Fatalf("read output: %v", err) + } + if !bytes.Equal(data, compressed.Bytes()) { + t.Fatal("compressed input was modified before reaching hprof-redact") + } +} + +func TestPipeHeapDumpThroughRedact_RejectsUnexpectedExtension(t *testing.T) { + tmp := t.TempDir() + base := filepath.Join(tmp, "dump.bin") + + bin := makeFakeBin(t, 0) + _, err := pipeHeapDumpThroughRedact(bin, bytes.NewBufferString("HEAP"), base, "lean", false) + if err == nil { + t.Fatal("expected unsupported extension error, got nil") + } +} + +func TestCombineHeapDumpStreamErrors(t *testing.T) { + redactErr := errors.New("redact boom") + closeErr := errors.New("close boom") + waitErr := errors.New("wait boom") + + err := combineHeapDumpStreamErrors(redactErr, closeErr, waitErr) + if err == nil { + t.Fatal("expected combined error, got nil") + } + + for _, want := range []string{ + "redaction failed", + "closing redaction input stream failed", + "remote heap dump stream failed", + "redact boom", + "close boom", + "wait boom", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected combined error to contain %q, got %q", want, err.Error()) + } + } + + if !errors.Is(err, redactErr) || !errors.Is(err, closeErr) || !errors.Is(err, waitErr) { + t.Fatal("expected combined error to match all component errors via errors.Is") + } +} + +func TestCombineHeapDumpStreamErrors_NilWhenNoErrors(t *testing.T) { + if err := combineHeapDumpStreamErrors(nil, nil, nil); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} diff --git a/test/apps/jdk11/manifest.yml b/test/apps/jdk11/manifest.yml new file mode 100644 index 0000000..26da285 --- /dev/null +++ b/test/apps/jdk11/manifest.yml @@ -0,0 +1,14 @@ +--- +applications: +- name: jdk11 + random-route: true + path: workload.jar + memory: 512M + health-check-type: http + health-check-http-endpoint: /health + buildpacks: + - https://github.com/cloudfoundry/java-buildpack.git + env: + JBP_CONFIG_COMPONENTS: '{jres: ["JavaBuildpack::Jre::OpenJdkJRE"]}' + 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:ReservedCodeCacheSize=64M']" diff --git a/test/apps/jdk11/workload.jar b/test/apps/jdk11/workload.jar new file mode 120000 index 0000000..80a31b0 --- /dev/null +++ b/test/apps/jdk11/workload.jar @@ -0,0 +1 @@ +../workload.jar \ No newline at end of file diff --git a/test/apps/jdk17/manifest.yml b/test/apps/jdk17/manifest.yml new file mode 100644 index 0000000..085886d --- /dev/null +++ b/test/apps/jdk17/manifest.yml @@ -0,0 +1,14 @@ +--- +applications: +- name: jdk17 + random-route: true + path: workload.jar + memory: 512M + health-check-type: http + health-check-http-endpoint: /health + buildpacks: + - https://github.com/cloudfoundry/java-buildpack.git + env: + JBP_CONFIG_COMPONENTS: '{jres: ["JavaBuildpack::Jre::OpenJdkJRE"]}' + JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64", version: 17.+ } }' + JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints -XX:ReservedCodeCacheSize=64M']" diff --git a/test/apps/jdk17/workload.jar b/test/apps/jdk17/workload.jar new file mode 120000 index 0000000..80a31b0 --- /dev/null +++ b/test/apps/jdk17/workload.jar @@ -0,0 +1 @@ +../workload.jar \ No newline at end of file diff --git a/test/apps/jdk21/manifest.yml b/test/apps/jdk21/manifest.yml new file mode 100644 index 0000000..a0b16bb --- /dev/null +++ b/test/apps/jdk21/manifest.yml @@ -0,0 +1,14 @@ +--- +applications: +- name: jdk21 + random-route: true + path: workload.jar + memory: 512M + health-check-type: http + health-check-http-endpoint: /health + buildpacks: + - https://github.com/cloudfoundry/java-buildpack.git + env: + JBP_CONFIG_COMPONENTS: '{jres: ["JavaBuildpack::Jre::OpenJdkJRE"]}' + JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64", version: 21.+ } }' + JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints -XX:ReservedCodeCacheSize=64M']" diff --git a/test/apps/jdk21/workload.jar b/test/apps/jdk21/workload.jar new file mode 120000 index 0000000..80a31b0 --- /dev/null +++ b/test/apps/jdk21/workload.jar @@ -0,0 +1 @@ +../workload.jar \ No newline at end of file diff --git a/test/apps/jdk25/manifest.yml b/test/apps/jdk25/manifest.yml new file mode 100644 index 0000000..e8ac0e3 --- /dev/null +++ b/test/apps/jdk25/manifest.yml @@ -0,0 +1,14 @@ +--- +applications: +- name: jdk25 + random-route: true + path: workload.jar + memory: 512M + health-check-type: http + health-check-http-endpoint: /health + buildpacks: + - https://github.com/cloudfoundry/java-buildpack.git + env: + JBP_CONFIG_COMPONENTS: '{jres: ["JavaBuildpack::Jre::OpenJdkJRE"]}' + JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64", version: 25.+ } }' + JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints -XX:ReservedCodeCacheSize=64M']" diff --git a/test/apps/jdk25/workload.jar b/test/apps/jdk25/workload.jar new file mode 120000 index 0000000..80a31b0 --- /dev/null +++ b/test/apps/jdk25/workload.jar @@ -0,0 +1 @@ +../workload.jar \ No newline at end of file diff --git a/test/apps/jre21/manifest.yml b/test/apps/jre21/manifest.yml deleted file mode 100644 index de42ea0..0000000 --- a/test/apps/jre21/manifest.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -applications: -- name: jre21 - random-route: true - path: test.jar - memory: 1024M - buildpacks: - - https://github.com/cloudfoundry/java-buildpack.git - env: - TARGET_RUNTIME: tomcat - JBP_CONFIG_COMPONENTS: '{jres: ["JavaBuildpack::Jre::OpenJdkJRE"]}' - JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { version: 21.+ } }' \ No newline at end of file diff --git a/test/apps/jre21/test.jar b/test/apps/jre21/test.jar deleted file mode 100644 index c998b29..0000000 Binary files a/test/apps/jre21/test.jar and /dev/null differ diff --git a/test/apps/run-on-all.sh b/test/apps/run-on-all.sh new file mode 100755 index 0000000..5853ad4 --- /dev/null +++ b/test/apps/run-on-all.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# run-on-all.sh β€” run a cf java command against all (or filtered) sample apps, +# group results by similarity, and report. +# +# Usage: ./run-on-all.sh [--jdk-only|--sapmachine-only] [--keep-tmp] -- +# +# The app name is inserted automatically after the subcommand: cf java SUBCOMMAND APP [options] +# +# Examples: +# ./run-on-all.sh -- asprof +# ./run-on-all.sh -- status +# ./run-on-all.sh --jdk-only -- heap-dump +# ./run-on-all.sh --keep-tmp -- thread-dump +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +ALL_APPS=(jdk11 sapmachine11 jdk17 sapmachine17 jdk21 sapmachine21 jdk25 sapmachine25) +JDK_APPS=(jdk11 jdk17 jdk21 jdk25) +SAP_APPS=(sapmachine11 sapmachine17 sapmachine21 sapmachine25) + +# ── parse flags ────────────────────────────────────────────────────────────── + +FILTER="all" +KEEP_TMP=false +CF_ARGS=() +PARALLEL=true + +while [[ $# -gt 0 ]]; do + case "$1" in + --jdk-only) FILTER="jdk"; shift ;; + --sapmachine-only) FILTER="sapmachine"; shift ;; + --keep-tmp) KEEP_TMP=true; shift ;; + --sequential) PARALLEL=false; shift ;; + --) shift; CF_ARGS=("$@"); break ;; + *) + echo "Unknown flag: $1" >&2 + echo "Usage: $0 [--jdk-only|--sapmachine-only] [--keep-tmp] [--sequential] -- " >&2 + exit 1 + ;; + esac +done + +if [[ ${#CF_ARGS[@]} -eq 0 ]]; then + echo "ERROR: no cf java arguments supplied after '--'" >&2 + echo "Usage: $0 [--jdk-only|--sapmachine-only] [--keep-tmp] [--sequential] -- " >&2 + exit 1 +fi + +# ── select apps ────────────────────────────────────────────────────────────── + +case "$FILTER" in + jdk) APPS=("${JDK_APPS[@]}") ;; + sapmachine) APPS=("${SAP_APPS[@]}") ;; + *) APPS=("${ALL_APPS[@]}") ;; +esac + +# ── scratch space ───────────────────────────────────────────────────────────── + +WORK_DIR="$(mktemp -d)" +# shellcheck disable=SC2064 +if [[ "$KEEP_TMP" == false ]]; then + trap "rm -rf '$WORK_DIR'" EXIT +else + echo "Tmp dir: $WORK_DIR" +fi + +# ── run ─────────────────────────────────────────────────────────────────────── + +echo "Running: cf java ${CF_ARGS[0]} APP ${CF_ARGS[*]:1}" +echo "Apps: ${APPS[*]}" +echo "" + +run_app() { + local app="$1" + local app_dir="$WORK_DIR/$app" + mkdir -p "$app_dir" + + local out="$app_dir/stdout" + local err="$app_dir/stderr" + local rc_file="$app_dir/rc" + + # Run from the app's own tmp directory so any downloaded files land there. + # Syntax: cf java SUBCOMMAND APP [options] + # CF_ARGS[0] is the subcommand; remaining elements are options/trailing args. + local subcmd="${CF_ARGS[0]}" + local rest=("${CF_ARGS[@]:1}") + ( + cd "$app_dir" + cf java "$subcmd" "$app" "${rest[@]}" >"$out" 2>"$err" + echo $? >"$rc_file" + ) || echo $? >"$rc_file" +} + +if [[ "$PARALLEL" == true ]]; then + pids=() + for app in "${APPS[@]}"; do + run_app "$app" & + pids+=($!) + done + # Wait for all and collect exit codes; don't let `wait` itself abort the script + for pid in "${pids[@]}"; do + wait "$pid" 2>/dev/null || true + done +else + for app in "${APPS[@]}"; do + echo " β†’ $app ..." + run_app "$app" + done +fi + +# Fill in missing rc files (process may have been killed) +for app in "${APPS[@]}"; do + rc_file="$WORK_DIR/$app/rc" + [[ -f "$rc_file" ]] || echo "1" >"$rc_file" +done + +# ── collect results ─────────────────────────────────────────────────────────── + +declare -A SIGNATURE_TO_APPS # signature β†’ space-separated app list +declare -A SIGNATURE_TO_OUT # signature β†’ stdout content +declare -A SIGNATURE_TO_ERR # signature β†’ stderr content +declare -A SIGNATURE_TO_RC # signature β†’ exit code + +for app in "${APPS[@]}"; do + app_dir="$WORK_DIR/$app" + stdout_content="" + stderr_content="" + rc=1 + [[ -f "$app_dir/stdout" ]] && stdout_content="$(cat "$app_dir/stdout")" + [[ -f "$app_dir/stderr" ]] && stderr_content="$(cat "$app_dir/stderr")" + [[ -f "$app_dir/rc" ]] && rc="$(cat "$app_dir/rc")" + + # Build a normalized signature for grouping: + # Strip app-specific parts (app name, timestamps, tmp paths, port numbers, + # PIDs from status output) so structurally identical output hashes the same. + sig="$(printf '%s\n%s\n%s' "$rc" "$stdout_content" "$stderr_content" \ + | sed "s|$app|APP_NAME|g" \ + | sed 's|[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}T[0-9:\.Z-]*|TIMESTAMP|g' \ + | sed 's|/tmp/[^[:space:]]*|TMP_PATH|g' \ + | sed 's|pid=[0-9]*|pid=PID|g' \ + | sed 's|PID: [0-9]*|PID: PID|g' \ + | md5sum | awk '{print $1}')" + + if [[ -v "SIGNATURE_TO_APPS[$sig]" ]]; then + SIGNATURE_TO_APPS["$sig"]+=" $app" + else + SIGNATURE_TO_APPS["$sig"]="$app" + SIGNATURE_TO_OUT["$sig"]="$stdout_content" + SIGNATURE_TO_ERR["$sig"]="$stderr_content" + SIGNATURE_TO_RC["$sig"]="$rc" + fi +done + +# ── report ──────────────────────────────────────────────────────────────────── + +total_groups=${#SIGNATURE_TO_APPS[@]} +group_idx=0 + +for sig in "${!SIGNATURE_TO_APPS[@]}"; do + group_idx=$((group_idx + 1)) + apps_in_group="${SIGNATURE_TO_APPS[$sig]}" + rc="${SIGNATURE_TO_RC[$sig]}" + stdout_content="${SIGNATURE_TO_OUT[$sig]}" + stderr_content="${SIGNATURE_TO_ERR[$sig]}" + + rc_label="OK (rc=0)" + [[ "$rc" != "0" ]] && rc_label="FAILED (rc=$rc)" + + echo "══════════════════════════════════════════════════════════════════" + echo "Group $group_idx/$total_groups β€” $rc_label" + echo "Apps: $apps_in_group" + echo "──────────────────────────────────────────────────────────────────" + + if [[ -n "$stdout_content" ]]; then + echo "$stdout_content" + fi + + if [[ -n "$stderr_content" ]]; then + echo "── stderr ─────────────────────────────────────────────────────────" + echo "$stderr_content" + fi + + echo "" +done + +# ── summary line ────────────────────────────────────────────────────────────── + +total_apps=${#APPS[@]} +failed=0 +for app in "${APPS[@]}"; do + rc_file="$WORK_DIR/$app/rc" + [[ -f "$rc_file" ]] && rc="$(cat "$rc_file")" || rc=1 + [[ "$rc" != "0" ]] && failed=$((failed + 1)) +done +ok=$((total_apps - failed)) + +echo "══════════════════════════════════════════════════════════════════" +echo "Summary: $ok/$total_apps succeeded, $failed failed, $total_groups unique result group(s)" +if [[ "$KEEP_TMP" == true ]]; then + echo "Tmp dir preserved: $WORK_DIR" +fi diff --git a/test/apps/sample-apps.sh b/test/apps/sample-apps.sh new file mode 100755 index 0000000..40de718 --- /dev/null +++ b/test/apps/sample-apps.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# sample-apps.sh β€” manage CF Java plugin test apps +# Usage: ./sample-apps.sh [deploy|undeploy|busy|calm] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +APPS=(jdk11 sapmachine11 jdk17 sapmachine17 jdk21 sapmachine21 jdk25 sapmachine25) +BUSY_LOOP_PID_FILE="/tmp/sample-apps-busy-loop.pid" + +# ── helpers ────────────────────────────────────────────────────────────────── + +die() { echo "ERROR: $*" >&2; exit 1; } + +require_cf() { + command -v cf >/dev/null 2>&1 || die "cf CLI not found on PATH" + cf target >/dev/null 2>&1 || die "Not logged in to CF. Run: cf login" +} + +app_route() { + local app="$1" + cf app "$app" 2>/dev/null \ + | grep -E "^routes:" \ + | awk '{print $2}' \ + | head -1 +} + +http_post() { + local url="$1" + curl -s -o /dev/null -w "%{http_code}" -X POST "$url" 2>/dev/null +} + +# ── deploy ──────────────────────────────────────────────────────────────────── + +cmd_deploy() { + require_cf + + # Build JAR if missing + if [[ ! -f "$SCRIPT_DIR/workload.jar" ]]; then + echo "workload.jar not found β€” building..." + "$SCRIPT_DIR/src/build.sh" + fi + + echo "Deleting any existing sample apps..." + for app in "${APPS[@]}"; do + cf delete -f "$app" 2>/dev/null || true + done + + echo "Pushing all apps (sequential to avoid staging queue saturation)..." + for app in "${APPS[@]}"; do + echo " β†’ pushing $app" + (cd "$SCRIPT_DIR/$app" && cf push) || echo " βœ— $app failed to push" + done + + echo "" + echo "App summary:" + printf "%-20s %-8s %s\n" "NAME" "STATE" "ROUTE" + printf "%-20s %-8s %s\n" "----" "-----" "-----" + for app in "${APPS[@]}"; do + state=$(cf app "$app" 2>/dev/null | grep "^requested state:" | awk '{print $3}' || echo "unknown") + route=$(app_route "$app" 2>/dev/null || echo "(no route)") + printf "%-20s %-8s %s\n" "$app" "$state" "$route" + done +} + +# ── undeploy ────────────────────────────────────────────────────────────────── + +cmd_undeploy() { + require_cf + cmd_calm 2>/dev/null || true + echo "Deleting all sample apps..." + for app in "${APPS[@]}"; do + echo " β†’ deleting $app" + cf delete -f "$app" 2>/dev/null || true + done + echo "Done." +} + +# ── busy ───────────────────────────────────────────────────────────────────── + +cmd_busy() { + require_cf + + echo "Activating all apps..." + for app in "${APPS[@]}"; do + route=$(app_route "$app" 2>/dev/null) || { echo " βœ— $app: cannot get route (not deployed?)"; continue; } + [[ -z "$route" ]] && { echo " βœ— $app: no route found"; continue; } + code=$(http_post "http://$route/activate") + echo " β†’ $app: POST /activate β†’ HTTP $code" + done + + # Kill any existing busy loop + if [[ -f "$BUSY_LOOP_PID_FILE" ]]; then + old_pid=$(cat "$BUSY_LOOP_PID_FILE") + kill "$old_pid" 2>/dev/null || true + rm -f "$BUSY_LOOP_PID_FILE" + fi + + echo "Starting local contention loop (10 concurrent GETs per app every 30s)..." + echo " Press Ctrl-C or run './sample-apps.sh calm' to stop." + + ( + trap 'exit 0' TERM INT + while true; do + sleep 30 + for app in "${APPS[@]}"; do + route=$(cf app "$app" 2>/dev/null | grep "^routes:" | awk '{print $2}' | head -1) || continue + [[ -z "$route" ]] && continue + for i in $(seq 1 10); do + curl -s -o /dev/null "http://$route/" & + done + done + wait + done + ) & + echo $! > "$BUSY_LOOP_PID_FILE" + echo "Busy loop PID: $(cat "$BUSY_LOOP_PID_FILE")" +} + +# ── calm ───────────────────────────────────────────────────────────────────── + +cmd_calm() { + require_cf + + # Stop local busy loop if running + if [[ -f "$BUSY_LOOP_PID_FILE" ]]; then + pid=$(cat "$BUSY_LOOP_PID_FILE") + kill "$pid" 2>/dev/null && echo "Stopped local contention loop (PID $pid)" + rm -f "$BUSY_LOOP_PID_FILE" + fi + + echo "Deactivating all apps..." + for app in "${APPS[@]}"; do + route=$(app_route "$app" 2>/dev/null) || { echo " βœ— $app: cannot get route (not deployed?)"; continue; } + [[ -z "$route" ]] && { echo " βœ— $app: no route found"; continue; } + code=$(http_post "http://$route/deactivate") + echo " β†’ $app: POST /deactivate β†’ HTTP $code" + done +} + +# ── dispatch ────────────────────────────────────────────────────────────────── + +case "${1:-}" in + deploy) cmd_deploy ;; + undeploy) cmd_undeploy ;; + busy) cmd_busy ;; + calm) cmd_calm ;; + *) + echo "Usage: $0 [deploy|undeploy|busy|calm]" + echo "" + echo " deploy Build JAR (if needed), push all 8 apps to CF" + echo " undeploy Delete all 8 apps from CF" + echo " busy Activate elevated workload on all apps + start local contention loop" + echo " calm Deactivate elevated workload (apps return to 5ms/1MB baseline)" + exit 1 + ;; +esac diff --git a/test/apps/sapmachine11/manifest.yml b/test/apps/sapmachine11/manifest.yml new file mode 100644 index 0000000..88fbf99 --- /dev/null +++ b/test/apps/sapmachine11/manifest.yml @@ -0,0 +1,31 @@ +--- +applications: +- name: sapmachine11 + random-route: true + path: workload.jar + memory: 512M + health-check-type: http + health-check-http-endpoint: /health + buildpacks: + - sap_java_buildpack_jakarta + env: + # JDK 11 default ReservedCodeCacheSize is 240M; cap it so the memory calculator fits in 512M. + # Exclude LoggingLevelChangeAgent from frameworks: the agent JAR in this buildpack is compiled + # for Java 17+ (class file version 61.0) and causes UnsupportedClassVersionError on Java 11. + JBP_CONFIG_COMPONENTS: "{jres: ['com.sap.xs.java.buildpack.jdk.SAPMachineJDK'],\ + frameworks: ['com.sap.xs.java.buildpack.framework.MultiBuildpack',\ + 'com.sap.xs.java.buildpack.framework.JavaOpts',\ + 'com.sap.xs.java.buildpack.framework.HanaJDBC',\ + 'com.sap.xs.java.buildpack.framework.PostgresqlJDBC',\ + 'com.sap.xs.java.buildpack.framework.PostgresqlSSLConfig',\ + 'com.sap.xs.java.buildpack.framework.JavaMemoryAssistant',\ + 'com.sap.xs.java.buildpack.framework.MemoryCalculator',\ + 'com.sap.xs.java.buildpack.framework.Resources',\ + 'com.sap.xs.java.buildpack.framework.ResourceConfiguration',\ + 'com.sap.xs.java.buildpack.framework.LogLevelClient',\ + 'com.sap.xs.java.buildpack.framework.JVMKill',\ + 'com.sap.xs.java.buildpack.framework.SystemResourceConfiguration',\ + 'com.sap.xs.java.buildpack.framework.DNSCache',\ + 'com.sap.xs.java.buildpack.framework.ContainerSecurityProvider']}" + JBP_CONFIG_SAP_MACHINE_JDK: "{ version: 11.+ }" + JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints -XX:ReservedCodeCacheSize=64M']" diff --git a/test/apps/sapmachine11/workload.jar b/test/apps/sapmachine11/workload.jar new file mode 120000 index 0000000..80a31b0 --- /dev/null +++ b/test/apps/sapmachine11/workload.jar @@ -0,0 +1 @@ +../workload.jar \ No newline at end of file diff --git a/test/apps/sapmachine17/manifest.yml b/test/apps/sapmachine17/manifest.yml index a6a58af..42dbaf5 100644 --- a/test/apps/sapmachine17/manifest.yml +++ b/test/apps/sapmachine17/manifest.yml @@ -2,12 +2,13 @@ applications: - name: sapmachine17 random-route: true - path: test.jar - memory: 512M - buildpacks: + path: workload.jar + memory: 256M + health-check-type: http + health-check-http-endpoint: /health + buildpacks: - sap_java_buildpack_jakarta env: - TARGET_RUNTIME: tomcat JBP_CONFIG_COMPONENTS: "jres: ['com.sap.xs.java.buildpack.jdk.SAPMachineJDK']" - JBP_CONFIG_SAP_MACHINE_JDK : "{ version: 17.+ }" + JBP_CONFIG_SAP_MACHINE_JDK: "{ version: 17.+ }" JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints']" diff --git a/test/apps/sapmachine17/test.jar b/test/apps/sapmachine17/test.jar deleted file mode 100644 index c998b29..0000000 Binary files a/test/apps/sapmachine17/test.jar and /dev/null differ diff --git a/test/apps/sapmachine17/workload.jar b/test/apps/sapmachine17/workload.jar new file mode 120000 index 0000000..80a31b0 --- /dev/null +++ b/test/apps/sapmachine17/workload.jar @@ -0,0 +1 @@ +../workload.jar \ No newline at end of file diff --git a/test/apps/sapmachine21/manifest.yml b/test/apps/sapmachine21/manifest.yml index ed9cff5..01a6faf 100644 --- a/test/apps/sapmachine21/manifest.yml +++ b/test/apps/sapmachine21/manifest.yml @@ -2,12 +2,13 @@ applications: - name: sapmachine21 random-route: true - path: test.jar - memory: 512M - buildpacks: + path: workload.jar + memory: 256M + health-check-type: http + health-check-http-endpoint: /health + buildpacks: - sap_java_buildpack_jakarta env: - TARGET_RUNTIME: tomcat JBP_CONFIG_COMPONENTS: "jres: ['com.sap.xs.java.buildpack.jdk.SAPMachineJDK']" - JBP_CONFIG_SAP_MACHINE_JDK : "{ version: 21.+ }" + JBP_CONFIG_SAP_MACHINE_JDK: "{ version: 21.+ }" JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints']" diff --git a/test/apps/sapmachine21/test.jar b/test/apps/sapmachine21/test.jar deleted file mode 100644 index c998b29..0000000 Binary files a/test/apps/sapmachine21/test.jar and /dev/null differ diff --git a/test/apps/sapmachine21/workload.jar b/test/apps/sapmachine21/workload.jar new file mode 120000 index 0000000..80a31b0 --- /dev/null +++ b/test/apps/sapmachine21/workload.jar @@ -0,0 +1 @@ +../workload.jar \ No newline at end of file diff --git a/test/apps/sapmachine25/manifest.yml b/test/apps/sapmachine25/manifest.yml new file mode 100644 index 0000000..a4b0c5e --- /dev/null +++ b/test/apps/sapmachine25/manifest.yml @@ -0,0 +1,14 @@ +--- +applications: +- name: sapmachine25 + random-route: true + path: workload.jar + memory: 256M + health-check-type: http + health-check-http-endpoint: /health + buildpacks: + - sap_java_buildpack_jakarta + env: + JBP_CONFIG_COMPONENTS: "jres: ['com.sap.xs.java.buildpack.jdk.SAPMachineJDK']" + JBP_CONFIG_SAP_MACHINE_JDK: "{ version: 25.+ }" + JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints']" diff --git a/test/apps/sapmachine25/workload.jar b/test/apps/sapmachine25/workload.jar new file mode 120000 index 0000000..80a31b0 --- /dev/null +++ b/test/apps/sapmachine25/workload.jar @@ -0,0 +1 @@ +../workload.jar \ No newline at end of file diff --git a/test/apps/src/WorkloadApp.java b/test/apps/src/WorkloadApp.java new file mode 100644 index 0000000..c7f455d --- /dev/null +++ b/test/apps/src/WorkloadApp.java @@ -0,0 +1,195 @@ +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +public class WorkloadApp { + + // ---------- shared state ---------- + private static final AtomicBoolean active = new AtomicBoolean(false); + private static final AtomicLong iterations = new AtomicLong(0); + + // Calibrated iteration counts so loops consume the right wall-clock time + private static long calmIterations; // ~5 ms worth + private static long busyIterations; // ~50 ms worth + private static final long calmAllocMB = 1; + private static final long busyAllocMB = 32; + + // Objects held during /allocate endpoint calls (released after 10 s) + private static final List held = new ArrayList<>(); + + // ---------- main ---------- + public static void main(String[] args) throws Exception { + calibrate(); + + Thread workload = new Thread(WorkloadApp::workloadLoop, "workload"); + workload.setDaemon(true); + workload.start(); + + int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080")); + HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); + // Register specific paths before the catch-all "/" + server.createContext("/health", ex -> handleHealth(ex)); + server.createContext("/activate", ex -> handleActivate(ex)); + server.createContext("/deactivate", ex -> handleDeactivate(ex)); + server.createContext("/status", ex -> handleStatus(ex)); + server.createContext("/allocate", ex -> handleAllocate(ex)); + server.createContext("/", ex -> handleRoot(ex)); + server.setExecutor(Executors.newFixedThreadPool(20)); + server.start(); + System.out.println("WorkloadApp listening on port " + port); + } + + // ---------- calibration ---------- + private static void calibrate() { + // Run math loop for 200 ms, count iterations, derive per-ms rate + long count = 0; + long end = System.currentTimeMillis() + 200; + while (System.currentTimeMillis() < end) { + burnCpu(10_000); + count += 10_000; + } + long perMs = Math.max(1, count / 200); + calmIterations = perMs * 5; // 5 ms + busyIterations = perMs * 50; // 50 ms + System.out.printf("Calibrated: %d iter/ms -> calm=%d busy=%d%n", + perMs, calmIterations, busyIterations); + } + + // ---------- workload loop ---------- + private static void workloadLoop() { + while (true) { + boolean isBusy = active.get(); + long iters = isBusy ? busyIterations : calmIterations; + long allocMB = isBusy ? busyAllocMB : calmAllocMB; + + burnCpu(iters); + iterations.incrementAndGet(); + allocateAndRelease(allocMB); + + try { + // stay close to 1-second cadence accounting for work time + Thread.sleep(isBusy ? 950 : 995); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + // ---------- CPU work (produces interesting flamegraph stacks) ---------- + private static double burnCpu(long n) { + double acc = 0; + for (long i = 0; i < n; i++) { + acc += Math.sin(i) * Math.sqrt(i + 1); + } + return acc; // value used to prevent dead-code elimination + } + + // ---------- heap work (transient β€” released immediately) ---------- + private static void allocateAndRelease(long mb) { + List local = new ArrayList<>(); + long bytesLeft = mb * 1024 * 1024; + + // ~50% byte arrays + local.add(new byte[(int) Math.min(bytesLeft / 2, Integer.MAX_VALUE)]); + bytesLeft -= bytesLeft / 2; + + // ~25% String array + int strings = (int) Math.min(bytesLeft / 64, 10_000); + String[] sa = new String[strings]; + for (int i = 0; i < strings; i++) sa[i] = "workload-string-" + i; + local.add(sa); + + // ~25% linked nodes + Node head = null; + int nodes = (int) Math.min(bytesLeft / 32, 5_000); + for (int i = 0; i < nodes; i++) head = new Node(i, head); + local.add(head); + + // local goes out of scope here β€” GC collects + } + + // ---------- /allocate endpoint: holds memory for 10 s ---------- + private static void holdAllocate(long mb) { + byte[] block = new byte[(int) Math.min(mb * 1024 * 1024, Integer.MAX_VALUE)]; + synchronized (held) { held.add(block); } + Thread releaser = new Thread(() -> { + try { Thread.sleep(10_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + synchronized (held) { held.remove(block); } + }); + releaser.setDaemon(true); + releaser.start(); + } + + // ---------- linked list node (visible in heap dumps) ---------- + private static class Node { + final int value; + final Node next; + Node(int value, Node next) { this.value = value; this.next = next; } + } + + // ---------- HTTP handlers ---------- + private static void handleRoot(HttpExchange ex) throws IOException { + // Small CPU burst per request β€” creates thread contention under load + burnCpu(1_000); + respond(ex, 200, "{\"status\":\"ok\"}"); + } + + private static void handleHealth(HttpExchange ex) throws IOException { + respond(ex, 200, "OK"); + } + + private static void handleActivate(HttpExchange ex) throws IOException { + if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) { + respond(ex, 405, "Method Not Allowed"); return; + } + active.set(true); + respond(ex, 200, "{\"active\":true}"); + } + + private static void handleDeactivate(HttpExchange ex) throws IOException { + if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) { + respond(ex, 405, "Method Not Allowed"); return; + } + active.set(false); + respond(ex, 200, "{\"active\":false}"); + } + + private static void handleStatus(HttpExchange ex) throws IOException { + String body = String.format("{\"active\":%b,\"iterations\":%d}", + active.get(), iterations.get()); + respond(ex, 200, body); + } + + private static void handleAllocate(HttpExchange ex) throws IOException { + if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) { + respond(ex, 405, "Method Not Allowed"); return; + } + long mb = 32; + String query = ex.getRequestURI().getQuery(); + if (query != null) { + for (String part : query.split("&")) { + if (part.startsWith("mb=")) { + try { mb = Long.parseLong(part.substring(3)); } catch (NumberFormatException ignored) {} + } + } + } + holdAllocate(mb); + respond(ex, 200, "{\"allocated_mb\":" + mb + "}"); + } + + private static void respond(HttpExchange ex, int code, String body) throws IOException { + byte[] bytes = body.getBytes("UTF-8"); + ex.getResponseHeaders().set("Content-Type", "application/json"); + ex.sendResponseHeaders(code, bytes.length); + try (OutputStream os = ex.getResponseBody()) { os.write(bytes); } + } +} diff --git a/test/apps/src/build.sh b/test/apps/src/build.sh new file mode 100755 index 0000000..7c5d897 --- /dev/null +++ b/test/apps/src/build.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +echo "Compiling WorkloadApp.java..." +mkdir -p out +javac --release 11 -d out WorkloadApp.java + +echo "Packaging workload.jar..." +jar cfe ../workload.jar WorkloadApp -C out . + +echo "Done: $(ls -lh ../workload.jar | awk '{print $5, $9}')" diff --git a/test/apps/workload.jar b/test/apps/workload.jar new file mode 100644 index 0000000..bfe959a Binary files /dev/null and b/test/apps/workload.jar differ diff --git a/utils/cfutils.go b/utils/cfutils.go index dbde266..8643ff8 100644 --- a/utils/cfutils.go +++ b/utils/cfutils.go @@ -2,21 +2,35 @@ package utils import ( + "compress/gzip" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "slices" "sort" "strings" + "sync" "github.com/lithammer/fuzzysearch/fuzzy" ) +func shellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `"'"'`) + "'" +} + +// ShellSingleQuote wraps s in single quotes for safe embedding in a shell command string. +func ShellSingleQuote(s string) string { return shellSingleQuote(s) } + +func remoteCatCommand(src string) string { + return "exec cat -- " + shellSingleQuote(src) +} + // Version represents a semantic version with major, minor, and build numbers. type Version struct { Major int @@ -209,7 +223,7 @@ 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. +// CopyOverCat copies a remote file to a local destination using cf ssh. func CopyOverCat(args []string, src string, dest string) error { // Ensure parent directory exists if dir := filepath.Dir(dest); dir != "" && dir != "." { @@ -219,7 +233,7 @@ func CopyOverCat(args []string, src string, dest string) error { } f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) //nolint:gosec // dest is a plugin-constructed output path, not user-supplied file inclusion 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.") + return errors.New("Error creating local file at " + dest + ". Please check that you are allowed to create files at the given local path.") } defer func() { if closeErr := f.Close(); closeErr != nil { @@ -228,7 +242,7 @@ func CopyOverCat(args []string, src string, dest string) error { } }() - args = append(args, "cat \""+src+"\"") + args = append(args, remoteCatCommand(src)) cat := exec.Command("cf", args...) cat.Stdout = f @@ -240,12 +254,101 @@ func CopyOverCat(args []string, src string, dest string) error { err = cat.Wait() if err != nil { - return errors.New("error occurred while waiting for the copying complete") + return errors.New("error occurred while waiting for the file copy to complete") } return nil } +// StreamOverCat starts a remote `cat` over cf ssh and returns a reader for the +// remote file plus a wait function that must be called after the reader is fully +// consumed. +func StreamOverCat(args []string, src string) (io.ReadCloser, func() error, error) { + pr, pw := io.Pipe() + catArgs := append(args, remoteCatCommand(src)) //nolint:gocritic // intentional new slice + cat := exec.Command("cf", catArgs...) + cat.Stdout = pw + cat.Stderr = os.Stderr + + if err := cat.Start(); err != nil { + _ = pr.Close() + _ = pw.Close() + return nil, nil, errors.New("error occurred during copying dump file: " + src + ", please try again.") + } + + var ( + waitOnce sync.Once + waitResult error + ) + wait := func() error { + waitOnce.Do(func() { waitResult = cat.Wait() }) + return waitResult + } + + go func() { + _ = pw.CloseWithError(wait()) + }() + + return pr, wait, nil +} + +// CopyOverCatGunzip streams a remote gzip-compressed file via cf ssh and decompresses +// it on the fly, saving the result at dest. +func CopyOverCatGunzip(args []string, src string, dest string) error { + if dir := filepath.Dir(dest); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:gosec // 0755 is correct for a local download directory + return fmt.Errorf("cannot create local directory %s: %w", dir, err) + } + } + f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) //nolint:gosec // dest is a plugin-constructed output path + 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 func() { + if closeErr := f.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close file %s: %v\n", dest, closeErr) + } + }() + + pr, pw := io.Pipe() + catArgs := append(args, remoteCatCommand(src)) //nolint:gocritic // intentional new slice + cat := exec.Command("cf", catArgs...) + cat.Stdout = pw + + if err := cat.Start(); err != nil { + _ = pr.Close() + _ = pw.Close() + return errors.New("error starting cf ssh: " + err.Error()) + } + + go func() { + _ = pw.CloseWithError(cat.Wait()) + }() + + gz, err := gzip.NewReader(pr) + if err != nil { + return fmt.Errorf("gzip header error: %w", err) + } + defer func() { _ = gz.Close() }() + + if _, err := io.Copy(f, gz); err != nil { //nolint:gosec // G110: source is a trusted CF container owned by the user + return fmt.Errorf("decompression failed: %w", err) + } + return nil +} + +// ProbeRemoteFileGzip checks if the first 2 bytes of a remote file are the gzip magic bytes (1f 8b). +func ProbeRemoteFileGzip(args []string, path string) (bool, error) { + cmd := fmt.Sprintf("xxd -l 2 \"%s\" 2>/dev/null || od -An -N2 -tx1 \"%s\" 2>/dev/null", path, path) + probeArgs := append(args, cmd) //nolint:gocritic // intentional new slice + out, err := exec.Command("cf", probeArgs...).Output() + if err != nil { + return false, err + } + outStr := string(out) + return strings.Contains(outStr, "1f") && strings.Contains(outStr, "8b"), 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+"\"") @@ -262,6 +365,11 @@ func FindHeapDumpFile(args []string, fullpath string, fspath string, namePrefix return FindFile(args, fullpath, fspath, "*.hprof", namePrefix) } +// FindHeapDumpGzFile locates gzip-compressed heap dump files (*.hprof.gz) on the remote container. +func FindHeapDumpGzFile(args []string, fullpath string, fspath string, namePrefix string) (string, error) { + return FindFile(args, fullpath, fspath, "*.hprof.gz", namePrefix) +} + // FindJFRFile locates Java Flight Recorder files (*.jfr) in the specified path on the remote container. func FindJFRFile(args []string, fullpath string, fspath string, namePrefix string) (string, error) { return FindFile(args, fullpath, fspath, "*.jfr", namePrefix)