diff --git a/Cargo.lock b/Cargo.lock index c9e9fee6..a2f7fa16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1033,6 +1033,7 @@ dependencies = [ "libc", "serde", "serde_json", + "sha2", "tempfile", "thiserror 2.0.12", "tokio", diff --git a/docs/configuration.md b/docs/configuration.md index 77a04f0a..a91511b4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -967,3 +967,10 @@ Invalid configuration will produce clear error messages with suggestions for fix - Monitor kernel memory usage when using large ringbuf sizes - Use smaller `proc_module_offsets_max_entries` for single-process debugging - Increase buffer size for high-frequency tracing scenarios + +## Prepared trace commands + +`ghostscope prepare -t TARGET --script-file SCRIPT -o BUNDLE` compiles without +BPF privileges. `ghostscope run-bundle BUNDLE -p PID` uses the saved compilation +configuration and skips DWARF parsing and LLVM codegen. See +[Prepared trace bundles](prepared-traces.md) for runtime requirements and limits. diff --git a/docs/prepared-traces.md b/docs/prepared-traces.md new file mode 100644 index 00000000..5d0a7b0f --- /dev/null +++ b/docs/prepared-traces.md @@ -0,0 +1,74 @@ +# Prepared trace bundles + +Prepare a trace once, then attach the saved eBPF programs to a running process +without repeating DWARF parsing or LLVM code generation: + +```bash +ghostscope prepare -t /path/to/app --script-file trace.gs -o trace.gsb +sudo ghostscope run-bundle trace.gsb -p +``` + +`prepare` runs as an ordinary user. It reads the target and its debug information, +resolves the script, and writes a new bundle. It does not probe BPF kernel +capabilities, load BPF programs, attach uprobes, start sysmon, or create bpffs pins. +An existing output file is never overwritten. Compilation must succeed for every +resolved target; partial compilations are not published as bundles. + +`run-bundle` verifies the bundle format, GhostScope version, native architecture, +target file SHA-256, module cookie, and the target's executable mapping in the +specified process. It then checks kernel capabilities, initializes runtime module +offsets, and attaches the saved probes. Event decoding, output-loss reporting, +and signal handling use the normal CLI runtime. There is no fallback to DWARF +loading or recompilation. + +## First milestone scope + +- CLI scripts targeting one executable or shared-library module in a host process. +- Process-scoped multi-uprobe support is required on the runtime kernel, including + correct coverage of worker threads. Preparation does not require this support + on its own kernel. +- `bt` / `backtrace`, including nested uses, are rejected during preparation. +- `$input_pid` is unavailable during preparation. `$pid` and `$tid` are read at + probe time and use host PID semantics. Container PID namespaces are not yet + supported by `run-bundle`. +- RingBuf is the default event transport. To prepare for PerfEventArray, use + `--config` with `[ebpf].force_perf_event_array = true`. Sleepable uprobes can + also be selected in the preparation config and require compatible runtime + capabilities. The consumer never silently changes the compiled backend. + +## Debug files, identity, and configuration + +`prepare` accepts `--config` and `--debug-file`, using the normal debug-file +matching rules. The bundle stores its script, compilation configuration, probe +attachment offsets, eBPF objects, and event-decoding type/context tables. Debug +files and source files are not required by `run-bundle`. + +The target binary must remain byte-for-byte identical. In particular, strip or +otherwise transform the binary **before** preparation, not afterwards. Removing +a separate debug file after preparation is supported. To use an identical target +at another location, pass `run-bundle ... -t /new/path/to/app`; its module cookie +must also match. A target without an ELF build ID must retain its filesystem +identity, so copying it to a different inode requires preparing another bundle. + +Bundles use a versioned JSON format and are limited to 64 MiB. Treat a bundle as +executable code: run only artifacts from a trusted preparation workflow. The +identity checks establish which target it was prepared for, not who produced it. +Reprepare after changing the target, script, compilation configuration, or +GhostScope version. Bundle schema changes also require repreparation. + +The runtime uses the saved eBPF configuration rather than the current user's +configuration file. `--script-output pretty|plain|jsonl` and `--no-status` control +runtime presentation. + +## Measuring startup cost + +Preparation reports its compilation time; `run-bundle` reports time to attachment +unless `--no-status` is set. Target hashing, ELF mapping discovery, kernel +capability checks, and BPF verification/loading still occur at runtime. This is +not a zero-cost attach path. + +The `prepared_bundle_execution` e2e tests compare normal and prepared startup on +a small C fixture with separate DWARF, verify reuse across PIDs and worker-thread +coverage, and remove the debug file before running the bundle. Timing is reported +without a brittle pass/fail threshold. Measure representative production binaries +before extrapolating the speedup to large debug-info workloads. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7b28b4ca..e48367fa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -48,3 +48,7 @@ GhostScope is still evolving quickly. The milestones below are ordered from “s - Typical scenario: sources and debug info live in the cloud, while binaries run on test rigs or local hosts. - Goal: keep GhostScope’s TUI on the control side and run the eBPF agent on the target machine, similar to `gdb`/`gdbserver`. - This will come after the core functionality stabilizes to avoid diluting near-term focus. + +## Prepared trace bundles +- The CLI `prepare` / `run-bundle` first milestone moves DWARF parsing and LLVM codegen out of repeated attach operations for host processes. See [Prepared trace bundles](prepared-traces.md) for compatibility requirements and current exclusions. +- Follow-up work includes runtime PID-namespace binding and module-level CFI/symbol providers for backtraces. diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 29e047c6..951d21aa 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -922,3 +922,9 @@ GhostScope 在启动时验证配置: - 使用大缓冲区时监控内核内存使用情况 - 单进程调试时使用较小的 `proc_module_offsets_max_entries` - 高频跟踪场景下增加缓冲区大小 + +## 预编译 trace 命令 + +`ghostscope prepare -t TARGET --script-file SCRIPT -o BUNDLE` 无需 BPF 权限即可编译。 +`ghostscope run-bundle BUNDLE -p PID` 使用保存的编译配置,跳过 DWARF 解析和 LLVM 编译。 +运行要求和当前限制见[预编译 trace bundle](prepared-traces.md)。 diff --git a/docs/zh/prepared-traces.md b/docs/zh/prepared-traces.md new file mode 100644 index 00000000..f719844d --- /dev/null +++ b/docs/zh/prepared-traces.md @@ -0,0 +1,43 @@ +# 预编译 trace bundle + +先以普通用户编译,再将保存的 eBPF 程序附加到运行中的进程: + +```bash +ghostscope prepare -t /path/to/app --script-file trace.gs -o trace.gsb +sudo ghostscope run-bundle trace.gsb -p +``` + +`prepare` 不需要 BPF 权限,不探测内核 BPF 能力,不加载或附加探针,不启动 +sysmon,也不写入 bpffs。所有目标必须编译成功,输出文件已存在时会报错,避免覆盖。 + +`run-bundle` 校验格式版本、GhostScope 版本、本机架构、目标文件 SHA-256、模块 +cookie,以及该文件在指定 PID 中的可执行映射。之后检查内核能力、初始化运行时 +模块偏移并附加保存的程序,复用普通 CLI 的事件输出、丢失计数和退出清理。 +不会回退到 DWARF 解析或 LLVM 编译。 + +首版范围: + +- 单个可执行文件或共享库模块,目标必须是宿主机进程。 +- 运行内核需要支持进程级 multi-uprobe,包括工作线程;准备机器不需要该能力。 +- 暂不支持 `bt` / `backtrace`、`$input_pid` 或容器 PID namespace。 +- `$pid` / `$tid` 在探针执行时读取,使用宿主机 PID 语义。 +- 默认使用 RingBuf。可在准备时通过 `--config` 选择 PerfEventArray 或 sleepable + uprobes;运行内核必须支持保存的配置,不会自动切换后端。 + +准备时可用 `--debug-file` 指定独立调试文件,仍执行通常的身份校验。 +准备后可以移除独立调试文件,但目标二进制必须逐字节一致;strip 等处理应在 +准备之前完成。可用 `run-bundle ... -t /new/path/to/app` 指定相同目标的新路径。 +没有 ELF build ID 的目标必须保留原来的文件身份,复制到其他 inode 后需要重新准备。 + +bundle 是有版本的 JSON 文件,上限为 64 MiB,包含脚本、编译配置、探针偏移、 +eBPF 对象及解码上下文。应像可执行文件一样,仅运行可信来源的 bundle。 +目标身份校验不等于来源认证。目标、脚本、编译配置、GhostScope 版本或 bundle +格式变化后,应重新准备。 + +运行时使用 bundle 保存的 eBPF 配置,不读取当前用户的配置文件。 +可用 `--script-output pretty|plain|jsonl` 和 `--no-status` 控制输出。 + +准备和附加阶段会报告耗时。运行阶段仍需目标校验、ELF 映射发现、内核能力检测和 +BPF 验证/加载。测试中的小型 C fixture 会比较普通启动与 bundle 启动,并验证移除 +调试文件后的读取、跨 PID 复用及线程覆盖;不使用容易波动的耗时阈值。 +生产环境收益应使用有代表性的目标另行测量。 diff --git a/docs/zh/roadmap.md b/docs/zh/roadmap.md index f0cd2b43..b2466557 100644 --- a/docs/zh/roadmap.md +++ b/docs/zh/roadmap.md @@ -48,3 +48,7 @@ GhostScope 仍处在快速演进阶段,以下里程碑按照“优先修补基 - 典型场景:代码与调试信息在云端,目标二进制运行在测试机或本地。 - 规划让 GhostScope TUI 仅运行在控制端,eBPF Agent 独立驻留在目标主机,参考 `gdb/gdbserver` 模式。 - 待核心能力稳定后再启动该特性,以免分散目前的开发精力。 + +## 预编译 trace bundle +- CLI `prepare` / `run-bundle` 首版将宿主机进程重复附加时的 DWARF 解析与 LLVM 编译前移。兼容要求和当前限制见[预编译 trace bundle](prepared-traces.md)。 +- 后续支持运行时 PID namespace 绑定,以及用于 backtrace 的模块级 CFI/符号提供器。 diff --git a/e2e-tests/tests/prepared_bundle_execution.rs b/e2e-tests/tests/prepared_bundle_execution.rs new file mode 100644 index 00000000..bc44ac58 --- /dev/null +++ b/e2e-tests/tests/prepared_bundle_execution.rs @@ -0,0 +1,454 @@ +//! Prepared bundles have an unprivileged producer and a DWARF-free consumer. +mod common; + +use anyhow::{ensure, Context, Result}; +use common::sandbox::SandboxHandle; +use std::fs::{self, File}; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +const SCRIPT: &str = r#"trace bundle_probe { print "BUNDLE value={} global={} pid={} tid={}", value, bundle_global, $pid, $tid; }"#; + +struct Fixture { + directory: tempfile::TempDir, + binary: PathBuf, + debug: PathBuf, + config: PathBuf, + ghostscope: PathBuf, + diagnostics: bool, +} + +impl Fixture { + fn new(perf: bool) -> Result { + let directory = tempfile::tempdir_in("/tmp")?; + // Root-run e2e deliberately prepares as nobody, without BPF capabilities. + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o777))?; + let source = directory.path().join("target.c"); + let binary = directory.path().join("target"); + let debug = directory.path().join("target.debug"); + fs::write( + &source, + r#" +#include +#include +#include +volatile int bundle_global = 73; +__attribute__((noinline)) void bundle_probe(int value) { + asm volatile("" : : "r"(value) : "memory"); +} +void *worker(void *arg) { + int value = *(int *)arg; + for (;;) { bundle_probe(value); usleep(10000); } + return 0; +} +int main(int argc, char **argv) { + int value = argc > 1 ? atoi(argv[1]) : 42; + int worker_value = value + 1; + pthread_t thread; + pthread_create(&thread, 0, worker, &worker_value); + worker(&value); + return 0; +} +"#, + )?; + successful( + Command::new("cc") + .args(["-g", "-O0", "-pthread", "-Wl,--build-id"]) + .arg(&source) + .arg("-o") + .arg(&binary) + .output()?, + )?; + successful( + Command::new("objcopy") + .arg("--only-keep-debug") + .arg(&binary) + .arg(&debug) + .output()?, + )?; + successful( + Command::new("strip") + .arg("--strip-debug") + .arg(&binary) + .output()?, + )?; + successful( + Command::new("objcopy") + .arg("--add-gnu-debuglink=target.debug") + .arg(&binary) + .current_dir(directory.path()) + .output()?, + )?; + let config = directory.path().join("config.toml"); + fs::write(&config, format!("[general]\nenable_logging = false\n[ebpf]\nforce_perf_event_array = {perf}\n[dwarf.debuginfod]\nenabled = \"off\"\n"))?; + let (binary_command, args) = SandboxHandle::host().ghostscope_command()?; + ensure!(args.is_empty(), "expected a direct host executable"); + let ghostscope = directory.path().join("ghostscope"); + fs::copy(PathBuf::from(binary_command), &ghostscope)?; + fs::set_permissions(&ghostscope, fs::Permissions::from_mode(0o755))?; + Ok(Self { + directory, + binary, + debug, + config, + ghostscope, + diagnostics: false, + }) + } + + fn script(&self) -> String { + if self.diagnostics { + SCRIPT.replace("$pid, $tid;", "$pid, $tid; print bundle_large;") + } else { + SCRIPT.to_owned() + } + } + + fn command(&self) -> Command { + let mut command = Command::new(&self.ghostscope); + command.current_dir(self.directory.path()); + command + } + + fn prepare(&self, script: &str, bundle: &Path) -> Result { + let mut command = self.command(); + command + .arg("prepare") + .arg("-t") + .arg(&self.binary) + .arg("--script") + .arg(script) + .arg("--config") + .arg(&self.config) + .arg("-o") + .arg(bundle); + // SAFETY: geteuid has no arguments or memory effects. + if unsafe { libc::geteuid() } == 0 { + command.gid(65534).uid(65534); + } + command + .output() + .context("could not execute unprivileged prepare") + } +} + +fn successful(output: Output) -> Result { + ensure!( + output.status.success(), + "command failed: {}\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8_lossy(&output.stderr).into_owned()) +} + +struct ChildGuard(Child); +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn capture(fixture: &Fixture, pid: u32, bundle: Option<&Path>) -> Result<(String, Duration)> { + let out_path = fixture.directory.path().join("stdout"); + let err_path = fixture.directory.path().join("stderr"); + let mut command = fixture.command(); + if let Some(bundle) = bundle { + command.arg("run-bundle").arg(bundle); + } else { + command + .arg("-t") + .arg(&fixture.binary) + .arg("--script") + .arg(fixture.script()) + .arg("--config") + .arg(&fixture.config) + .args([ + "--no-log", + "--no-save-llvm-ir", + "--no-save-ebpf", + "--no-save-ast", + ]); + } + command + .args([ + "-p", + &pid.to_string(), + "--no-status", + "--script-output", + if fs::read_to_string(&fixture.config)?.contains("force_perf_event_array = true") { + "plain" + } else { + "pretty" + }, + "--emit-ready-marker", + "BUNDLE_READY", + ]) + .stdout(Stdio::from(File::create(&out_path)?)) + .stderr(Stdio::from(File::create(&err_path)?)); + let started = Instant::now(); + let mut child = ChildGuard(command.spawn()?); + let mut ready_after = None; + let deadline = started + Duration::from_secs(60); + let output = loop { + let output = fs::read_to_string(&out_path)?; + if output.contains("BUNDLE_READY") && ready_after.is_none() { + ready_after = Some(started.elapsed()); + } + if ready_after.is_some() + && output.contains("value=42 global=73") + && output.contains("value=43 global=73") + { + break output; + } + if let Some(status) = child.0.try_wait()? { + anyhow::bail!( + "tracer exited {status}: {output}\n{}", + fs::read_to_string(&err_path)? + ); + } + ensure!( + Instant::now() < deadline, + "timed out: {output}\n{}", + fs::read_to_string(&err_path)? + ); + std::thread::sleep(Duration::from_millis(10)); + }; + common::termination::send_sigterm(child.0.id(), "bundle tracer")?; + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.0.try_wait()? { + ensure!( + status.success(), + "tracer failed shutdown: {}", + fs::read_to_string(&err_path)? + ); + break; + } + ensure!(Instant::now() < deadline, "tracer failed to shut down"); + std::thread::sleep(Duration::from_millis(10)); + } + let stderr = fs::read_to_string(&err_path)?; + ensure!( + !fixture.diagnostics + || stderr + .lines() + .filter(|line| line.contains("Display note") + && line.contains("bundle_large") + && line.contains("capture-budget")) + .count() + == 1, + "expected exactly one static display diagnostic: {stderr}" + ); + ensure!( + !output.contains("Display note"), + "diagnostics leaked into stdout: {output}" + ); + for line in output.lines().filter(|line| line.contains("BUNDLE value=")) { + ensure!(line.contains(&format!(" pid={pid} ")), "wrong PID: {line}"); + ensure!( + !line.contains("value=99") && !line.contains("value=100"), + "unrelated process was traced: {line}" + ); + } + Ok((output, ready_after.context("missing ready marker")?)) +} + +fn host_topology() -> Result { + Ok(SandboxHandle::default_ghostscope()?.is_host_backend() + && SandboxHandle::default_target()?.is_host_backend()) +} + +#[test] +fn test_prepared_bundle_unprivileged_roundtrip_without_debug_info() -> Result<()> { + common::init(); + if !host_topology()? { + eprintln!("prepared bundle MVP is host-only; skipping positive cases outside host->host"); + return Ok(()); + } + for perf in [false, true] { + let fixture = Fixture::new(perf)?; + let bundle = fixture.directory.path().join("trace.gsb"); + successful(fixture.prepare(SCRIPT, &bundle)?)?; + let target = ChildGuard(Command::new(&fixture.binary).arg("42").spawn()?); + let unrelated = ChildGuard(Command::new(&fixture.binary).arg("99").spawn()?); + if !ghostscope_loader::KernelCapabilities::detect_for_startup(false)?.supports_uprobe_multi + { + // Older supported GhostScope kernels cannot run this bundle profile. + // Still exercise offline preparation and the explicit refusal path. + let output = fixture + .command() + .arg("run-bundle") + .arg(&bundle) + .arg("-p") + .arg(target.0.id().to_string()) + .output()?; + ensure!( + !output.status.success(), + "unsupported kernel accepted a bundle" + ); + ensure!( + String::from_utf8_lossy(&output.stderr) + .contains("requires process-scoped multi-uprobe"), + "unexpected capability error: {}", + String::from_utf8_lossy(&output.stderr) + ); + writeln!(std::io::stderr(), "prepared bundle: verified unsupported-kernel rejection; positive attach coverage requires process-scoped multi-uprobes")?; + continue; + } + let mut normal_times = Vec::new(); + for _ in 0..3 { + normal_times.push(capture(&fixture, target.0.id(), None)?.1); + } + // The compiled producer needs this file. The prepared consumer must not. + fs::remove_file(&fixture.debug)?; + let mut bundle_times = Vec::new(); + for _ in 0..3 { + bundle_times.push(capture(&fixture, target.0.id(), Some(&bundle))?.1); + } + drop(target); + let second = ChildGuard(Command::new(&fixture.binary).arg("42").spawn()?); + capture(&fixture, second.0.id(), Some(&bundle))?; + drop(unrelated); + normal_times.sort(); + bundle_times.sort(); + // Direct stderr keeps benchmark evidence visible even without --nocapture. + writeln!(std::io::stderr(), "prepared bundle perf={perf}: median of 3 normal ready={}ms, bundle ready={}ms (small split-DWARF C fixture)", normal_times[1].as_millis(), bundle_times[1].as_millis())?; + } + Ok(()) +} + +#[test] +fn test_prepared_bundle_rejects_backtrace_mismatch_and_overwrite() -> Result<()> { + common::init(); + if !host_topology()? { + return Ok(()); + } + let fixture = Fixture::new(false)?; + let bundle = fixture.directory.path().join("trace.gsb"); + let rejected = fixture.prepare("trace bundle_probe { if true { bt; } }", &bundle)?; + ensure!(!rejected.status.success(), "backtrace was accepted"); + ensure!( + String::from_utf8_lossy(&rejected.stderr).contains("does not support bt/backtrace"), + "unexpected failure: {}", + String::from_utf8_lossy(&rejected.stderr) + ); + ensure!(!bundle.exists(), "failed preparation published a bundle"); + successful(fixture.prepare(SCRIPT, &bundle)?)?; + let original = fs::read(&bundle)?; + ensure!( + !fixture.prepare(SCRIPT, &bundle)?.status.success(), + "existing bundle was overwritten" + ); + ensure!(fs::read(&bundle)? == original, "existing bundle changed"); + + let target = ChildGuard(Command::new(&fixture.binary).arg("42").spawn()?); + for (field, value, expected) in [ + ("format_version", serde_json::json!(999), "format version"), + ( + "architecture", + serde_json::json!("unsupported"), + "architecture mismatch", + ), + ( + "ghostscope_version", + serde_json::json!("0.0.0"), + "version mismatch", + ), + ( + "target_sha256", + serde_json::json!(vec![0; 32]), + "identity mismatch", + ), + ] { + let mut contents: serde_json::Value = serde_json::from_slice(&original)?; + contents[field] = value; + let path = fixture.directory.path().join("bad.gsb"); + fs::write(&path, serde_json::to_vec(&contents)?)?; + let output = fixture + .command() + .arg("run-bundle") + .arg(path) + .arg("-p") + .arg(target.0.id().to_string()) + .output()?; + ensure!(!output.status.success(), "invalid {field} accepted"); + ensure!( + String::from_utf8_lossy(&output.stderr).contains(expected), + "unexpected {field} error: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let output = fixture + .command() + .arg("run-bundle") + .arg(&bundle) + .arg("-p") + .arg(std::process::id().to_string()) + .output()?; + ensure!(!output.status.success(), "unmapped target accepted"); + ensure!( + String::from_utf8_lossy(&output.stderr).contains("not mapped"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +#[test] +fn test_prepared_bundle_replays_static_diagnostics() -> Result<()> { + common::init(); + if !host_topology()? { + return Ok(()); + } + if !ghostscope_loader::KernelCapabilities::detect_for_startup(false)?.supports_uprobe_multi { + eprintln!("static bundle diagnostics test requires process-scoped multi-uprobes"); + return Ok(()); + } + for perf in [false, true] { + let mut fixture = Fixture::new(perf)?; + fixture.diagnostics = true; + let source = fixture.directory.path().join("diagnostics.rs"); + fs::write( + &source, + r#" +use std::cell::Cell; +#[no_mangle] pub static mut bundle_global: i32 = 73; +#[no_mangle] pub static mut bundle_large: Cell = Cell::new(String::new()); +#[inline(never)] #[no_mangle] pub fn bundle_probe(value: i32) { std::hint::black_box(value); std::hint::black_box(unsafe { bundle_global }); } +fn worker(value: i32) { loop { bundle_probe(value); std::thread::sleep(std::time::Duration::from_millis(10)); } } +fn main() { + unsafe { bundle_large = Cell::new(String::from("cell value")); } + std::thread::spawn(|| worker(43)); + worker(42); +} +"#, + )?; + successful( + Command::new("rustc") + .args(["-g", "--edition=2021"]) + .arg(&source) + .arg("-o") + .arg(&fixture.binary) + .output()?, + )?; + let config = + fs::read_to_string(&fixture.config)?.replace("[ebpf]", "[ebpf]\nmem_dump_cap = 24"); + fs::write(&fixture.config, config)?; + let bundle = fixture.directory.path().join("diagnostics.gsb"); + let preparation = successful(fixture.prepare(&fixture.script(), &bundle)?)?; + ensure!( + preparation.contains("capture-budget"), + "fixture produced no static diagnostic: {preparation}" + ); + let target = ChildGuard(Command::new(&fixture.binary).spawn()?); + capture(&fixture, target.0.id(), None)?; + capture(&fixture, target.0.id(), Some(&bundle))?; + } + Ok(()) +} diff --git a/ghostscope/Cargo.toml b/ghostscope/Cargo.toml index c8959b80..b6f74f2b 100644 --- a/ghostscope/Cargo.toml +++ b/ghostscope/Cargo.toml @@ -36,6 +36,6 @@ anyhow = { workspace = true } futures = { workspace = true } serde_json = { workspace = true } chrono = { workspace = true } +sha2 = "0.10" -[dev-dependencies] tempfile = { workspace = true } diff --git a/ghostscope/src/bundle/artifact.rs b/ghostscope/src/bundle/artifact.rs new file mode 100644 index 00000000..f9603b5b --- /dev/null +++ b/ghostscope/src/bundle/artifact.rs @@ -0,0 +1,309 @@ +use anyhow::{ensure, Context, Result}; +use ghostscope_compiler::{script::TracePattern, CompilationResult, UProbeConfig}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +use crate::config::settings::{EbpfConfig, ValueAdapterConfig}; + +// Bump when the stored probe/context schema or compiled runtime ABI changes. +const FORMAT_VERSION: u32 = 1; +const MAX_BUNDLE_BYTES: u64 = 64 * 1024 * 1024; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Bundle { + format_version: u32, + ghostscope_version: String, + architecture: String, + pub target: String, + pub target_sha256: [u8; 32], + pub module_cookie: u64, + pub script: String, + pub ebpf: EbpfConfig, + pub value_adapter: ValueAdapterConfig, + pub probes: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Probe { + function_name: Option, + function_address: u64, + uprobe_offset: u64, + ebpf_bytecode: Vec, + ebpf_function_name: String, + assigned_trace_id: u32, + trace_context: ghostscope_protocol::TraceContext, + resolved_address_index: Option, +} + +impl Bundle { + pub fn from_compilation( + target: String, + target_sha256: [u8; 32], + module_cookie: u64, + script: String, + ebpf: EbpfConfig, + value_adapter: ValueAdapterConfig, + compilation: CompilationResult, + ) -> Result { + ensure!( + compilation.failed_targets.is_empty(), + "prepare refuses a partially compiled script; fix all failed targets" + ); + ensure!(!compilation.uprobe_configs.is_empty(), "no probes compiled"); + let probes = compilation + .uprobe_configs + .into_iter() + .map(|config| { + ensure!( + !config.uses_backtrace, + "prepare does not support bt/backtrace yet" + ); + ensure!( + config.binary_path == target, + "prepare supports one target module only" + ); + Ok(Probe { + function_name: config.function_name, + function_address: config.function_address.context("probe has no address")?, + uprobe_offset: config + .uprobe_offset + .context("probe has no attachment offset")?, + ebpf_bytecode: config.ebpf_bytecode, + ebpf_function_name: config.ebpf_function_name, + assigned_trace_id: config.assigned_trace_id, + trace_context: config.trace_context, + resolved_address_index: config.resolved_address_index, + }) + }) + .collect::>>()?; + Ok(Self { + format_version: FORMAT_VERSION, + ghostscope_version: env!("CARGO_PKG_VERSION").into(), + architecture: std::env::consts::ARCH.into(), + target, + target_sha256, + module_cookie, + script, + ebpf, + value_adapter, + probes, + }) + } + + pub fn read(path: &Path) -> Result { + let file = open_regular_file(path) + .with_context(|| format!("cannot open bundle {}", path.display()))?; + ensure!( + file.metadata()?.len() <= MAX_BUNDLE_BYTES, + "bundle exceeds 64 MiB limit" + ); + let mut bytes = Vec::new(); + file.take(MAX_BUNDLE_BYTES + 1).read_to_end(&mut bytes)?; + ensure!( + bytes.len() as u64 <= MAX_BUNDLE_BYTES, + "bundle exceeds 64 MiB limit" + ); + let bundle: Self = serde_json::from_slice(&bytes).context("invalid trace bundle")?; + bundle.validate()?; + Ok(bundle) + } + + fn validate(&self) -> Result<()> { + self.ebpf.validate("trace bundle")?; + self.value_adapter.validate("trace bundle")?; + ensure!( + self.format_version == FORMAT_VERSION, + "unsupported bundle format version; prepare it again" + ); + ensure!( + self.ghostscope_version == env!("CARGO_PKG_VERSION"), + "bundle GhostScope version mismatch; prepare it again" + ); + ensure!( + self.architecture == std::env::consts::ARCH, + "bundle architecture mismatch" + ); + ensure!(!self.probes.is_empty(), "bundle contains no probes"); + let mut ids = std::collections::HashSet::new(); + for probe in &self.probes { + ensure!( + ids.insert(probe.assigned_trace_id), + "duplicate trace ID in bundle" + ); + ensure!( + probe.assigned_trace_id < u32::MAX, + "invalid trace ID in bundle" + ); + ensure!( + probe.ebpf_bytecode.starts_with(b"\x7fELF"), + "probe bytecode is not ELF" + ); + } + Ok(()) + } + + pub fn write(&self, path: &Path) -> Result<()> { + self.validate()?; + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut file = tempfile::NamedTempFile::new_in(parent)?; + serde_json::to_writer(file.as_file_mut(), self)?; + ensure!( + file.as_file().metadata()?.len() <= MAX_BUNDLE_BYTES, + "bundle exceeds 64 MiB limit" + ); + file.flush()?; + file.as_file().sync_all()?; + // The serializer has no matching nesting limit. Do not publish a file + // whose type context the bounded reader cannot deserialize. + Self::read(file.path()).context("prepared context exceeds bundle decoding limits")?; + file.persist_noclobber(path).with_context(|| { + format!( + "cannot create {}; existing bundles are not overwritten", + path.display() + ) + })?; + Ok(()) + } + + pub fn into_compilation(self, target: &str, pid: u32) -> CompilationResult { + let configs: Vec<_> = self + .probes + .into_iter() + .map(|probe| UProbeConfig { + trace_pattern: TracePattern::Address(probe.function_address), + binary_path: target.into(), + function_name: probe.function_name, + function_address: Some(probe.function_address), + uprobe_offset: Some(probe.uprobe_offset), + target_pid: Some(pid), + ebpf_bytecode: probe.ebpf_bytecode, + ebpf_function_name: probe.ebpf_function_name, + assigned_trace_id: probe.assigned_trace_id, + trace_context: probe.trace_context, + backtrace_unwind_rows: Vec::new(), + backtrace_module_row_ranges: Vec::new(), + backtrace_tail_call_program: None, + uses_backtrace: false, + resolved_address_index: probe.resolved_address_index, + }) + .collect(); + CompilationResult { + trace_count: configs.len(), + target_info: target.into(), + failed_targets: Vec::new(), + next_available_trace_id: configs + .iter() + .map(|p| p.assigned_trace_id + 1) + .max() + .unwrap_or(0), + uprobe_configs: configs, + } + } +} + +pub(super) fn fingerprint(file: &mut File) -> Result<[u8; 32]> { + file.seek(SeekFrom::Start(0))?; + let mut hash = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + Ok(hash.finalize().into()) +} + +/// Opening a FIFO/device must not block an offline identity check. +pub(super) fn open_regular_file(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY) + .open(path)?; + ensure!( + file.metadata()?.is_file(), + "expected a regular file: {}", + path.display() + ); + Ok(file) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oversized_bundle_is_rejected_before_json_decoding() -> Result<()> { + let file = tempfile::NamedTempFile::new()?; + file.as_file().set_len(MAX_BUNDLE_BYTES + 1)?; + assert!(Bundle::read(file.path()) + .unwrap_err() + .to_string() + .contains("64 MiB")); + Ok(()) + } + + #[test] + fn incomplete_bundle_is_rejected() -> Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + file.write_all(b"{\"format_version\":1,")?; + assert!(Bundle::read(file.path()) + .unwrap_err() + .to_string() + .contains("invalid trace bundle")); + Ok(()) + } + + #[test] + fn unreadable_type_context_is_not_published() -> Result<()> { + use ghostscope_protocol::TypeInfo; + let mut ty = TypeInfo::UnknownType { + name: "deep".into(), + }; + for _ in 0..128 { + ty = TypeInfo::PointerType { + target_type: Box::new(ty), + size: 8, + }; + } + let mut context = ghostscope_protocol::TraceContext::new(); + context.add_type(ty)?; + let bundle = Bundle { + format_version: FORMAT_VERSION, + ghostscope_version: env!("CARGO_PKG_VERSION").into(), + architecture: std::env::consts::ARCH.into(), + target: "unused".into(), + target_sha256: [0; 32], + module_cookie: 1, + script: String::new(), + ebpf: EbpfConfig::default(), + value_adapter: ValueAdapterConfig::default(), + probes: vec![Probe { + function_name: None, + function_address: 1, + uprobe_offset: 1, + ebpf_bytecode: b"\x7fELF".to_vec(), + ebpf_function_name: "probe".into(), + assigned_trace_id: 1, + trace_context: context, + resolved_address_index: None, + }], + }; + let directory = tempfile::tempdir()?; + let path = directory.path().join("trace.gsb"); + assert!(bundle.write(&path).is_err()); + assert!(!path.exists()); + assert_eq!(directory.path().read_dir()?.count(), 0); + Ok(()) + } +} diff --git a/ghostscope/src/bundle/mod.rs b/ghostscope/src/bundle/mod.rs new file mode 100644 index 00000000..1fb32490 --- /dev/null +++ b/ghostscope/src/bundle/mod.rs @@ -0,0 +1,232 @@ +//! Offline compilation and a DWARF-free, LLVM-free attach path. +mod artifact; + +use anyhow::{ensure, Context, Result}; +use clap::Parser; +use ghostscope_compiler::script::Statement; +use ghostscope_loader::KernelCapabilities; +use std::fs::File; +use std::os::fd::AsRawFd; +use std::os::unix::fs::MetadataExt; +use std::path::Path; +use std::time::Instant; + +use crate::config::args::{PrepareArgs, RunBundleArgs}; +use crate::config::{Args, Config, ResolvedConfig, UserConfig}; +use crate::core::GhostSession; +use artifact::{fingerprint, open_regular_file, Bundle}; + +// This is a compilation profile, not a probe of the preparing machine's kernel. +// Runtime must explicitly verify it before loading any saved bytecode. +fn compilation_profile(user: &UserConfig) -> KernelCapabilities { + KernelCapabilities { + supports_uprobe_multi: true, + supports_ringbuf: !user.ebpf_config.force_perf_event_array, + supports_perf_event_array: true, + supports_ns_current_pid_tgid_helper: false, + supports_sleepable_uprobe: user.ebpf_config.sleepable_uprobe, + supports_sleepable_tail_calls: false, + } +} + +fn reject_backtrace(statements: &[Statement]) -> Result<()> { + for statement in statements { + match statement { + Statement::Backtrace(_) => anyhow::bail!("prepare does not support bt/backtrace yet"), + Statement::TracePoint { body, .. } | Statement::Block(body) => reject_backtrace(body)?, + Statement::If { + then_body, + else_body, + .. + } => { + reject_backtrace(then_body)?; + if let Some(body) = else_body { + reject_backtrace(std::slice::from_ref(body))?; + } + } + _ => {} + } + } + Ok(()) +} + +pub(crate) async fn prepare(args: PrepareArgs) -> Result<()> { + let started = Instant::now(); + ensure!( + !args.output.try_exists()?, + "output already exists: {}", + args.output.display() + ); + let mut trace_args = Args::try_parse_from(["ghostscope"])?; + trace_args.target = Some(args.target); + trace_args.script = args.script; + trace_args.script_file = args.script_file; + trace_args.debug_file = args.debug_file; + trace_args.config = args.config.clone(); + let parsed = trace_args.into_parsed()?; + let mut user = UserConfig::new_with_explicit_config(parsed, args.config)?; + user.dry_run = true; + user.tui_mode = false; + user.dwarf_backtrace_runtime_modules = false; + user.validate_with_pid_state(false)?; + ensure!( + !user.ebpf_config.sleepable_uprobe || !user.ebpf_config.force_perf_event_array, + "sleepable uprobes require RingBuf output" + ); + let script = match (&user.script, &user.script_file) { + (Some(script), _) => script.clone(), + (_, Some(file)) => std::fs::read_to_string(file).context("cannot read trace script")?, + _ => anyhow::bail!("prepare requires --script or --script-file"), + }; + reject_backtrace(&ghostscope_compiler::script::parser::parse(&script)?.statements)?; + let target = std::fs::canonicalize(user.target_path.as_ref().context("prepare requires -t")?)?; + let target = ghostscope_process::module_probe::module_path_str(&target)?.to_owned(); + user.target_path = Some(target.clone()); + let mut target_file = open_regular_file(Path::new(&target))?; + let target_sha256 = fingerprint(&mut target_file)?; + let module_cookie = ghostscope_compiler::module_cookie_for_path(&target); + let profile = compilation_profile(&user); + let config = ResolvedConfig { + user, + runtime: Default::default(), + kernel_capabilities: profile, + }; + let mut session = GhostSession::new_with_config(&config); + session.load_binary_parallel().await?; + let options = config.get_compile_options(false, false, false, Some(target.clone())); + let compiled = crate::script::compile_script_for_cli(&script, &mut session, &options)?; + ensure!( + target_sha256 == fingerprint(&mut open_regular_file(Path::new(&target))?)?, + "target changed during preparation" + ); + let bundle = Bundle::from_compilation( + target, + target_sha256, + module_cookie, + script, + config.ebpf_config.clone(), + config.value_adapter_config, + compiled, + )?; + bundle.write(&args.output)?; + eprintln!( + "Prepared {} probe(s) in {:.1} ms: {} (no BPF programs loaded)", + bundle.probes.len(), + started.elapsed().as_secs_f64() * 1000.0, + args.output.display() + ); + Ok(()) +} + +pub(crate) async fn run(args: RunBundleArgs) -> Result<()> { + let started = Instant::now(); + let bundle = Bundle::read(&args.bundle)?; + let mut trace_args = Args::try_parse_from(["ghostscope"])?; + trace_args.target = Some(args.target.unwrap_or_else(|| bundle.target.clone())); + trace_args.pid = Some(args.pid); + trace_args.script = Some(bundle.script.clone()); + trace_args.script_output = Some(args.script_output); + trace_args.no_status = args.no_status; + trace_args.emit_ready_marker = args.emit_ready_marker; + let mut user = UserConfig::new(trace_args.into_parsed()?, Config::default()); + user.ebpf_config = bundle.ebpf.clone(); + user.value_adapter_config = bundle.value_adapter; + user.dwarf_backtrace_runtime_modules = false; + user.should_save_llvm_ir = false; + user.should_save_ebpf = false; + user.should_save_ast = false; + let target = user.target_path.clone().context("bundle has no target")?; + let mut target_file = + open_regular_file(Path::new(&target)).context("cannot open bundle target")?; + ensure!( + fingerprint(&mut target_file)? == bundle.target_sha256, + "bundle target identity mismatch (SHA-256); prepare it again for this binary" + ); + ensure!( + ghostscope_compiler::module_cookie_for_path(&target) == bundle.module_cookie, + "bundle module cookie mismatch; targets without a build ID must retain their file identity" + ); + let start_time = process_start_time(args.pid)?; + ensure_target_is_mapped(args.pid, &target_file)?; + + crate::util::ensure_privileges(); + let caps = if user.ebpf_config.sleepable_uprobe { + KernelCapabilities::detect_for_startup_with_sleepable_uprobe( + user.ebpf_config.force_perf_event_array, + )? + } else { + KernelCapabilities::detect_for_startup(user.ebpf_config.force_perf_event_array)? + }; + ensure!(caps.supports_uprobe_multi, "run-bundle requires process-scoped multi-uprobe support; no recompilation fallback is performed"); + ensure!( + if user.ebpf_config.force_perf_event_array { + caps.supports_perf_event_array + } else { + caps.supports_ringbuf + }, + "kernel does not support the bundle event transport; prepare a compatible bundle" + ); + let config = ResolvedConfig::resolve(user, &caps)?; + ensure!(!config.runtime.runtime_env.is_container_likely() + && config.runtime.pid_views.as_ref().is_some_and(|views| views.is_initial_pid_namespace()), + "run-bundle currently supports host processes only; container PID namespaces require runtime binding support"); + let _cleanup = crate::util::PinnedMapsCleanupGuard::new(); + // No load_binary* call: the process manager only reads ELF mappings/offsets. + let mut session = GhostSession::new_with_config(&config); + let mut options = config.get_compile_options(false, false, false, Some(target.clone())); + options.pid_filter_spec = None; + options.special_pid_ns = None; + options.proc_offsets_pid_ns = None; + options.input_pid = None; + let script = bundle.script.clone(); + let compilation = bundle.into_compilation(&target, args.pid); + crate::script::report_value_diagnostics(&compilation); + // Attach through the retained descriptor, so a pathname replacement cannot + // substitute another inode after the fingerprint/mapping checks. + let attach_target = format!("/proc/self/fd/{}", target_file.as_raw_fd()); + crate::script::load_compiled_script_for_cli( + &script, + compilation, + &mut session, + &options, + Some(&attach_target), + ) + .await?; + ensure!( + process_start_time(args.pid)? == start_time, + "target process changed during bundle attachment" + ); + ensure_target_is_mapped(args.pid, &target_file)?; + if config.script_status { + eprintln!( + "Bundle attached in {:.1} ms (DWARF parsing and LLVM codegen skipped)", + started.elapsed().as_secs_f64() * 1000.0 + ); + } + crate::cli::script_runtime::monitor_cli_session(session, &config).await +} + +fn process_start_time(pid: u32) -> Result { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?; + stat.rsplit_once(')') + .and_then(|(_, rest)| rest.split_whitespace().nth(19)) + .map(str::to_owned) + .context("invalid process stat start time") +} + +fn ensure_target_is_mapped(pid: u32, file: &File) -> Result<()> { + let meta = file.metadata()?; + let dev = meta.dev(); + ensure!( + ghostscope_process::proc_maps::read_proc_maps(pid)? + .iter() + .any(|entry| { + entry.inode == meta.ino() + && entry.dev_major == u64::from(libc::major(dev)) + && entry.dev_minor == u64::from(libc::minor(dev)) + && entry.perms.contains('x') + }), + "bundle target is not mapped as executable code in PID {pid}" + ); + Ok(()) +} diff --git a/ghostscope/src/cli/script_runtime.rs b/ghostscope/src/cli/script_runtime.rs index 4050d3e9..229dee20 100644 --- a/ghostscope/src/cli/script_runtime.rs +++ b/ghostscope/src/cli/script_runtime.rs @@ -364,6 +364,17 @@ async fn run_cli_with_session( return Err(e); } + monitor_cli_session(session, config).await +} + +/// Share output, loss reporting, cancellation and teardown across compiled and prepared traces. +pub(crate) async fn monitor_cli_session( + mut session: GhostSession, + config: &ResolvedConfig, +) -> Result<()> { + let show_cli_status = should_print_cli_status(config); + let stderr_colors = crate::cli::color::CliColors::for_stderr(config.script_color_mode); + // Step 8: Start event monitoring loop using trace_manager info!( "Starting event monitoring for {} active traces", diff --git a/ghostscope/src/config/args.rs b/ghostscope/src/config/args.rs index 4bc9f4f3..8fc7579a 100644 --- a/ghostscope/src/config/args.rs +++ b/ghostscope/src/config/args.rs @@ -38,11 +38,52 @@ pub enum ScriptTimestampFormat { #[derive(Debug, Clone)] pub enum ParsedCommand { Trace(Box), + Prepare(PrepareArgs), + RunBundle(RunBundleArgs), Bpffs(BpffsCommand), ScriptHelp, ValueDiagnosticsHelp, } +#[derive(ClapArgs, Debug, Clone)] +pub struct PrepareArgs { + /// Target module to compile against, without attaching to a process + #[arg(short = 't', long = "target", value_name = "PATH")] + pub target: String, + #[arg( + long, + required_unless_present = "script_file", + conflicts_with = "script_file" + )] + pub script: Option, + #[arg(long, value_name = "PATH")] + pub script_file: Option, + /// New bundle file; an existing file is never overwritten + #[arg(short = 'o', long = "output")] + pub output: PathBuf, + #[arg(long)] + pub config: Option, + #[arg(long)] + pub debug_file: Option, +} + +#[derive(ClapArgs, Debug, Clone)] +pub struct RunBundleArgs { + pub bundle: PathBuf, + /// Host process to attach to + #[arg(short = 'p', long = "pid", value_parser = clap::value_parser!(u32).range(1..=i32::MAX as i64))] + pub pid: u32, + /// Location of an identical target module (defaults to the prepared path) + #[arg(short = 't', long = "target")] + pub target: Option, + #[arg(long, value_enum, default_value = "pretty")] + pub script_output: ScriptOutputMode, + #[arg(long)] + pub no_status: bool, + #[arg(long, value_name = "TEXT", hide = true)] + pub emit_ready_marker: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum BpffsCommand { Prune(BpffsPruneArgs), @@ -102,7 +143,11 @@ impl BpffsPruneArgs { } #[derive(Subcommand, Debug)] -enum BpffsTopLevelCommand { +enum TopLevelCommand { + /// Compile a reusable trace bundle without BPF privileges + Prepare(PrepareArgs), + /// Attach a prepared trace bundle without DWARF parsing or LLVM codegen + RunBundle(RunBundleArgs), /// Manage GhostScope bpffs pin directories Bpffs(BpffsArgs), } @@ -412,6 +457,12 @@ impl Args { } fn parse_args_from(args: Vec) -> ParsedCommand { + if matches!( + args.get(1).map(String::as_str), + Some("prepare" | "run-bundle") + ) { + return Self::try_parse_dispatch(&args).unwrap_or_else(|e| e.exit()); + } let bpffs_prune_invocation = Self::is_bpffs_prune_invocation(&args); let args_pos = args.iter().position(|arg| arg == "--args"); if Self::is_reference_help_invocation(&args, args_pos) { @@ -494,12 +545,13 @@ impl Args { .unwrap_or_else(|e| e.exit()) } - fn command_with_bpffs() -> clap::Command { - BpffsTopLevelCommand::augment_subcommands(Args::command()) + fn command_with_subcommands() -> clap::Command { + TopLevelCommand::augment_subcommands(Args::command()) } fn try_parse_dispatch(args: &[String]) -> std::result::Result { - let matches = Self::command_with_bpffs().try_get_matches_from(args.iter().cloned())?; + let matches = + Self::command_with_subcommands().try_get_matches_from(args.iter().cloned())?; if matches.get_flag("script_help") { return Ok(ParsedCommand::ScriptHelp); @@ -508,6 +560,16 @@ impl Args { return Ok(ParsedCommand::ValueDiagnosticsHelp); } + if let Some(("prepare", sub_matches)) = matches.subcommand() { + return Ok(ParsedCommand::Prepare(PrepareArgs::from_arg_matches( + sub_matches, + )?)); + } + if let Some(("run-bundle", sub_matches)) = matches.subcommand() { + return Ok(ParsedCommand::RunBundle(RunBundleArgs::from_arg_matches( + sub_matches, + )?)); + } if let Some(("bpffs", sub_matches)) = matches.subcommand() { let bpffs = BpffsArgs::from_arg_matches(sub_matches)?; return Ok(ParsedCommand::Bpffs(match bpffs.command { @@ -523,6 +585,12 @@ impl Args { ))) } + pub(crate) fn into_parsed(self) -> std::result::Result { + let binary = self.binary.clone(); + let remaining = self.remaining.clone(); + Self::parsed_trace_args_from_clap(self, binary, remaining) + } + fn parsed_trace_args_from_clap( parsed: Args, binary_path: Option, @@ -798,7 +866,9 @@ mod tests { #[test] fn root_help_lists_bpffs_subcommand() { - let help = Args::command_with_bpffs().render_long_help().to_string(); + let help = Args::command_with_subcommands() + .render_long_help() + .to_string(); assert!(help.contains("bpffs")); assert!(help.contains("--script-help")); assert!(help.contains("--value-diagnostics-help")); diff --git a/ghostscope/src/main.rs b/ghostscope/src/main.rs index cef8511a..beb66753 100644 --- a/ghostscope/src/main.rs +++ b/ghostscope/src/main.rs @@ -1,3 +1,4 @@ +mod bundle; mod cli; mod config; mod core; @@ -16,9 +17,11 @@ async fn main() -> Result<()> { // Setup panic hook before doing anything else crate::util::setup_panic_hook(); - // Parse command line arguments and route explicit maintenance commands + // Route explicit commands before privileged tracing setup (prepare is offline). let parsed_args = match config::Args::parse_args() { config::ParsedCommand::Trace(parsed_args) => *parsed_args, + config::ParsedCommand::Prepare(args) => return bundle::prepare(args).await, + config::ParsedCommand::RunBundle(args) => return bundle::run(args).await, config::ParsedCommand::Bpffs(config::BpffsCommand::Prune(prune_args)) => { return cli::run_bpffs_prune(&prune_args); } diff --git a/ghostscope/src/script/attach.rs b/ghostscope/src/script/attach.rs index d414e0a4..0b046eff 100644 --- a/ghostscope/src/script/attach.rs +++ b/ghostscope/src/script/attach.rs @@ -51,6 +51,7 @@ pub(super) async fn create_and_attach_loader( attach_pid: Option, session: &mut GhostSession, compile_options: &ghostscope_compiler::CompileOptions, + attach_target: Option<&str>, ) -> Result { info!( "Creating new eBPF loader with {} bytes of bytecode for trace_id {}", @@ -252,7 +253,7 @@ pub(super) async fn create_and_attach_loader( function_name, uprobe_offset, config.binary_path, config.ebpf_function_name ); loader.attach_uprobe_with_program_name( - &config.binary_path, + attach_target.unwrap_or(&config.binary_path), function_name, Some(uprobe_offset), attach_pid.map(|p| p as i32), @@ -264,7 +265,7 @@ pub(super) async fn create_and_attach_loader( uprobe_offset, config.binary_path, config.ebpf_function_name ); loader.attach_uprobe_with_program_name( - &config.binary_path, + attach_target.unwrap_or(&config.binary_path), &format!("0x{uprobe_offset:x}"), Some(uprobe_offset), attach_pid.map(|p| p as i32), diff --git a/ghostscope/src/script/cli.rs b/ghostscope/src/script/cli.rs index 7bd32391..f6c6af79 100644 --- a/ghostscope/src/script/cli.rs +++ b/ghostscope/src/script/cli.rs @@ -76,6 +76,13 @@ pub fn compile_script_for_cli( } } + report_value_diagnostics(&compilation_result); + Ok(compilation_result) +} + +pub(crate) fn report_value_diagnostics( + compilation_result: &ghostscope_compiler::CompilationResult, +) { for config in &compilation_result.uprobe_configs { for message in config.trace_context.value_diagnostic_messages() { eprintln!( @@ -85,7 +92,6 @@ pub fn compile_script_for_cli( ); } } - Ok(compilation_result) } /// Compile and load script for command line mode using session.command_loaders. @@ -98,6 +104,16 @@ pub async fn compile_and_load_script_for_cli( prepare_runtime_modules_before_compile(script, session, &mut compile_options).await?; let compilation_result = compile_script_for_cli(script, session, &compile_options)?; + load_compiled_script_for_cli(script, compilation_result, session, &compile_options, None).await +} + +pub(crate) async fn load_compiled_script_for_cli( + script: &str, + compilation_result: ghostscope_compiler::CompilationResult, + session: &mut GhostSession, + compile_options: &ghostscope_compiler::CompileOptions, + attach_target: Option<&str>, +) -> Result<()> { ensure_prefill_for_session_pid(session)?; let ghostscope_compiler::CompilationResult { @@ -114,8 +130,14 @@ pub async fn compile_and_load_script_for_cli( let mut attached_count = 0; for config in &uprobe_configs { - match create_and_attach_loader(config, session.attach_pid(), session, &compile_options) - .await + match create_and_attach_loader( + config, + session.attach_pid(), + session, + compile_options, + attach_target, + ) + .await { Ok(loader) => { info!( diff --git a/ghostscope/src/script/mod.rs b/ghostscope/src/script/mod.rs index 44319b49..325ea3cb 100644 --- a/ghostscope/src/script/mod.rs +++ b/ghostscope/src/script/mod.rs @@ -9,4 +9,5 @@ mod tui; // Re-export main functions for convenience pub use cli::{compile_and_load_script_for_cli, compile_script_for_cli}; +pub(crate) use cli::{load_compiled_script_for_cli, report_value_diagnostics}; pub use tui::compile_and_load_script_for_tui; diff --git a/ghostscope/src/script/tui.rs b/ghostscope/src/script/tui.rs index 298fb926..f75f6ec1 100644 --- a/ghostscope/src/script/tui.rs +++ b/ghostscope/src/script/tui.rs @@ -61,8 +61,14 @@ pub async fn compile_and_load_script_for_tui( let mut attached_count = 0; for config in &uprobe_configs { - match create_and_attach_loader(config, session.attach_pid(), session, &compile_options) - .await + match create_and_attach_loader( + config, + session.attach_pid(), + session, + &compile_options, + None, + ) + .await { Ok(loader) => { info!(