diff --git a/.github/workflows/build-and-snapshot.yml b/.github/workflows/build-and-snapshot.yml index 3c8059d..51027ff 100644 --- a/.github/workflows/build-and-snapshot.yml +++ b/.github/workflows/build-and-snapshot.yml @@ -231,9 +231,9 @@ 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 + # on Windows amd64 cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-windows-amd64 - # on Linux x86 + # on Linux amd64 cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-linux-amd64 ``` diff --git a/.github/workflows/build.py b/.github/workflows/build.py index 74b8a06..38f4941 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,46 @@ 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) + +rc = subprocess.call(f"go build -o dist/cf-cli-java-plugin-{os_name}-{arch}", shell=True) +sys.exit(rc) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index e39f333..3f352e1 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,54 @@ 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] - - name: Build plugin - run: | - echo "๐Ÿ”จ Building plugin..." - if ! python3 .github/workflows/build.py; then - echo "โŒ Build failed." - exit 1 - fi - echo "โœ… Build successful!" + 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: Validation Summary + - name: Download JStall minimal JAR for go:embed + shell: bash 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)" - fi - echo "โœ… Plugin build" - echo "" - echo "๐Ÿš€ Ready for merge!" + 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: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: cf-cli-java-plugin-${{ matrix.os }} + path: | + dist/* + !dist/jstall-minimal.jar + !dist/hprof-redact-* diff --git a/.gitignore b/.gitignore index 83d268c..59af7df 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ _testmain.go # Tools counterfeiter -# Built binaries +# Build output directories build/ pkg/ @@ -49,8 +49,54 @@ 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/ + +# 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..07c956f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] -### 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`. - ### Changed +- macOS plugin support now requires Apple Silicon. macOS Intel (`darwin/amd64`) is not supported. - Improved SSH error messages for better clarity and debugging -- Enhanced documentation and README with better clarity + +### 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 `--args`. +- `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`. ## [4.0.2] diff --git a/Makefile b/Makefile index 9fd9e4b..35376fb 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ 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 @@ -34,15 +34,69 @@ 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..1f2b96f 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 +# on Windows amd64 cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-windows-amd64 -# on Linux x64 +# 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 +# on Windows amd64 cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-windows-amd64 -# on Linux x64 +# 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 @@ -192,43 +204,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 +349,89 @@ 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` 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..9fb810b 100644 --- a/cf_cli_java_plugin.go +++ b/cf_cli_java_plugin.go @@ -11,11 +11,13 @@ package main import ( "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 +33,30 @@ 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" + 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 +211,21 @@ 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 } // FlagDefinition holds metadata for a command-line flag @@ -285,6 +304,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 +370,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 +399,38 @@ 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 + }(), + } + + 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 +442,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 @@ -516,10 +605,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. @@ -545,12 +634,14 @@ if [ -z "${JMAP_COMMAND}" ] && [ -z "${JVMMON_COMMAND}" ]; then 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 "${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 $(pidof java) ) || 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 @@ -561,6 +652,7 @@ HEAP_DUMP_NAME=$(find @FSPATH -name 'java_pid*.hprof' -printf '%T@ %p\0' | sort 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 fi`, FileLabel: "heap dump", FileNamePart: "heapdump", @@ -985,13 +1077,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 +1160,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 +1198,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 +1242,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 +1276,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 +1307,101 @@ 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 + } + + 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..7927416 --- /dev/null +++ b/cf_cli_java_plugin_test.go @@ -0,0 +1,63 @@ +/* + * 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 ( + "testing" +) + +const testAppName = "myapp" + +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/go.mod b/go.mod index ebd3fee..ed77c4b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module cf.plugin.ref/requires -go 1.25.0 +go 1.26.0 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..26d77dc 100644 --- a/jstall.go +++ b/jstall.go @@ -25,7 +25,7 @@ import ( var jstallJarBytes []byte func javaExecutable() string { - if runtime.GOOS == "windows" { + if runtime.GOOS == osWindows { return "java.exe" } return cmdJava @@ -76,7 +76,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,11 +170,6 @@ 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, "'", "'\\''") + "'" -} - func (c *JavaPlugin) executeJstall(appName string, jstallArgs string, appInstanceIndex int, dryRun bool) (string, error) { javaPath, err := findJava17Plus() if err != nil { @@ -190,17 +185,15 @@ func (c *JavaPlugin) executeJstall(appName string, jstallArgs string, appInstanc 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) + // Use --cf which jstall translates to "cf ssh -c" internally via ProcessBuilder + // (no sh -c wrapper since v0.7.2, so this works on Windows too). + // For instance index, fall back to --ssh since --cf doesn't support it. if appInstanceIndex != -1 { - sshCmd += " --app-instance-index " + strconv.Itoa(appInstanceIndex) + sshCmd := "cf ssh " + appName + " --app-instance-index " + strconv.Itoa(appInstanceIndex) + " -c" + args = append(args, "--ssh", sshCmd) + } else { + args = append(args, "--cf", appName) } - sshCmd += " -c" - args = append(args, "--ssh", sshCmd) - args = append(args, "--ssh-prefix", pathSetup) if jstallArgs != "" { splitArgs, err := shlex.Split(jstallArgs) diff --git a/open.go b/open.go new file mode 100644 index 0000000..0354b24 --- /dev/null +++ b/open.go @@ -0,0 +1,185 @@ +/* + * 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, + WriteTimeout: 30 * time.Second, + } + + 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..a8c3772 --- /dev/null +++ b/open_test.go @@ -0,0 +1,395 @@ +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") + content := strings.Repeat("HEAP_CONTENT", 1<<14) + 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..eeba3aa --- /dev/null +++ b/redact.go @@ -0,0 +1,162 @@ +/* + * 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) + } + + // Pre-check write access; close immediately so Windows doesn't hold a lock. + if f, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600); err != nil { //nolint:gosec // plugin-constructed path + return "", fmt.Errorf("error creating local file at %s: %w", outputPath, err) + } else { + _ = f.Close() + _ = os.Remove(outputPath) + } + + 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/utils/cfutils.go b/utils/cfutils.go index dbde266..3bdb8fe 100644 --- a/utils/cfutils.go +++ b/utils/cfutils.go @@ -2,21 +2,32 @@ 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, "'", `"'"'`) + "'" +} + +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 +220,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 +230,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 +239,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 +251,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 +362,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)