Skip to content

Commit

Permalink
Improve copying of PHP and legacy CLI files
Browse files Browse the repository at this point in the history
- Check the files on each run
- Try to avoid partial writes
- Move temporary directory from /tmp to the home directory
  • Loading branch information
pjcdawkins committed Jan 15, 2025
1 parent 38ff2e3 commit f2d3fd0
Show file tree
Hide file tree
Showing 12 changed files with 411 additions and 184 deletions.
15 changes: 11 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
PHP_VERSION = 8.2.26
PHP_VERSION = 8.2.27
LEGACY_CLI_VERSION = 4.22.0

GORELEASER_ID ?= platform
Expand Down Expand Up @@ -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)

Expand Down
5 changes: 4 additions & 1 deletion commands/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ func newCompletionCommand(cnf *config.Config) *cobra.Command {
exitWithError(err)
}

pharPath := c.PharPath()
pharPath, err := c.PharPath()
if err != nil {
exitWithError(err)
}

completions := strings.ReplaceAll(
strings.ReplaceAll(
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
22 changes: 17 additions & 5 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/platformsh/cli/internal/config"
)
Expand All @@ -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)
Expand All @@ -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)
})
}
87 changes: 87 additions & 0 deletions internal/config/dir.go
Original file line number Diff line number Diff line change
@@ -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()
}
28 changes: 3 additions & 25 deletions internal/config/schema.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
package config

import (
"os"
"path/filepath"
)

// Config provides YAML configuration for the CLI.
// This includes some translation strings for vendorization or white-label needs.
//
Expand Down Expand Up @@ -60,6 +55,9 @@ type Config struct {
SSH struct {
DomainWildcards []string `validate:"required" yaml:"domain_wildcards"` // e.g. ["*.platform.sh"]
} `validate:"required"`

tempDir string `yaml:"-"`
writableUserDir string `yaml:"-"`
}

// applyDefaults applies defaults to config before parsing.
Expand All @@ -79,23 +77,3 @@ func (c *Config) applyDynamicDefaults() {
c.Application.WritableUserDir = c.Application.UserConfigDir
}
}

// 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
}
return path, nil
}

// 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
}
58 changes: 58 additions & 0 deletions internal/file/file.go
Original file line number Diff line number Diff line change
@@ -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
}
81 changes: 81 additions & 0 deletions internal/file/file_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
Loading

0 comments on commit f2d3fd0

Please sign in to comment.