Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob
}
if cnf.Wrapper.GitHubRepo != "" {
go func() {
rel, _ := internal.CheckForUpdate(cnf.Wrapper.GitHubRepo, version)
rel, _ := internal.CheckForUpdate(cnf, version)
updateMessageChan <- rel
}()
}
Expand Down
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ func LoadYAML() ([]byte, error) {
// FromYAML parses YAML configuration.
func FromYAML(b []byte) (*Config, error) {
c := &Config{}
c.applyDefaults()
if err := yaml.Unmarshal(b, c); err != nil {
return nil, fmt.Errorf("invalid config YAML: %w", err)
}
v := validator.New()
if err := v.Struct(c); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
c.applyDynamicDefaults()
return c, nil
}

Expand Down
52 changes: 28 additions & 24 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package config_test

import (
_ "embed"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -13,28 +15,30 @@ import (
var validConfig string

func TestFromYAML(t *testing.T) {
cases := []struct {
name string
config string
shouldContainError string
}{
{
"missing_values",
"application: {name: Test CLI}",
`Error:Field validation for 'EnvPrefix' failed on the 'required' tag`,
},
{"complete", validConfig, ""},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := config.FromYAML([]byte(c.config))
if c.shouldContainError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), c.shouldContainError)
} else {
assert.NoError(t, err)
}
})
}
t.Run("missing_values", func(t *testing.T) {
_, err := config.FromYAML([]byte(`application: {name: Test CLI}`))
assert.Error(t, err)
assert.Contains(t, err.Error(), `Error:Field validation for 'EnvPrefix' failed on the 'required' tag`)
})

