diff --git a/Makefile b/Makefile index 4865d680..177802ab 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PHP_VERSION = 8.2.26 +PHP_VERSION = 8.2.27 LEGACY_CLI_VERSION = 4.22.0 GORELEASER_ID ?= platform @@ -49,14 +49,21 @@ internal/legacy/archives/php_linux_$(GOARCH): --progress=plain \ ext/static-php-cli/docker +PHP_WINDOWS_REMOTE_FILENAME := "php-$(PHP_VERSION)-nts-Win32-vs16-x64.zip" internal/legacy/archives/php_windows.zip: - mkdir -p internal/legacy/archives - wget https://windows.php.net/downloads/releases/php-$(PHP_VERSION)-nts-Win32-vs16-x64.zip -O internal/legacy/archives/php_windows.zip + ( \ + set -e ;\ + mkdir -p internal/legacy/archives ;\ + cd internal/legacy/archives ;\ + curl -f "https://windows.php.net/downloads/releases/$(PHP_WINDOWS_REMOTE_FILENAME)" > php_windows.zip ;\ + curl -f https://windows.php.net/downloads/releases/sha256sum.txt | grep "$(PHP_WINDOWS_REMOTE_FILENAME)" | sed s/"$(PHP_WINDOWS_REMOTE_FILENAME)"/"php_windows.zip"/g > php_windows.zip.sha256 ;\ + sha256sum -c php_windows.zip.sha256 ;\ + ) .PHONY: internal/legacy/archives/cacert.pem internal/legacy/archives/cacert.pem: mkdir -p internal/legacy/archives - wget https://curl.se/ca/cacert.pem -O internal/legacy/archives/cacert.pem + curl https://curl.se/ca/cacert.pem > internal/legacy/archives/cacert.pem php: $(PHP_BINARY_PATH) diff --git a/commands/completion.go b/commands/completion.go index 02dc0b97..c1a87ae8 100644 --- a/commands/completion.go +++ b/commands/completion.go @@ -2,18 +2,13 @@ package commands import ( "bytes" - "errors" "fmt" - "os" - "os/exec" - "path" + "path/filepath" "strings" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/platformsh/cli/internal/config" - "github.com/platformsh/cli/internal/legacy" ) func newCompletionCommand(cnf *config.Config) *cobra.Command { @@ -28,42 +23,24 @@ func newCompletionCommand(cnf *config.Config) *cobra.Command { completionArgs = append(completionArgs, "--shell-type", args[0]) } var b bytes.Buffer - c := &legacy.CLIWrapper{ - Config: cnf, - Version: version, - CustomPharPath: viper.GetString("phar-path"), - Debug: viper.GetBool("debug"), - DebugLogFunc: debugLog, - DisableInteraction: viper.GetBool("no-interaction"), - Stdout: &b, - Stderr: cmd.ErrOrStderr(), - Stdin: cmd.InOrStdin(), - } + c := makeLegacyCLIWrapper(cnf, &b, cmd.ErrOrStderr(), cmd.InOrStdin()) - if err := c.Init(); err != nil { - debugLog(err.Error()) - os.Exit(1) - return + if err := c.Exec(cmd.Context(), completionArgs...); err != nil { + exitWithError(err) } - if err := c.Exec(cmd.Context(), completionArgs...); err != nil { - debugLog(err.Error()) - exitCode := 1 - var execErr *exec.ExitError - if errors.As(err, &execErr) { - exitCode = execErr.ExitCode() - } - os.Exit(exitCode) - return + pharPath, err := c.PharPath() + if err != nil { + exitWithError(err) } completions := strings.ReplaceAll( strings.ReplaceAll( b.String(), - c.PharPath(), + pharPath, cnf.Application.Executable, ), - path.Base(c.PharPath()), + filepath.Base(pharPath), cnf.Application.Executable, ) fmt.Fprintln(cmd.OutOrStdout(), "#compdef "+cnf.Application.Executable) diff --git a/commands/list.go b/commands/list.go index 6835a37e..ba9304e8 100644 --- a/commands/list.go +++ b/commands/list.go @@ -9,7 +9,6 @@ import ( "github.com/spf13/viper" "github.com/platformsh/cli/internal/config" - "github.com/platformsh/cli/internal/legacy" ) func newListCommand(cnf *config.Config) *cobra.Command { @@ -18,23 +17,6 @@ func newListCommand(cnf *config.Config) *cobra.Command { Short: "Lists commands", Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - var b bytes.Buffer - c := &legacy.CLIWrapper{ - Config: cnf, - Version: version, - CustomPharPath: viper.GetString("phar-path"), - Debug: viper.GetBool("debug"), - DebugLogFunc: debugLog, - DisableInteraction: viper.GetBool("no-interaction"), - Stdout: &b, - Stderr: cmd.ErrOrStderr(), - Stdin: cmd.InOrStdin(), - } - if err := c.Init(); err != nil { - exitWithError(cmd, err) - return - } - arguments := []string{"list", "--format=json"} if viper.GetBool("all") { arguments = append(arguments, "--all") @@ -42,15 +24,17 @@ func newListCommand(cnf *config.Config) *cobra.Command { if len(args) > 0 { arguments = append(arguments, args[0]) } + + var b bytes.Buffer + c := makeLegacyCLIWrapper(cnf, &b, cmd.ErrOrStderr(), cmd.InOrStdin()) + if err := c.Exec(cmd.Context(), arguments...); err != nil { - exitWithError(cmd, err) - return + exitWithError(err) } var list List if err := json.Unmarshal(b.Bytes(), &list); err != nil { - exitWithError(cmd, err) - return + exitWithError(err) } // Override the application name and executable with our own config. @@ -88,15 +72,14 @@ func newListCommand(cnf *config.Config) *cobra.Command { c.Stdout = cmd.OutOrStdout() arguments := []string{"list", "--format=" + format} if err := c.Exec(cmd.Context(), arguments...); err != nil { - exitWithError(cmd, err) + exitWithError(err) } return } result, err := formatter.Format(&list, config.FromContext(cmd.Context())) if err != nil { - exitWithError(cmd, err) - return + exitWithError(err) } fmt.Fprintln(cmd.OutOrStdout(), string(result)) diff --git a/commands/root.go b/commands/root.go index 6dcbf232..4ea42044 100644 --- a/commands/root.go +++ b/commands/root.go @@ -76,7 +76,10 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob } }, Run: func(cmd *cobra.Command, _ []string) { - runLegacyCLI(cmd.Context(), cnf, cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin(), os.Args[1:]) + c := makeLegacyCLIWrapper(cnf, cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin()) + if err := c.Exec(cmd.Context(), os.Args[1:]...); err != nil { + exitWithError(err) + } }, PersistentPostRun: func(cmd *cobra.Command, _ []string) { checkShellConfigLeftovers(cmd.ErrOrStderr(), cnf) @@ -103,13 +106,13 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob args = []string{"help"} } - runLegacyCLI(cmd.Context(), cnf, cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin(), args) + c := makeLegacyCLIWrapper(cnf, cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin()) + if err := c.Exec(cmd.Context(), args...); err != nil { + exitWithError(err) + } }) cmd.PersistentFlags().BoolP("version", "V", false, fmt.Sprintf("Displays the %s version", cnf.Application.Name)) - cmd.PersistentFlags().String("phar-path", "", - fmt.Sprintf("Uses a local .phar file for the Legacy %s", cnf.Application.Name), - ) cmd.PersistentFlags().Bool("debug", false, "Enable debug logging") cmd.PersistentFlags().Bool("no-interaction", false, "Enable non-interactive mode") cmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output") @@ -239,40 +242,27 @@ func debugLog(format string, v ...any) { fmt.Fprintf(color.Error, prefix+" "+strings.TrimSpace(format)+"\n", v...) } -func exitWithError(cmd *cobra.Command, err error) { - cmd.PrintErrln(color.RedString(err.Error())) - exitCode := 1 +func exitWithError(err error) { var execErr *exec.ExitError if errors.As(err, &execErr) { - exitCode = execErr.ExitCode() + exitCode := execErr.ExitCode() + debugLog(err.Error()) + os.Exit(exitCode) + } + if !viper.GetBool("quiet") { + fmt.Fprintln(color.Error, color.RedString(err.Error())) } - os.Exit(exitCode) + os.Exit(1) } -func runLegacyCLI(ctx context.Context, cnf *config.Config, stdout, stderr io.Writer, stdin io.Reader, args []string) { - c := &legacy.CLIWrapper{ +func makeLegacyCLIWrapper(cnf *config.Config, stdout, stderr io.Writer, stdin io.Reader) *legacy.CLIWrapper { + return &legacy.CLIWrapper{ Config: cnf, Version: version, - CustomPharPath: viper.GetString("phar-path"), - Debug: viper.GetBool("debug"), DebugLogFunc: debugLog, DisableInteraction: viper.GetBool("no-interaction"), Stdout: stdout, Stderr: stderr, Stdin: stdin, } - if err := c.Init(); err != nil { - fmt.Fprintln(stderr, color.RedString(err.Error())) - os.Exit(1) - } - - if err := c.Exec(ctx, args...); err != nil { - debugLog("%s\n", color.RedString(err.Error())) - exitCode := 1 - var execErr *exec.ExitError - if errors.As(err, &execErr) { - exitCode = execErr.ExitCode() - } - os.Exit(exitCode) - } } diff --git a/go.mod b/go.mod index 4238caff..ff25159c 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/stretchr/testify v1.9.0 github.com/wk8/go-ordered-map/v2 v2.1.8 golang.org/x/crypto v0.31.0 + golang.org/x/sync v0.10.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 73d74f43..f524cb36 100644 --- a/go.sum +++ b/go.sum @@ -168,6 +168,8 @@ golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/config/config.go b/internal/config/config.go index fc8b3f4c..590913ce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,7 @@ func FromYAML(b []byte) (*Config, error) { return nil, fmt.Errorf("invalid config: %w", err) } c.applyDynamicDefaults() + c.raw = b return c, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index af901977..c4f6f679 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/platformsh/cli/internal/config" ) @@ -25,6 +26,14 @@ func TestFromYAML(t *testing.T) { cnf, err := config.FromYAML([]byte(validConfig)) assert.NoError(t, err) + tempDir := t.TempDir() + require.NoError(t, os.Setenv(cnf.Application.EnvPrefix+"HOME", tempDir)) + require.NoError(t, os.Setenv(cnf.Application.EnvPrefix+"TMP", filepath.Join(tempDir, "tmp"))) + t.Cleanup(func() { + _ = os.Unsetenv(cnf.Application.EnvPrefix + "HOME") + _ = os.Unsetenv(cnf.Application.EnvPrefix + "TMP") + }) + // Test defaults assert.Equal(t, "state.json", cnf.Application.UserStateFile) assert.Equal(t, true, cnf.Updates.Check) @@ -33,13 +42,16 @@ func TestFromYAML(t *testing.T) { assert.Equal(t, "example-cli-tmp", cnf.Application.TempSubDir) assert.Equal(t, "platform", cnf.Service.ProjectConfigFlavor) + homeDir, err := cnf.HomeDir() + require.NoError(t, err) + assert.Equal(t, tempDir, homeDir) + writableDir, err := cnf.WritableUserDir() assert.NoError(t, err) + assert.Equal(t, filepath.Join(homeDir, cnf.Application.WritableUserDir), writableDir) - if homeDir, err := os.UserHomeDir(); err == nil { - assert.Equal(t, filepath.Join(homeDir, cnf.Application.WritableUserDir), writableDir) - } else { - assert.Equal(t, filepath.Join(os.TempDir(), cnf.Application.TempSubDir), writableDir) - } + d, err := cnf.TempDir() + assert.NoError(t, err) + assert.Equal(t, filepath.Join(tempDir, "tmp", cnf.Application.TempSubDir), d) }) } diff --git a/internal/config/dir.go b/internal/config/dir.go new file mode 100644 index 00000000..949c1a2a --- /dev/null +++ b/internal/config/dir.go @@ -0,0 +1,87 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" +) + +// TempDir returns the path to a user-specific temporary directory, suitable for caches. +// +// It creates the temporary directory if it does not already exist. +// +// The directory can be specified in the {ENV_PREFIX}TMP environment variable. +// +// This does not use os.TempDir, as on Linux/Unix systems that usually returns a +// global /tmp directory, which could conflict with other users. It also does not +// use os.MkdirTemp, as the CLI usually needs a stable (not random) directory +// path. It therefore uses os.UserCacheDir which in turn will use XDG_CACHE_HOME +// or the home directory. +func (c *Config) TempDir() (string, error) { + if c.tempDir != "" { + return c.tempDir, nil + } + d := os.Getenv(c.Application.EnvPrefix + "TMP") + if d == "" { + ucd, err := os.UserCacheDir() + if err != nil { + return "", err + } + d = ucd + } + + // Windows already has a user-specific temporary directory. + if runtime.GOOS == "windows" { + osTemp := os.TempDir() + if strings.HasPrefix(osTemp, d) { + d = osTemp + } + } + + path := filepath.Join(d, c.Application.TempSubDir) + + // If the subdirectory cannot be created due to a read-only filesystem, fall back to /tmp. + if err := os.MkdirAll(path, 0o700); err != nil { + if !errors.Is(err, syscall.EROFS) { + return "", err + } + path = filepath.Join(os.TempDir(), c.Application.TempSubDir) + if err := os.MkdirAll(path, 0o700); err != nil { + return "", err + } + } + c.tempDir = path + + return path, nil +} + +// WritableUserDir returns the path to a writable user-level directory. +// Deprecated: unless backwards compatibility is desired, TempDir is preferable. +func (c *Config) WritableUserDir() (string, error) { + if c.writableUserDir != "" { + return c.writableUserDir, nil + } + hd, err := c.HomeDir() + if err != nil { + return "", err + } + path := filepath.Join(hd, c.Application.WritableUserDir) + if err := os.MkdirAll(path, 0o700); err != nil { + return "", err + } + c.writableUserDir = path + + return path, nil +} + +// HomeDir returns the user's home directory, which can be overridden with the {ENV_PREFIX}HOME variable. +func (c *Config) HomeDir() (string, error) { + d := os.Getenv(c.Application.EnvPrefix + "HOME") + if d != "" { + return d, nil + } + return os.UserHomeDir() +} diff --git a/internal/config/schema.go b/internal/config/schema.go index 79a2a788..c4427fcd 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -1,8 +1,9 @@ package config import ( - "os" - "path/filepath" + "fmt" + + "gopkg.in/yaml.v3" ) // Config provides YAML configuration for the CLI. @@ -60,6 +61,10 @@ type Config struct { SSH struct { DomainWildcards []string `validate:"required" yaml:"domain_wildcards"` // e.g. ["*.platform.sh"] } `validate:"required"` + + raw []byte `yaml:"-"` + tempDir string `yaml:"-"` + writableUserDir string `yaml:"-"` } // applyDefaults applies defaults to config before parsing. @@ -80,22 +85,14 @@ func (c *Config) applyDynamicDefaults() { } } -// WritableUserDir returns the path to a writable user-level directory. -func (c *Config) WritableUserDir() (string, error) { - // Attempt to create the directory under $HOME first. - if homeDir, err := os.UserHomeDir(); err == nil { - path := filepath.Join(homeDir, c.Application.WritableUserDir) - if err := os.Mkdir(path, 0o700); err != nil && !os.IsExist(err) { - return "", err +// Raw returns the config before it was unmarshalled, or a marshaled version if that is not available. +func (c *Config) Raw() ([]byte, error) { + if len(c.raw) == 0 { + b, err := yaml.Marshal(c) + if err != nil { + return nil, fmt.Errorf("could not load raw config: %w", err) } - return path, nil + c.raw = b } - - // Otherwise,attempt to create it in the temporary directory. - path := filepath.Join(os.TempDir(), c.Application.TempSubDir) - if err := os.Mkdir(path, 0o700); err != nil && !os.IsExist(err) { - return "", err - } - - return path, nil + return c.raw, nil } diff --git a/internal/file/file.go b/internal/file/file.go new file mode 100644 index 00000000..595321fe --- /dev/null +++ b/internal/file/file.go @@ -0,0 +1,58 @@ +package file + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" +) + +// WriteIfNeeded writes data to a destination file, only if the file does not exist or if it was partially written. +// To save time, it only checks that the file size is correct, and then matches the end of its contents (up to 32KB). +func WriteIfNeeded(destFilename string, source []byte, perm os.FileMode) error { + matches, err := probablyMatches(destFilename, source, 32*1024) + if err != nil || matches { + return err + } + return Write(destFilename, source, perm) +} + +// Write creates or overwrites a file, somewhat atomically, using a temporary file next to it. +func Write(path string, content []byte, fileMode fs.FileMode) error { + tmpFile := path + ".tmp" + if err := os.WriteFile(tmpFile, content, fileMode); err != nil { + return err + } + + return os.Rename(tmpFile, path) +} + +// probablyMatches checks if a file exists and matches the end of source data (up to checkSize bytes). +func probablyMatches(filename string, data []byte, checkSize int) (bool, error) { + f, err := os.Open(filename) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return false, err + } + if fi.Size() != int64(len(data)) { + return false, nil + } + + buf := make([]byte, min(checkSize, len(data))) + offset := max(0, len(data)-checkSize) + n, err := f.ReadAt(buf, int64(offset)) + if err != nil && err != io.EOF { + return false, err + } + + return bytes.Equal(data[offset:], buf[:n]), nil +} diff --git a/internal/file/file_test.go b/internal/file/file_test.go new file mode 100644 index 00000000..4874ad01 --- /dev/null +++ b/internal/file/file_test.go @@ -0,0 +1,81 @@ +package file + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteIfNeeded(t *testing.T) { + largeContentLength := 128 * 1024 + largeContent := make([]byte, largeContentLength) + largeContent[0] = 'f' + largeContent[largeContentLength-2] = 'o' + largeContent[largeContentLength-1] = 'o' + + largeContent2 := make([]byte, largeContentLength) + largeContent2[0] = 'b' + largeContent2[largeContentLength-2] = 'a' + largeContent2[largeContentLength-1] = 'r' + + assert.Equal(t, len(largeContent), len(largeContent2)) + + cases := []struct { + name string + initialData []byte + sourceData []byte + expectWrite bool + }{ + {"File does not exist", nil, []byte("new data"), true}, + {"File matches source", []byte("same data"), []byte("same data"), false}, + {"File content differs", []byte("old data"), []byte("new data"), true}, + {"Larger file content differs", largeContent, largeContent2, true}, + {"Larger file content matches", largeContent, largeContent, false}, + {"File size differs", []byte("short"), []byte("much longer data"), true}, + {"Empty source", []byte("existing data"), []byte{}, true}, + } + + tmpDir := t.TempDir() + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + destFile := filepath.Join(tmpDir, "testfile") + + if c.initialData != nil { + require.NoError(t, os.WriteFile(destFile, c.initialData, 0o600)) + time.Sleep(time.Millisecond * 5) + } + + var modTimeBefore time.Time + stat, err := os.Stat(destFile) + if c.initialData == nil { + require.True(t, os.IsNotExist(err)) + } else { + require.NoError(t, err) + modTimeBefore = stat.ModTime() + } + + err = WriteIfNeeded(destFile, c.sourceData, 0o600) + require.NoError(t, err) + + statAfter, err := os.Stat(destFile) + require.NoError(t, err) + modTimeAfter := statAfter.ModTime() + + if c.expectWrite { + assert.Greater(t, modTimeAfter.Truncate(time.Millisecond), modTimeBefore.Truncate(time.Millisecond)) + } else { + assert.Equal(t, modTimeBefore.Truncate(time.Millisecond), modTimeAfter.Truncate(time.Millisecond)) + } + + data, err := os.ReadFile(destFile) + require.NoError(t, err) + + assert.True(t, bytes.Equal(data, c.sourceData)) + }) + } +} diff --git a/internal/legacy/legacy.go b/internal/legacy/legacy.go index b9fac69a..a04edfe3 100644 --- a/internal/legacy/legacy.go +++ b/internal/legacy/legacy.go @@ -1,18 +1,23 @@ package legacy import ( - "bytes" "context" _ "embed" + "errors" "fmt" "io" + "io/fs" "os" "os/exec" - "path" + "path/filepath" + "sync" + "time" "github.com/gofrs/flock" + "golang.org/x/sync/errgroup" "github.com/platformsh/cli/internal/config" + "github.com/platformsh/cli/internal/file" ) //go:embed archives/platform.phar @@ -23,48 +28,7 @@ var ( PHPVersion = "0.0.0" ) -var phpPath = fmt.Sprintf("php-%s", PHPVersion) -var pharPath = fmt.Sprintf("phar-%s", LegacyCLIVersion) - -// copyFile from the given bytes to destination -func copyFile(destination string, fin []byte) error { - if _, err := os.Stat(destination); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("could not stat file: %w", err) - } - - fout, err := os.Create(destination) - if err != nil { - return fmt.Errorf("could not create file: %w", err) - } - defer fout.Close() - - r := bytes.NewReader(fin) - - if _, err := io.Copy(fout, r); err != nil { - return fmt.Errorf("could copy file: %w", err) - } - - return nil -} - -// fileChanged checks if a file's content differs from the provided bytes. -func fileChanged(filename string, content []byte) (bool, error) { - stat, err := os.Stat(filename) - if err != nil { - if os.IsNotExist(err) { - return true, nil - } - return false, fmt.Errorf("could not stat file: %w", err) - } - if int(stat.Size()) != len(content) { - return true, nil - } - current, err := os.ReadFile(filename) - if err != nil { - return false, err - } - return !bytes.Equal(current, content), nil -} +const configBasename = "config.yaml" // CLIWrapper wraps the legacy CLI type CLIWrapper struct { @@ -73,10 +37,12 @@ type CLIWrapper struct { Stdin io.Reader Config *config.Config Version string - CustomPharPath string Debug bool DisableInteraction bool DebugLogFunc func(string, ...any) + + initOnce sync.Once + _cacheDir string } func (c *CLIWrapper) debug(msg string, args ...any) { @@ -85,68 +51,85 @@ func (c *CLIWrapper) debug(msg string, args ...any) { } } -func (c *CLIWrapper) cacheDir() string { - return path.Join(os.TempDir(), fmt.Sprintf("%s-%s-%s", c.Config.Application.Slug, PHPVersion, LegacyCLIVersion)) -} - -// Init the CLI wrapper, creating a temporary directory and copying over files -func (c *CLIWrapper) Init() error { - if _, err := os.Stat(c.cacheDir()); os.IsNotExist(err) { - c.debug("Cache directory does not exist, creating: %s", c.cacheDir()) - if err := os.Mkdir(c.cacheDir(), 0o700); err != nil { - return fmt.Errorf("could not create temporary directory: %w", err) +func (c *CLIWrapper) cacheDir() (string, error) { + if c._cacheDir == "" { + cd, err := c.Config.TempDir() + if err != nil { + return "", err } + cd = filepath.Join(cd, fmt.Sprintf("legacy-%s-%s", PHPVersion, LegacyCLIVersion)) + if err := os.Mkdir(cd, 0o700); err != nil && !errors.Is(err, fs.ErrExist) { + return "", err + } + c._cacheDir = cd } - fileLock := flock.New(path.Join(c.cacheDir(), ".lock")) - if err := fileLock.Lock(); err != nil { - return fmt.Errorf("could not acquire lock: %w", err) - } - c.debug("Lock acquired: %s", fileLock.Path()) - //nolint:errcheck - defer fileLock.Unlock() - if _, err := os.Stat(c.PharPath()); os.IsNotExist(err) { - if c.CustomPharPath != "" { - return fmt.Errorf("legacy CLI phar file not found: %w", err) - } + return c._cacheDir, nil +} - c.debug("Phar file does not exist, copying: %s", c.PharPath()) - if err := copyFile(c.PharPath(), phar); err != nil { - return fmt.Errorf("could not copy phar file: %w", err) - } - } +// runInitOnce runs the init method, only once for this object. +func (c *CLIWrapper) runInitOnce() error { + var err error + c.initOnce.Do(func() { err = c.init() }) + return err +} - // Always write the config.yaml file if it changed. - configContent, err := config.LoadYAML() - if err != nil { - return fmt.Errorf("could not load config for checking: %w", err) - } - changed, err := fileChanged(c.ConfigPath(), configContent) +// init initializes the CLI wrapper, creating a temporary directory and copying over files. +func (c *CLIWrapper) init() error { + preInit := time.Now() + + cacheDir, err := c.cacheDir() if err != nil { - return fmt.Errorf("could not check config file: %w", err) + return err } - if changed { - if err := copyFile(c.ConfigPath(), configContent); err != nil { - return fmt.Errorf("could not copy config: %w", err) - } + + preLock := time.Now() + fileLock := flock.New(filepath.Join(cacheDir, ".lock")) + if err := fileLock.Lock(); err != nil { + return fmt.Errorf("could not acquire lock: %w", err) } + c.debug("lock acquired (%s): %s", time.Since(preLock), fileLock.Path()) + defer fileLock.Unlock() //nolint:errcheck - if _, err := os.Stat(c.PHPPath()); os.IsNotExist(err) { - c.debug("PHP binary does not exist, copying: %s", c.PHPPath()) - if err := c.copyPHP(); err != nil { - return fmt.Errorf("could not copy files: %w", err) + g := errgroup.Group{} + g.Go(func() error { + if err := file.WriteIfNeeded(c.pharPath(cacheDir), phar, 0o644); err != nil { + return fmt.Errorf("could not copy phar file: %w", err) + } + return nil + }) + g.Go(func() error { + configContent, err := c.Config.Raw() + if err != nil { + return fmt.Errorf("could not load config for checking: %w", err) } - if err := os.Chmod(c.PHPPath(), 0o700); err != nil { - return fmt.Errorf("could not make PHP executable: %w", err) + if err := file.WriteIfNeeded(filepath.Join(cacheDir, configBasename), configContent, 0o644); err != nil { + return fmt.Errorf("could not write config: %w", err) } + return nil + }) + + g.Go(newPHPManager(cacheDir).copy) + + if err := g.Wait(); err != nil { + return err } + c.debug("Initialized PHP CLI (%s)", time.Since(preInit)) + return nil } // Exec a legacy CLI command with the given arguments func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { - cmd := c.makeCmd(ctx, args) + if err := c.runInitOnce(); err != nil { + return fmt.Errorf("failed to initialize PHP CLI: %w", err) + } + cacheDir, err := c.cacheDir() + if err != nil { + return err + } + cmd := c.makeCmd(ctx, args, cacheDir) if c.Stdin != nil { cmd.Stdin = c.Stdin } else { @@ -166,16 +149,13 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { envPrefix := c.Config.Application.EnvPrefix cmd.Env = append( cmd.Env, - "CLI_CONFIG_FILE="+c.ConfigPath(), + "CLI_CONFIG_FILE="+filepath.Join(cacheDir, configBasename), envPrefix+"UPDATES_CHECK=0", envPrefix+"MIGRATE_CHECK=0", envPrefix+"APPLICATION_PROMPT_SELF_INSTALL=0", envPrefix+"WRAPPED=1", envPrefix+"APPLICATION_VERSION="+c.Version, ) - if c.Debug { - cmd.Env = append(cmd.Env, envPrefix+"CLI_DEBUG=1") - } if c.DisableInteraction { cmd.Env = append(cmd.Env, envPrefix+"NO_INTERACTION=1") } @@ -187,37 +167,39 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { c.Version, )) if err := cmd.Run(); err != nil { - // Cleanup cache directory - c.debug("Removing cache directory: %s", c.cacheDir()) - os.RemoveAll(c.cacheDir()) - return fmt.Errorf("could not run legacy CLI command: %w", err) + return fmt.Errorf("could not run PHP CLI command: %w", err) } return nil } // makeCmd makes a legacy CLI command with the given context and arguments. -func (c *CLIWrapper) makeCmd(ctx context.Context, args []string) *exec.Cmd { - iniSettings := c.phpSettings() - var cmdArgs = make([]string, 0, len(args)+2+len(iniSettings)*2) - for _, s := range iniSettings { +func (c *CLIWrapper) makeCmd(ctx context.Context, args []string, cacheDir string) *exec.Cmd { + phpMgr := newPHPManager(cacheDir) + settings := phpMgr.settings() + var cmdArgs = make([]string, 0, len(args)+2+len(settings)*2) + for _, s := range settings { cmdArgs = append(cmdArgs, "-d", s) } - cmdArgs = append(cmdArgs, c.PharPath()) + cmdArgs = append(cmdArgs, c.pharPath(cacheDir)) cmdArgs = append(cmdArgs, args...) - return exec.CommandContext(ctx, c.PHPPath(), cmdArgs...) //nolint:gosec + return exec.CommandContext(ctx, phpMgr.binPath(), cmdArgs...) //nolint:gosec } // PharPath returns the path to the legacy CLI's Phar file. -func (c *CLIWrapper) PharPath() string { - if c.CustomPharPath != "" { - return c.CustomPharPath +func (c *CLIWrapper) PharPath() (string, error) { + cacheDir, err := c.cacheDir() + if err != nil { + return "", err } - return path.Join(c.cacheDir(), pharPath) + return c.pharPath(cacheDir), nil } -// ConfigPath returns the path to the YAML config file that will be provided to the legacy CLI. -func (c *CLIWrapper) ConfigPath() string { - return path.Join(c.cacheDir(), "config.yaml") +func (c *CLIWrapper) pharPath(cacheDir string) string { + if customPath := os.Getenv(c.Config.Application.EnvPrefix + "PHAR_PATH"); customPath != "" { + return customPath + } + + return filepath.Join(cacheDir, c.Config.Application.Executable+".phar") } diff --git a/internal/legacy/legacy_test.go b/internal/legacy/legacy_test.go new file mode 100644 index 00000000..8db854e4 --- /dev/null +++ b/internal/legacy/legacy_test.go @@ -0,0 +1,74 @@ +package legacy + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/platformsh/cli/internal/config" +) + +func TestLegacyCLI(t *testing.T) { + if len(phar) == 0 || len(phpCLI) == 0 { + t.Skip() + } + + cnf := &config.Config{} + cnf.Application.Name = "Test CLI" + cnf.Application.Executable = "platform-test" + cnf.Application.Slug = "test-cli" + cnf.Application.EnvPrefix = "TEST_CLI_" + cnf.Application.TempSubDir = "temp_sub_dir" + + tempDir := t.TempDir() + + _ = os.Setenv(cnf.Application.EnvPrefix+"TMP", tempDir) + t.Cleanup(func() { + _ = os.Unsetenv(cnf.Application.EnvPrefix + "TMP") + }) + + stdout := &bytes.Buffer{} + stdErr := io.Discard + if testing.Verbose() { + stdErr = os.Stderr + } + + testCLIVersion := "1.2.3" + + wrapper := &CLIWrapper{ + Stdout: stdout, + Stderr: stdErr, + Config: cnf, + Version: testCLIVersion, + DisableInteraction: true, + } + if testing.Verbose() { + wrapper.DebugLogFunc = t.Logf + } + PHPVersion = "6.5.4" + LegacyCLIVersion = "3.2.1" + + err := wrapper.Exec(context.Background(), "help") + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "Displays help for a command") + + cacheDir, err := wrapper.cacheDir() + require.NoError(t, err) + + pharPath, err := wrapper.PharPath() + require.NoError(t, err) + + assert.Equal(t, filepath.Join(cacheDir, "platform-test.phar"), pharPath) + + stdout.Reset() + err = wrapper.Exec(context.Background(), "--version") + assert.NoError(t, err) + assert.Equal(t, "Test CLI "+testCLIVersion, strings.TrimSuffix(stdout.String(), "\n")) +} diff --git a/internal/legacy/php_manager.go b/internal/legacy/php_manager.go new file mode 100644 index 00000000..5a4f5ef2 --- /dev/null +++ b/internal/legacy/php_manager.go @@ -0,0 +1,20 @@ +package legacy + +type phpManager interface { + // copy writes embedded PHP files to temporary files. + copy() error + + // binPath returns the path to the temporary PHP binary. + binPath() string + + // settings returns PHP INI entries (key=value format). + settings() []string +} + +type phpManagerPerOS struct { + cacheDir string +} + +func newPHPManager(cacheDir string) phpManager { + return &phpManagerPerOS{cacheDir} +} diff --git a/internal/legacy/php_manager_test.go b/internal/legacy/php_manager_test.go new file mode 100644 index 00000000..4f9c6832 --- /dev/null +++ b/internal/legacy/php_manager_test.go @@ -0,0 +1,16 @@ +package legacy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPHPManager(t *testing.T) { + tempDir := t.TempDir() + + pm := newPHPManager(tempDir) + assert.NoError(t, pm.copy()) + + assert.FileExists(t, pm.binPath()) +} diff --git a/internal/legacy/php_manager_unix.go b/internal/legacy/php_manager_unix.go new file mode 100644 index 00000000..9f1e43c6 --- /dev/null +++ b/internal/legacy/php_manager_unix.go @@ -0,0 +1,21 @@ +//go:build darwin || linux + +package legacy + +import ( + "path/filepath" + + "github.com/platformsh/cli/internal/file" +) + +func (m *phpManagerPerOS) copy() error { + return file.WriteIfNeeded(m.binPath(), phpCLI, 0o755) +} + +func (m *phpManagerPerOS) binPath() string { + return filepath.Join(m.cacheDir, "php") +} + +func (m *phpManagerPerOS) settings() []string { + return nil +} diff --git a/internal/legacy/php_manager_windows.go b/internal/legacy/php_manager_windows.go new file mode 100644 index 00000000..a68497a7 --- /dev/null +++ b/internal/legacy/php_manager_windows.go @@ -0,0 +1,102 @@ +package legacy + +import ( + "archive/zip" + "bytes" + _ "embed" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "golang.org/x/sync/errgroup" + + "github.com/platformsh/cli/internal/file" +) + +//go:embed archives/php_windows.zip +var phpCLI []byte + +//go:embed archives/cacert.pem +var caCert []byte + +func (m *phpManagerPerOS) copy() error { + destDir := filepath.Join(m.cacheDir, "php") + + r, err := zip.NewReader(bytes.NewReader(phpCLI), int64(len(phpCLI))) + if err != nil { + return fmt.Errorf("could not open zip reader: %w", err) + } + + g := errgroup.Group{} + g.SetLimit(runtime.NumCPU() * 4) + for _, f := range r.File { + g.Go(func() error { + return copyZipFile(f, destDir) + }) + } + if err := g.Wait(); err != nil { + return err + } + + if err := file.WriteIfNeeded(filepath.Join(destDir, "extras", "cacert.pem"), caCert, 0o644); err != nil { + return err + } + + return nil +} + +func (m *phpManagerPerOS) binPath() string { + return filepath.Join(m.cacheDir, "php", "php.exe") +} + +func (m *phpManagerPerOS) settings() []string { + return []string{ + "extension=" + filepath.Join(m.cacheDir, "php", "ext", "php_curl.dll"), + "extension=" + filepath.Join(m.cacheDir, "php", "ext", "php_openssl.dll"), + "openssl.cafile=" + filepath.Join(m.cacheDir, "php", "extras", "cacert.pem"), + } +} + +// copyZipFile extracts a file from the Zip to the destination directory. +// If the file already exists and has the correct size, it will be skipped. +func copyZipFile(f *zip.File, destDir string) error { + destPath := filepath.Join(destDir, f.Name) + if !strings.HasPrefix(destPath, filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("invalid file path: %s", destPath) + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("could not create extracted directory %s: %w", destPath, err) + } + return nil + } + + if existingFileInfo, err := os.Lstat(destPath); err == nil && uint64(existingFileInfo.Size()) == f.UncompressedSize64 { + return nil + } + + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("could not create parent directory for extracted file %s: %w", destPath, err) + } + + rc, err := f.Open() + if err != nil { + return fmt.Errorf("could not open file in zip archive %s: %w", f.Name, err) + } + defer rc.Close() + + b, err := io.ReadAll(rc) + if err != nil { + return fmt.Errorf("could not extract zipped file %s: %w", f.Name, err) + } + + if err := file.Write(destPath, b, f.Mode()); err != nil { + return fmt.Errorf("could not copy extracted file %s: %w", destPath, err) + } + + return nil +} diff --git a/internal/legacy/php_unix.go b/internal/legacy/php_unix.go deleted file mode 100644 index 4e4c741f..00000000 --- a/internal/legacy/php_unix.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build darwin || linux -// +build darwin linux - -package legacy - -import ( - "fmt" - "path" -) - -// copyPHP to destination, if it does not exist -func (c *CLIWrapper) copyPHP() error { - if err := copyFile(c.PHPPath(), phpCLI); err != nil { - return fmt.Errorf("could not copy PHP CLI: %w", err) - } - - return nil -} - -// PHPPath returns the path that the PHP CLI will reside -func (c *CLIWrapper) PHPPath() string { - return path.Join(c.cacheDir(), phpPath) -} - -func (c *CLIWrapper) phpSettings() []string { - return nil -} diff --git a/internal/legacy/php_windows.go b/internal/legacy/php_windows.go deleted file mode 100644 index e31ae55b..00000000 --- a/internal/legacy/php_windows.go +++ /dev/null @@ -1,78 +0,0 @@ -package legacy - -import ( - "archive/zip" - "bytes" - _ "embed" - "fmt" - "io" - "os" - "path" - "path/filepath" - "strings" -) - -//go:embed archives/php_windows.zip -var phpCLI []byte - -//go:embed archives/cacert.pem -var caCert []byte - -// copyPHP to destination, if it does not exist -func (c *CLIWrapper) copyPHP() error { - dest := path.Join(c.cacheDir(), "php") - br := bytes.NewReader(phpCLI) - r, err := zip.NewReader(br, int64(len(phpCLI))) - if err != nil { - return fmt.Errorf("could not open zip reader: %w", err) - } - - for _, f := range r.File { - rc, err := f.Open() - if err != nil { - return fmt.Errorf("could not open zipped file %s: %w", f.Name, err) - } - defer rc.Close() - - fpath := filepath.Join(dest, f.Name[strings.Index(f.Name, string(os.PathSeparator))+1:]) - if f.FileInfo().IsDir() { - continue - } - - if lastIndex := strings.LastIndex(fpath, string(os.PathSeparator)); lastIndex > -1 { - fdir := fpath[:lastIndex] - if err := os.MkdirAll(fdir, 0755); err != nil { - return fmt.Errorf("could create parent directory %s: %w", fdir, err) - } - } - - f, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) - if err != nil { - return fmt.Errorf("could not open file to unzip %s: %w", fpath, err) - } - defer f.Close() - - if _, err := io.Copy(f, rc); err != nil { - return fmt.Errorf("could not write zipped file %s: %w", fpath, err) - } - } - - copyFile(path.Join(c.cacheDir(), "php", "extras", "cacert.pem"), caCert) - - return nil -} - -// PHPPath returns the path that the PHP CLI will reside -func (c *CLIWrapper) PHPPath() string { - return path.Join(c.cacheDir(), "php", "php.exe") -} - -func (c *CLIWrapper) phpSettings() []string { - cacheDir := c.cacheDir() - - return []string{ - "extension=" + filepath.Join(cacheDir, "php", "ext", "php_curl.dll"), - "extension=" + filepath.Join(cacheDir, "php", "ext", "php_openssl.dll"), - "openssl.cafile=" + filepath.Join(cacheDir, "php", "extras", "cacert.pem"), - } -}