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  }