t.Run("complete", func(t *testing.T) {
cnf, err := config.FromYAML([]byte(validConfig))
assert.NoError(t, err)

// Test defaults
assert.Equal(t, "state.json", cnf.Application.UserStateFile)
assert.Equal(t, true, cnf.Updates.Check)
assert.Equal(t, 3600, cnf.Updates.CheckInterval)
assert.Equal(t, cnf.Application.UserConfigDir, cnf.Application.WritableUserDir)
assert.Equal(t, "example-cli-tmp", cnf.Application.TempSubDir)

writableDir, err := cnf.WritableUserDir()
assert.NoError(t, err)

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)
}
})
}
61 changes: 54 additions & 7 deletions internal/config/schema.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
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 All @@ -16,14 +21,19 @@ type Config struct {

Application struct {
// Fields required for both the PHP and Go applications.
Name string `validate:"required"` // e.g. "Platform.sh CLI"
EnvPrefix string `validate:"required" yaml:"env_prefix"` // e.g. "PLATFORMSH_CLI_"
Executable string `validate:"required"` // e.g. "platform"
Slug string `validate:"required,ascii"` // e.g. "platformsh-cli"

// Fields only needed by the PHP (legacy) CLI, at least for now.
UserConfigDir string `validate:"required" yaml:"user_config_dir"` // e.g. ".platformsh"
Name string `validate:"required"` // e.g. "Platform.sh CLI"
EnvPrefix string `validate:"required" yaml:"env_prefix"` // e.g. "PLATFORMSH_CLI_"
Executable string `validate:"required"` // e.g. "platform"
Slug string `validate:"required,ascii"` // e.g. "platformsh-cli"
UserConfigDir string `validate:"required" yaml:"user_config_dir"` // e.g. ".platformsh"
UserStateFile string `validate:"omitempty" yaml:"user_state_file"` // defaults to "state.json"
WritableUserDir string `validate:"omitempty" yaml:"writable_user_dir"` // defaults to UserConfigDir
TempSubDir string `validate:"omitempty" yaml:"tmp_sub_dir"` // defaults to Slug+"-tmp"
} `validate:"required,dive"`
Updates struct {
Check bool `validate:"omitempty"` // defaults to true
CheckInterval int `validate:"omitempty" yaml:"check_interval"` // seconds, defaults to 3600
} `validate:"omitempty"`

// Fields only needed by the PHP (legacy) CLI, at least for now.
API struct {
Expand All @@ -50,3 +60,40 @@ type Config struct {
DocsURL string `validate:"omitempty,url" yaml:"docs_url"` // e.g. "https://docs.platform.sh"
} `validate:"required,dive"`
}

// applyDefaults applies defaults to config before parsing.
func (c *Config) applyDefaults() {
c.Application.UserStateFile = "state.json"
c.Updates.Check = true
c.Updates.CheckInterval = 3600
}

// applyDynamicDefaults applies defaults to config after parsing and validating.
func (c *Config) applyDynamicDefaults() {
if c.Application.TempSubDir == "" {
c.Application.TempSubDir = c.Application.Slug + "-tmp"
}
if c.Application.WritableUserDir == "" {
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
}
44 changes: 0 additions & 44 deletions internal/state.go

This file was deleted.

57 changes: 57 additions & 0 deletions internal/state/state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package state

import (
"encoding/json"
"os"
"path/filepath"

"github.com/platformsh/cli/internal/config"
)

type State struct {
Updates struct {
LastChecked int64 `json:"last_checked"`
} `json:"updates"`
}

// Load reads state from the filesystem.
func Load(cnf *config.Config) (state State, err error) {
statePath, err := getPath(cnf)
if err != nil {
return
}
data, err := os.ReadFile(statePath)
if err != nil {
if os.IsNotExist(err) {
err = nil
}
return
}
err = json.Unmarshal(data, &state)
return
}

// Save writes state to the filesystem.
func Save(state State, cnf *config.Config) error {
statePath, err := getPath(cnf)
if err != nil {
return err
}

data, err := json.Marshal(state)
if err != nil {
return err
}

return os.WriteFile(statePath, data, 0o600)
}

// getPath determines the path to the state JSON file depending on config.
func getPath(cnf *config.Config) (string, error) {
writableDir, err := cnf.WritableUserDir()
if err != nil {
return "", err
}

return filepath.Join(writableDir, cnf.Application.UserStateFile), nil
}
51 changes: 23 additions & 28 deletions internal/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"

"github.com/mattn/go-isatty"

"github.com/platformsh/cli/internal/config"
"github.com/platformsh/cli/internal/state"
)

var versionRegex = regexp.MustCompile(`^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-(?P<preRelease>.+))?$`)
Expand Down Expand Up @@ -105,55 +107,48 @@ func ParseVersion(version string) (*Version, error) {
}

// CheckForUpdate checks whether this software has had a newer release on GitHub
func CheckForUpdate(repo, currentVersion string) (*ReleaseInfo, error) {
if !shouldCheckForUpdate() {
func CheckForUpdate(cnf *config.Config, currentVersion string) (*ReleaseInfo, error) {
if !shouldCheckForUpdate(cnf) {
return nil, nil
}

homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
s, err := state.Load(cnf)
if err == nil && time.Now().Unix()-s.Updates.LastChecked < int64(cnf.Updates.CheckInterval) {
// Updates were already checked recently.
return nil, nil
}

stateFilePath := filepath.Join(homeDir, ".platformsh", "state.json")
state, err := getState(stateFilePath)
if err != nil || state == nil || state.Updates.LastChecked < int(time.Now().Add(-1*time.Hour).Unix()) {
if state == nil {
state = &stateEntry{}
}
releaseInfo, releaseInfoErr := getLatestReleaseInfo(repo)
if releaseInfoErr == nil {
state.Updates.LastChecked = int(time.Now().Unix())
state.Updates.LatestRelease = releaseInfo
//nolint:errcheck // not being able to set the state should have no impact on the rest of the program
setState(stateFilePath, state)
}
}
defer func() {
// After checking, save the last check time.
s.Updates.LastChecked = time.Now().Unix()
//nolint:errcheck // not being able to set the state should have no impact on the rest of the program
state.Save(s, cnf)
}()

if state.Updates.LatestRelease == nil {
return nil, fmt.Errorf("could not determine latest release")
releaseInfo, err := getLatestReleaseInfo(cnf.Wrapper.GitHubRepo)
if err != nil {
return nil, fmt.Errorf("could not determine latest release: %w", err)
}

currentVersionParsed, err := ParseVersion(currentVersion)
if err != nil {
return nil, err
}

latestVersionParsed, err := ParseVersion(state.Updates.LatestRelease.Version)
latestVersionParsed, err := ParseVersion(releaseInfo.Version)
if err != nil {
return nil, err
}
if CompareVersions(latestVersionParsed, currentVersionParsed) == 1 {
return state.Updates.LatestRelease, nil
return releaseInfo, nil
}

return nil, nil
}

// shouldCheckForUpdate makes sure that we're not running in CI, this is a Terminal window and
// PLATFORMSH_CLI_UPDATES_CHECK is not 0
func shouldCheckForUpdate() bool {
if os.Getenv("PLATFORMSH_CLI_UPDATES_CHECK") == "0" {
// shouldCheckForUpdate checks updates are not disabled and the environment is a terminal
func shouldCheckForUpdate(cnf *config.Config) bool {
if cnf.Wrapper.GitHubRepo == "" || !cnf.Updates.Check || os.Getenv(cnf.Application.EnvPrefix+"UPDATES_CHECK") == "0" {
return false
}

Expand Down