@@ -6,8 +6,11 @@ import (
66 "crypto/rand"
77 "encoding/hex"
88 "fmt"
9+ "os"
10+ "path/filepath"
911 "strings"
1012
13+ "github.com/caarlos0/env/v11"
1114 "github.com/urfave/cli/v3"
1215)
1316
@@ -35,11 +38,298 @@ type Config struct {
3538 CustomAttributes []CustomAttribute
3639}
3740
38- // ParseArgs parses command-line arguments using urfave/cli and returns a Config.
39- // Expected format: program_name [--trace-id <id>] [--parent-id <id>] [-a name=expr]... -- <command> [args...].
40- // licenseText is displayed when --license flag is used.
41- // version, commit, and buildDate are build-time injected values displayed with --version.
41+ // EnvConfig holds process-tracer configuration from environment variables.
42+ type EnvConfig struct {
43+ TraceID string `env:"PROCESS_TRACER_TRACE_ID"`
44+ ParentID string `env:"PROCESS_TRACER_PARENT_ID"`
45+ Attributes string `env:"PROCESS_TRACER_ATTRIBUTES"`
46+ Mode string `env:"PROCESS_TRACER_MODE" envDefault:"auto"`
47+ ShellBinary string `env:"PROCESS_TRACER_SHELL_BINARY"`
48+ }
49+
50+ // ParseEnvConfig parses process-tracer configuration from environment variables.
51+ func ParseEnvConfig () (* EnvConfig , error ) {
52+ var cfg EnvConfig
53+ if err := env .Parse (& cfg ); err != nil {
54+ return nil , fmt .Errorf ("failed to parse process-tracer env config: %w" , err )
55+ }
56+ return & cfg , nil
57+ }
58+
59+ // ParseAttributeString parses semicolon-separated NAME=EXPR pairs.
60+ // Format: "name1=expr1;name2=expr2;name3=expr3"
61+ func ParseAttributeString (attrStr string ) ([]CustomAttribute , error ) {
62+ if attrStr == "" {
63+ return nil , nil
64+ }
65+
66+ var attrs []CustomAttribute
67+ pairs := strings .Split (attrStr , ";" )
68+
69+ for _ , pair := range pairs {
70+ pair = strings .TrimSpace (pair )
71+ if pair == "" {
72+ continue
73+ }
74+
75+ // Split on first '=' to separate name from expression
76+ parts := strings .SplitN (pair , "=" , 2 )
77+ if len (parts ) != 2 {
78+ return nil , fmt .Errorf ("invalid attribute format %q: expected NAME=EXPR" , pair )
79+ }
80+
81+ name := strings .TrimSpace (parts [0 ])
82+ expr := strings .TrimSpace (parts [1 ])
83+
84+ if name == "" {
85+ return nil , fmt .Errorf ("attribute name cannot be empty in %q" , pair )
86+ }
87+ if expr == "" {
88+ return nil , fmt .Errorf ("attribute expression cannot be empty in %q" , pair )
89+ }
90+
91+ attrs = append (attrs , CustomAttribute {
92+ Name : name ,
93+ Expression : expr ,
94+ })
95+ }
96+
97+ return attrs , nil
98+ }
99+
100+ // detectSymlinkMode determines if the binary is invoked via symlink.
101+ // Returns true if symlink mode should be used, false for direct CLI mode.
102+ func detectSymlinkMode (mode string ) (bool , error ) {
103+ // Check explicit override
104+ switch mode {
105+ case "direct" :
106+ return false , nil
107+ case "symlink" :
108+ return true , nil
109+ case "auto" :
110+ // Continue to auto-detection
111+ case "" :
112+ // Empty string means auto-detect (default)
113+ default :
114+ return false , fmt .Errorf ("invalid PROCESS_TRACER_MODE: %s (must be auto, direct, or symlink)" , mode )
115+ }
116+
117+ // Auto-detect: check if os.Args[0] is a symlink to ourselves
118+ if len (os .Args ) == 0 {
119+ return false , nil
120+ }
121+
122+ selfPath , err := os .Executable ()
123+ if err != nil {
124+ return false , nil // Can't determine, assume direct mode
125+ }
126+
127+ argsPath := os .Args [0 ]
128+ if ! filepath .IsAbs (argsPath ) {
129+ // Make it absolute
130+ argsPath , err = filepath .Abs (argsPath )
131+ if err != nil {
132+ return false , nil
133+ }
134+ }
135+
136+ // Resolve symlinks for os.Args[0]
137+ resolvedArgs , err := filepath .EvalSymlinks (argsPath )
138+ if err != nil {
139+ // Can't resolve, assume direct mode
140+ return false , nil
141+ }
142+
143+ resolvedSelf , err := filepath .EvalSymlinks (selfPath )
144+ if err != nil {
145+ return false , nil
146+ }
147+
148+ // If they resolve to the same path but os.Args[0] != resolved path, it's a symlink
149+ if resolvedArgs == resolvedSelf && argsPath != resolvedArgs {
150+ return true , nil
151+ }
152+
153+ return false , nil
154+ }
155+
156+ // isExecutable checks if a file exists and is executable.
157+ func isExecutable (path string ) bool {
158+ info , err := os .Stat (path )
159+ if err != nil {
160+ return false
161+ }
162+ return ! info .IsDir () && info .Mode ()& 0111 != 0
163+ }
164+
165+ // isSelfBinary checks if the given path points to the current executable.
166+ // Uses os.SameFile to handle symlinks and hardlinks correctly.
167+ func isSelfBinary (path string ) (bool , error ) {
168+ selfPath , err := os .Executable ()
169+ if err != nil {
170+ return false , err
171+ }
172+
173+ // Resolve both to absolute paths
174+ absPath , err := filepath .Abs (path )
175+ if err != nil {
176+ return false , err
177+ }
178+
179+ absSelf , err := filepath .Abs (selfPath )
180+ if err != nil {
181+ return false , err
182+ }
183+
184+ // Get file info for both
185+ selfInfo , err := os .Stat (absSelf )
186+ if err != nil {
187+ return false , err
188+ }
189+
190+ pathInfo , err := os .Stat (absPath )
191+ if err != nil {
192+ return false , err
193+ }
194+
195+ // Compare using SameFile to handle symlinks/hardlinks
196+ return os .SameFile (selfInfo , pathInfo ), nil
197+ }
198+
199+ // resolveShellBinary determines the actual shell binary to execute when running in shell mode.
200+ // Resolution order:
201+ // 1. PROCESS_TRACER_SHELL_BINARY env var (if set)
202+ // 2. Search PATH for binary matching symlink basename (excluding self)
203+ // 3. Check common locations: /bin/<name>, /usr/bin/<name>, /usr/local/bin/<name>
204+ // Returns absolute path to shell binary, or error if not found.
205+ func resolveShellBinary (symlinkName string , envOverride string ) (string , error ) {
206+ basename := filepath .Base (symlinkName )
207+
208+ // 1. Check environment variable override
209+ if envOverride != "" {
210+ if ! isExecutable (envOverride ) {
211+ return "" , fmt .Errorf ("PROCESS_TRACER_SHELL_BINARY=%q is not executable or does not exist" , envOverride )
212+ }
213+ absPath , err := filepath .Abs (envOverride )
214+ if err != nil {
215+ return "" , fmt .Errorf ("failed to resolve PROCESS_TRACER_SHELL_BINARY path: %w" , err )
216+ }
217+ return absPath , nil
218+ }
219+
220+ // 2. Search PATH for the binary
221+ pathEnv := os .Getenv ("PATH" )
222+ if pathEnv != "" {
223+ for _ , dir := range filepath .SplitList (pathEnv ) {
224+ candidate := filepath .Join (dir , basename )
225+ if ! isExecutable (candidate ) {
226+ continue
227+ }
228+
229+ // Skip if it's ourselves
230+ isSelf , err := isSelfBinary (candidate )
231+ if err != nil {
232+ // If we can't determine, skip this candidate
233+ continue
234+ }
235+ if isSelf {
236+ continue
237+ }
238+
239+ // Found a valid binary that's not us
240+ absPath , err := filepath .Abs (candidate )
241+ if err != nil {
242+ continue
243+ }
244+ return absPath , nil
245+ }
246+ }
247+
248+ // 3. Try common locations
249+ commonLocations := []string {
250+ filepath .Join ("/bin" , basename ),
251+ filepath .Join ("/usr/bin" , basename ),
252+ filepath .Join ("/usr/local/bin" , basename ),
253+ }
254+
255+ for _ , location := range commonLocations {
256+ if ! isExecutable (location ) {
257+ continue
258+ }
259+
260+ // Skip if it's ourselves
261+ isSelf , err := isSelfBinary (location )
262+ if err != nil || isSelf {
263+ continue
264+ }
265+
266+ return location , nil
267+ }
268+
269+ // 4. Not found - return helpful error
270+ return "" , fmt .Errorf ("could not resolve shell binary for %q\n \n Tried:\n - PROCESS_TRACER_SHELL_BINARY environment variable (not set)\n - Searching PATH for %q (excluding process-tracer)\n - /bin/%s (not found)\n - /usr/bin/%s (not found)\n - /usr/local/bin/%s (not found)\n \n To fix:\n 1. Install %s: apt-get install %s (or equivalent)\n 2. Or set: export PROCESS_TRACER_SHELL_BINARY=/path/to/%s" ,
271+ basename , basename , basename , basename , basename , basename , basename , basename )
272+ }
273+
274+ // parseSymlinkMode handles configuration when invoked via symlink.
275+ // Resolves the actual shell binary and passes all args to it.
276+ func parseSymlinkMode (args []string , envCfg * EnvConfig ) (* Config , error ) {
277+ symlinkName := args [0 ]
278+
279+ // Resolve shell binary
280+ shellBinary , err := resolveShellBinary (symlinkName , envCfg .ShellBinary )
281+ if err != nil {
282+ return nil , fmt .Errorf ("failed to resolve shell binary for %q: %w" ,
283+ filepath .Base (symlinkName ), err )
284+ }
285+
286+ // Parse custom attributes from environment
287+ customAttrs , err := ParseAttributeString (envCfg .Attributes )
288+ if err != nil {
289+ return nil , err
290+ }
291+
292+ return & Config {
293+ Command : shellBinary ,
294+ Args : args [1 :], // ALL args go to shell
295+ TraceID : envCfg .TraceID ,
296+ ParentID : envCfg .ParentID ,
297+ CustomAttributes : customAttrs ,
298+ }, nil
299+ }
300+
301+ // ParseArgs is the main entry point for configuration parsing.
302+ // It handles both symlink mode (env vars only) and direct mode (CLI + env vars).
303+ // Maintained for backward compatibility - new code should use ParseConfig.
42304func ParseArgs (args []string , licenseText string , version , commit , buildDate string ) (* Config , error ) {
305+ return ParseConfig (args , licenseText , version , commit , buildDate )
306+ }
307+
308+ // ParseConfig is the unified configuration parser.
309+ // It handles both symlink mode (env vars only) and direct mode (CLI + env vars).
310+ func ParseConfig (args []string , licenseText string , version , commit , buildDate string ) (* Config , error ) {
311+ // Parse environment configuration
312+ envCfg , err := ParseEnvConfig ()
313+ if err != nil {
314+ return nil , fmt .Errorf ("failed to parse environment config: %w" , err )
315+ }
316+
317+ // Detect invocation mode
318+ isSymlinkMode , err := detectSymlinkMode (envCfg .Mode )
319+ if err != nil {
320+ return nil , err
321+ }
322+
323+ if isSymlinkMode {
324+ return parseSymlinkMode (args , envCfg )
325+ }
326+
327+ return parseDirectMode (args , envCfg , licenseText , version , commit , buildDate )
328+ }
329+
330+ // parseDirectMode handles configuration when invoked directly.
331+ // Parses CLI arguments and merges with environment variables (CLI overrides env).
332+ func parseDirectMode (args []string , envCfg * EnvConfig , licenseText string , version , commit , buildDate string ) (* Config , error ) {
43333 var traceID string
44334 var parentID string
45335 var customAttrs []CustomAttribute
@@ -129,16 +419,34 @@ func ParseArgs(args []string, licenseText string, version, commit, buildDate str
129419 return fmt .Errorf ("no command specified\n \n Use '--' to separate options from the command to trace.\n \n Example: process-tracer -a env_name='env[\" ENVIRONMENT\" ]' -- bash -c 'echo hello'" )
130420 }
131421
132- // Store config for return
133- // Note: traceID and parentID remain empty strings if not provided by user
134- // They will be treated as expressions to evaluate, or trigger auto-generation
135- // if empty
422+ // Merge with environment config (CLI overrides)
423+ finalTraceID := traceID
424+ if finalTraceID == "" {
425+ finalTraceID = envCfg .TraceID
426+ }
427+
428+ finalParentID := parentID
429+ if finalParentID == "" {
430+ finalParentID = envCfg .ParentID
431+ }
432+
433+ // Parse env attributes and prepend (CLI attributes take precedence)
434+ var finalAttrs []CustomAttribute
435+ if envCfg .Attributes != "" {
436+ envAttrs , err := ParseAttributeString (envCfg .Attributes )
437+ if err != nil {
438+ return err
439+ }
440+ finalAttrs = append (finalAttrs , envAttrs ... )
441+ }
442+ finalAttrs = append (finalAttrs , customAttrs ... )
443+
136444 resultCfg = & Config {
137445 Command : cmdArgs [0 ],
138446 Args : cmdArgs [1 :],
139- TraceID : traceID ,
140- ParentID : parentID ,
141- CustomAttributes : customAttrs ,
447+ TraceID : finalTraceID ,
448+ ParentID : finalParentID ,
449+ CustomAttributes : finalAttrs ,
142450 }
143451
144452 return nil
0 commit comments