github.com/symfony-cli/symfony-cli@v0.0.0-20240514161054-ece2df437dfa/local/php/executor.go (about) 1 /* 2 * Copyright (c) 2021-present Fabien Potencier <fabien@symfony.com> 3 * 4 * This file is part of Symfony CLI project 5 * 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU Affero General Public License as 8 * published by the Free Software Foundation, either version 3 of the 9 * License, or (at your option) any later version. 10 * 11 * This program is distributed in the hope that it will be useful, 12 * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 * GNU Affero General Public License for more details. 15 * 16 * You should have received a copy of the GNU Affero General Public License 17 * along with this program. If not, see <http://www.gnu.org/licenses/>. 18 */ 19 20 package php 21 22 import ( 23 "fmt" 24 "io" 25 "os" 26 "os/exec" 27 "os/signal" 28 "path/filepath" 29 "runtime" 30 "strings" 31 "syscall" 32 33 "github.com/pkg/errors" 34 "github.com/rs/xid" 35 "github.com/rs/zerolog" 36 "github.com/symfony-cli/phpstore" 37 "github.com/symfony-cli/symfony-cli/envs" 38 "github.com/symfony-cli/symfony-cli/util" 39 "github.com/symfony-cli/terminal" 40 ) 41 42 type Executor struct { 43 Dir string 44 BinName string 45 Args []string 46 SkipNbArgs int 47 Stdout io.Writer 48 Stderr io.Writer 49 Stdin io.Reader 50 Paths []string 51 ExtraEnv []string 52 Logger zerolog.Logger 53 54 environ []string 55 iniDir string 56 scriptDir string 57 tempDir string 58 } 59 60 var execCommand = exec.Command 61 62 // IsBinaryName returns true if the command is a PHP binary name 63 func IsBinaryName(name string) bool { 64 for _, bin := range GetBinaryNames() { 65 if name == bin { 66 return true 67 } 68 } 69 return false 70 } 71 72 func GetBinaryNames() []string { 73 return []string{"php", "pecl", "pear", "php-fpm", "php-cgi", "php-config", "phpdbg", "phpize"} 74 } 75 76 func (e *Executor) lookupPHP(cliDir string, forceReload bool) (*phpstore.Version, string, bool, error) { 77 phpStore := phpstore.New(cliDir, forceReload, nil) 78 v, source, warning, err := phpStore.BestVersionForDir(e.scriptDir) 79 if warning != "" { 80 terminal.Eprintfln("<warning>WARNING</> %s", warning) 81 } 82 if err != nil { 83 return nil, "", true, err 84 } 85 e.Logger.Debug().Str("source", "PHP").Msgf("Using PHP version %s (from %s)", v.Version, source) 86 path := v.PHPPath 87 phpiniArgs := true 88 if e.BinName == "php-fpm" { 89 if v.FPMPath == "" { 90 return nil, "", true, errors.Errorf("%s does not seem to be available under %s\n", e.BinName, filepath.Dir(path)) 91 } 92 path = v.FPMPath 93 } 94 if e.BinName == "php-cgi" { 95 if v.CGIPath == "" { 96 return nil, "", true, errors.Errorf("%s does not seem to be available under %s\n", e.BinName, filepath.Dir(path)) 97 } 98 path = v.CGIPath 99 } 100 if e.BinName == "php-config" { 101 if v.PHPConfigPath == "" { 102 return nil, "", true, errors.Errorf("%s does not seem to be available under %s\n", e.BinName, filepath.Dir(path)) 103 } 104 phpiniArgs = false 105 path = v.PHPConfigPath 106 } 107 if e.BinName == "phpize" { 108 if v.PHPizePath == "" { 109 return nil, "", true, errors.Errorf("%s does not seem to be available under %s\n", e.BinName, filepath.Dir(path)) 110 } 111 phpiniArgs = false 112 path = v.PHPizePath 113 } 114 if e.BinName == "phpdbg" { 115 if v.PHPdbgPath == "" { 116 return nil, "", true, errors.Errorf("%s does not seem to be available under %s\n", e.BinName, filepath.Dir(path)) 117 } 118 phpiniArgs = false 119 path = v.PHPdbgPath 120 } 121 if e.BinName == "pecl" || e.BinName == "pear" { 122 phpiniArgs = false 123 path = filepath.Join(filepath.Dir(path), e.BinName) 124 } 125 if _, err := os.Stat(path); os.IsNotExist(err) { 126 // if a version does not exist anymore, it probably means that PHP has been updated 127 // try again after forcing the reload of the versions 128 if !forceReload { 129 return e.lookupPHP(cliDir, true) 130 } 131 132 // we should never get here 133 return nil, "", true, errors.Errorf("%s does not seem to be available anymore under %s\n", e.BinName, filepath.Dir(path)) 134 } 135 136 return v, path, phpiniArgs, nil 137 } 138 139 // DetectScriptDir detects the script dir based on the current configuration 140 func (e *Executor) DetectScriptDir() (string, error) { 141 if e.scriptDir != "" { 142 return e.scriptDir, nil 143 } 144 145 if e.SkipNbArgs == 0 { 146 e.SkipNbArgs = 1 147 } 148 149 if e.SkipNbArgs < 0 { 150 wd, err := os.Getwd() 151 if err != nil { 152 return "", errors.WithStack(err) 153 } 154 e.scriptDir = wd 155 } else { 156 if len(e.Args) < 1 { 157 return "", errors.New("args cannot be empty") 158 } 159 160 e.scriptDir = detectScriptDir(e.Args[e.SkipNbArgs:]) 161 } 162 163 return e.scriptDir, nil 164 } 165 166 // Config determines the right version of PHP depending on the configuration (+ its configuration) 167 func (e *Executor) Config(loadDotEnv bool) error { 168 // reset environment 169 e.environ = make([]string, 0) 170 171 if len(e.Args) < 1 { 172 return errors.New("args cannot be empty") 173 } 174 175 if _, err := e.DetectScriptDir(); err != nil { 176 return err 177 } 178 179 vars := make(map[string]string) 180 // env defined by Platform.sh services/tunnels or docker-compose services 181 if env, err := envs.GetEnv(e.scriptDir, terminal.IsDebug()); err == nil { 182 for k, v := range envs.AsMap(env) { 183 vars[k] = v 184 } 185 } 186 if loadDotEnv { 187 for k, v := range envs.LoadDotEnv(vars, e.scriptDir) { 188 vars[k] = v 189 } 190 } 191 for k, v := range vars { 192 e.environ = append(e.environ, fmt.Sprintf("%s=%s", k, v)) 193 } 194 195 // When running in Cloud we don't need to detect PHP or do anything fancy 196 // with the configuration, the only thing we want is to potentially load the 197 // .env file 198 if util.InCloud() { 199 // args[0] MUST be the same as path 200 // but as we change the path, we should update args[0] accordingly 201 e.Args[0] = e.BinName 202 return nil 203 } 204 205 cliDir := util.GetHomeDir() 206 var v *phpstore.Version 207 var path string 208 var phpiniArgs bool 209 var err error 210 if v, path, phpiniArgs, err = e.lookupPHP(cliDir, false); err != nil { 211 // try again after reloading PHP versions 212 if v, path, phpiniArgs, err = e.lookupPHP(cliDir, true); err != nil { 213 return err 214 } 215 } 216 e.environ = append(e.environ, fmt.Sprintf("PHP_BINARY=%s", v.PHPPath)) 217 e.environ = append(e.environ, fmt.Sprintf("PHP_PATH=%s", v.PHPPath)) 218 // for pecl 219 e.environ = append(e.environ, fmt.Sprintf("PHP_PEAR_PHP_BIN=%s", v.PHPPath)) 220 // prepending the PHP directory in the PATH does not work well if the PHP binary is not named "php" (like php7.3 for instance) 221 // in that case, we create a temp directory with a symlink 222 // we also link php-config for pecl to pick up the right one (it is always looks for something called php-config) 223 phpDir := filepath.Join(cliDir, "tmp", xid.New().String(), "bin") 224 e.tempDir = phpDir 225 if err := os.MkdirAll(phpDir, 0755); err != nil { 226 return err 227 } 228 // always symlink (copy on Windows) these binaries as they can be called internally (like pecl for instance) 229 if v.PHPConfigPath != "" { 230 if err := symlink(v.PHPConfigPath, filepath.Join(phpDir, "php-config")); err != nil { 231 return err 232 } 233 // we also alias a version with the prefix/suffix as required by pecl 234 if filepath.Base(v.PHPConfigPath) != "php-config" { 235 if err := symlink(v.PHPConfigPath, filepath.Join(phpDir, filepath.Base(v.PHPConfigPath))); err != nil { 236 return err 237 } 238 } 239 } 240 if v.PHPizePath != "" { 241 if err := symlink(v.PHPizePath, filepath.Join(phpDir, "phpize")); err != nil { 242 return err 243 } 244 // we also alias a version with the prefix/suffix as required by pecl 245 if filepath.Base(v.PHPizePath) != "phpize" { 246 if err := symlink(v.PHPizePath, filepath.Join(phpDir, filepath.Base(v.PHPizePath))); err != nil { 247 return err 248 } 249 } 250 } 251 if v.PHPdbgPath != "" { 252 if err := symlink(v.PHPdbgPath, filepath.Join(phpDir, "phpdbg")); err != nil { 253 return err 254 } 255 } 256 // if the bin is not one of the previous created symlink, create the symlink now 257 if _, err := os.Stat(filepath.Join(phpDir, e.BinName)); os.IsNotExist(err) { 258 if err := symlink(path, filepath.Join(phpDir, e.BinName)); err != nil { 259 return err 260 } 261 } 262 e.Paths = append([]string{filepath.Dir(path), phpDir}, e.Paths...) 263 if phpiniArgs { 264 // see https://php.net/manual/en/configuration.file.php 265 // if PHP_INI_SCAN_DIR exists, just append our new directory 266 // if not, add the default one (empty string) and then our new directory 267 // Look for php.ini in the script dir and go up if needed (symfony php ./app/test.php should read php/ini in ./) 268 dirs := "" 269 if phpIniDir := e.phpiniDirForDir(); phpIniDir != "" { 270 dirs += string(os.PathListSeparator) + phpIniDir 271 } 272 if e.iniDir != "" { 273 dirs += string(os.PathListSeparator) + e.iniDir 274 } 275 if dirs != "" { 276 e.environ = append(e.environ, fmt.Sprintf("PHP_INI_SCAN_DIR=%s%s", os.Getenv("PHP_INI_SCAN_DIR"), dirs)) 277 } 278 } 279 280 // args[0] MUST be the same as path 281 // but as we change the path, we should update args[0] accordingly 282 e.Args[0] = path 283 284 return err 285 } 286 287 // Find composer depending on the configuration 288 func (e *Executor) findComposer(extraBin string) (string, error) { 289 if scriptDir, err := e.DetectScriptDir(); err == nil { 290 for _, file := range []string{extraBin, "composer.phar", "composer"} { 291 path := filepath.Join(scriptDir, file) 292 d, err := os.Stat(path) 293 if err != nil { 294 continue 295 } 296 if m := d.Mode(); !m.IsDir() { 297 // Yep! 298 return path, nil 299 } 300 } 301 } 302 303 // fallback to default composer detection 304 return findComposer(extraBin) 305 } 306 307 // Execute executes the right version of PHP depending on the configuration 308 func (e *Executor) Execute(loadDotEnv bool) int { 309 if err := e.Config(loadDotEnv); err != nil { 310 fmt.Fprintln(os.Stderr, err) 311 return 1 312 } 313 defer func() { 314 if e.iniDir != "" { 315 os.RemoveAll(e.iniDir) 316 } 317 if e.tempDir != "" { 318 os.RemoveAll(e.tempDir) 319 } 320 }() 321 cmd := execCommand(e.Args[0], e.Args[1:]...) 322 environ := append(os.Environ(), e.environ...) 323 gpathname := "PATH" 324 if runtime.GOOS == "windows" { 325 gpathname = "Path" 326 } 327 fullPath := os.Getenv(gpathname) 328 for _, path := range e.Paths { 329 fullPath = fmt.Sprintf("%s%c%s", path, filepath.ListSeparator, fullPath) 330 } 331 environ = append(environ, fmt.Sprintf("%s=%s", gpathname, fullPath)) 332 cmd.Env = append(cmd.Env, environ...) 333 cmd.Env = append(cmd.Env, e.ExtraEnv...) 334 if e.Stdout == nil { 335 e.Stdout = os.Stdout 336 } 337 if e.Stderr == nil { 338 e.Stderr = os.Stderr 339 } 340 if e.Stdin == nil { 341 e.Stdin = os.Stdin 342 } 343 cmd.Stdout = e.Stdout 344 cmd.Stderr = e.Stderr 345 cmd.Stdin = e.Stdin 346 if e.Dir != "" { 347 cmd.Dir = e.Dir 348 } 349 if err := cmd.Start(); err != nil { 350 fmt.Fprintln(os.Stderr, err) 351 return 1 352 } 353 354 waitCh := make(chan error) 355 go func() { 356 waitCh <- cmd.Wait() 357 close(waitCh) 358 }() 359 360 sigChan := make(chan os.Signal) 361 signal.Notify(sigChan) 362 defer signal.Stop(sigChan) 363 364 for { 365 select { 366 case sig := <-sigChan: 367 if shouldSignalBeIgnored(sig) { 368 continue 369 } 370 if err := cmd.Process.Signal(sig); err != nil { 371 if err.Error() != "os: process already finished" { 372 fmt.Fprintln(os.Stderr, "error sending signal", sig, err) 373 } 374 } 375 case err := <-waitCh: 376 exitCode := 0 377 if err == nil { 378 return exitCode 379 } 380 if !strings.Contains(err.Error(), "exit status") { 381 fmt.Fprintln(os.Stderr, err) 382 } 383 if exiterr, ok := err.(*exec.ExitError); ok { 384 if s, ok := exiterr.Sys().(syscall.WaitStatus); ok { 385 exitCode = s.ExitStatus() 386 } 387 } 388 return exitCode 389 } 390 } 391 } 392 393 // we look in the directory of the current PHP version first, then fall back to PATH 394 func LookPath(file string) (string, error) { 395 if util.InCloud() { 396 // does not make sense to look for the php store, fall back 397 return exec.LookPath(file) 398 } 399 phpStore := phpstore.New(util.GetHomeDir(), false, nil) 400 wd, _ := os.Getwd() 401 v, _, warning, _ := phpStore.BestVersionForDir(wd) 402 if warning != "" { 403 terminal.Eprintfln("<warning>WARNING</> %s", warning) 404 } 405 if v == nil { 406 // unable to find the current PHP version, fall back 407 return exec.LookPath(file) 408 } 409 410 path := filepath.Join(filepath.Dir(v.PHPPath), file) 411 d, err := os.Stat(path) 412 if err != nil { 413 // file does not exist, fall back 414 return exec.LookPath(file) 415 } 416 if m := d.Mode(); !m.IsDir() && m&0111 != 0 { 417 // Yep! 418 return path, nil 419 } 420 // found, but not executable, fall back 421 return exec.LookPath(file) 422 } 423 424 // detectScriptDir tries to get the script directory from args 425 func detectScriptDir(args []string) string { 426 script := "" 427 skipNext := false 428 for i, arg := range args { 429 if skipNext { 430 skipNext = false 431 continue 432 } 433 if strings.HasPrefix(arg, "-f") { 434 if len(arg) > 2 { 435 script = arg[2:] 436 break 437 } else if len(args) >= i+1 { 438 script = args[i+1] 439 break 440 } 441 continue 442 } 443 // skip options that take an option 444 for _, flag := range []string{"-c", "-d", "-r", "-B", "-R", "-F", "-E", "-S", "-t", "-z"} { 445 if strings.HasPrefix(arg, flag) { 446 if len(arg) == 2 { 447 skipNext = true 448 } 449 continue 450 } 451 } 452 // done 453 if arg == "--" { 454 break 455 } 456 if strings.HasPrefix(arg, "-") { 457 continue 458 } 459 script = arg 460 break 461 } 462 if script != "" { 463 if script, err := filepath.Abs(script); err == nil { 464 return filepath.Dir(script) 465 } 466 return filepath.Dir(script) 467 } 468 469 // fallback to the current directory 470 wd, err := os.Getwd() 471 if err != nil { 472 return "/" 473 } 474 return wd 475 } 476 477 func (e *Executor) PathsToWatch() []string { 478 var paths []string 479 480 if dir := e.phpiniDirForDir(); dir != "" { 481 paths = append(paths, filepath.Join(dir, "php.ini")) 482 } 483 484 return paths 485 } 486 487 func (e *Executor) phpiniDirForDir() string { 488 dir := e.scriptDir 489 for { 490 if _, err := os.Stat(filepath.Join(dir, "php.ini")); err == nil { 491 return dir 492 } 493 upDir := filepath.Dir(dir) 494 if upDir == dir || upDir == "." { 495 break 496 } 497 dir = upDir 498 } 499 return "" 500 }