Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add afero to Config, Refactor Config file access #1156

Merged
merged 5 commits into from
May 4, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion cli/cmd/turbo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"time"

"github.com/spf13/afero"
"github.com/vercel/turborepo/cli/internal/config"
"github.com/vercel/turborepo/cli/internal/info"
"github.com/vercel/turborepo/cli/internal/login"
Expand Down Expand Up @@ -51,6 +52,7 @@ func main() {

ui := ui.BuildColoredUi(colorMode)
c := cli.NewCLI("turbo", turboVersion)
fsys := afero.NewOsFs()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns afero.Fs, but in places where we will end up needing it we'll need IOFS interfaces and afero.Lstater which is not implemented by afero.Fs. I'd pitch diving in and doing fsys := &afero.OsFs{} for that reason and then wrapping it in iofsys := afero.IOFS{Fs: fsys}.


util.InitPrintf()

Expand All @@ -59,7 +61,7 @@ func main() {
c.ErrorWriter = os.Stderr
// Parse and validate cmd line flags and env vars
// Note that cf can be nil
cf, err := config.ParseAndValidate(c.Args, ui, turboVersion)
cf, err := config.ParseAndValidate(c.Args, fsys, ui, turboVersion)
if err != nil {
ui.Error(fmt.Sprintf("%s %s", uiPkg.ERROR_PREFIX, color.RedString(err.Error())))
os.Exit(1)
Expand Down
22 changes: 19 additions & 3 deletions cli/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"runtime"
"strings"

"github.com/spf13/afero"
"github.com/vercel/turborepo/cli/internal/client"
"github.com/vercel/turborepo/cli/internal/fs"

Expand Down Expand Up @@ -53,6 +54,8 @@ type Config struct {
RootPackageJSON *fs.PackageJSON
// Current Working Directory
Cwd fs.AbsolutePath
// The filesystem to use for any filesystem interactions
Fs afero.Fs
}

// IsLoggedIn returns true if we have a token and either a team id or team slug
Expand All @@ -71,7 +74,7 @@ type CacheConfig struct {
// ParseAndValidate parses the cmd line flags / env vars, and verifies that all required
// flags have been set. Users can pass in flags when calling a subcommand, or set env vars
// with the prefix 'TURBO_'. If both values are set, the env var value will be used.
func ParseAndValidate(args []string, ui cli.Ui, turboVersion string) (c *Config, err error) {
func ParseAndValidate(args []string, fsys afero.Fs, ui cli.Ui, turboVersion string) (c *Config, err error) {

// Special check for ./turbo invocation without any args
// Return the help message
Expand Down Expand Up @@ -107,8 +110,20 @@ func ParseAndValidate(args []string, ui cli.Ui, turboVersion string) (c *Config,
if err != nil {
return nil, err
}
userConfig, _ := ReadUserConfigFile()
partialConfig, _ := ReadConfigFile(filepath.Join(".turbo", "config.json"))
userConfig, err := ReadUserConfigFile(fsys)
if err != nil {
return nil, fmt.Errorf("reading user config file: %v", err)
}
if userConfig == nil {
userConfig = defaultUserConfig()
}
partialConfig, err := ReadRepoConfigFile(fsys, cwd)
if err != nil {
return nil, fmt.Errorf("reading repo config file: %v", err)
}
if partialConfig == nil {
partialConfig = defaultRepoConfig()
}
partialConfig.Token = userConfig.Token

enverr := envconfig.Process("TURBO", partialConfig)
Expand Down Expand Up @@ -210,6 +225,7 @@ func ParseAndValidate(args []string, ui cli.Ui, turboVersion string) (c *Config,
RootPackageJSON: rootPackageJSON,
TurboJSON: turboJSON,
Cwd: cwd,
Fs: fsys,
}

c.ApiClient.SetToken(partialConfig.Token)
Expand Down
103 changes: 68 additions & 35 deletions cli/internal/config/config_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package config

import (
"encoding/json"
"io/ioutil"
"errors"
"os"
"path/filepath"

"github.com/adrg/xdg"
"github.com/spf13/afero"
"github.com/vercel/turborepo/cli/internal/fs"
)

Expand All @@ -23,13 +25,27 @@ type TurborepoConfig struct {
TeamSlug string `json:"teamSlug,omitempty" envconfig:"team"`
}

func defaultUserConfig() *TurborepoConfig {
return &TurborepoConfig{
ApiUrl: "https://vercel.com/api",
LoginUrl: "https://vercel.com",
}
}

func defaultRepoConfig() *TurborepoConfig {
return &TurborepoConfig{
ApiUrl: "https://vercel.com/api",
LoginUrl: "https://vercel.com",
}
}

// writeConfigFile writes config file at a path
func writeConfigFile(path string, config *TurborepoConfig) error {
func writeConfigFile(fsys afero.Fs, path fs.AbsolutePath, config *TurborepoConfig) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fs.AbsolutePath 🎉

This looks like it clobbers whatever is there? Should we make this a mergeConfigFile that does a get-set? (Most particularly for ApiUrl and LoginUrl, things that may be hand-tweaked.)

jsonBytes, marshallError := json.Marshal(config)
if marshallError != nil {
return marshallError
}
writeFilErr := ioutil.WriteFile(path, jsonBytes, 0644)
writeFilErr := fs.WriteFile(fsys, path, jsonBytes, 0644)
if writeFilErr != nil {
return writeFilErr
}
Expand All @@ -38,38 +54,51 @@ func writeConfigFile(path string, config *TurborepoConfig) error {

// WriteRepoConfigFile is used to write the portion of the config file that is saved
// within the repository itself.
func WriteRepoConfigFile(config *TurborepoConfig) error {
fs.EnsureDir(filepath.Join(".turbo", "config.json"))
path := filepath.Join(".turbo", "config.json")
return writeConfigFile(path, config)
func WriteRepoConfigFile(fsys afero.Fs, repoRoot fs.AbsolutePath, toWrite *TurborepoConfig) error {
path := repoRoot.Join(".turbo", "config.json")
err := fs.EnsureDirFS(fsys, path)
if err != nil {
return err
}
return writeConfigFile(fsys, path, toWrite)
}

// WriteUserConfigFile writes a user config file. This may contain a token and so should
// not be saved within the repository to avoid committing sensitive data
func WriteUserConfigFile(config *TurborepoConfig) error {
func userConfigPath(fsys afero.Fs) (fs.AbsolutePath, error) {
path, err := xdg.ConfigFile(filepath.Join("turborepo", "config.json"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

xdg.ConfigFile creates the directory as a side effect: https://github.com/adrg/xdg/blob/v0.4.0/internal/pathutil/pathutil.go#L51

And the Exists call directly calls os.Stat or os.Lstat (depending upon the implementation)

It isn't fully-required as it isn't a code path we'd necessarily care to push through a custom FS, but it'd be nice to be able to use a consistent filesystem abstraction.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, unfortunate. Looks like upstream is open to a patch though, which is good.

if err != nil {
return err
return "", err
}
return writeConfigFile(path, config)
absPath, err := fs.CheckedToAbsolutePath(path)
if err != nil {
return "", err
}
return absPath, nil
}

// ReadConfigFile reads a config file at a path
func ReadConfigFile(path string) (*TurborepoConfig, error) {
var config = &TurborepoConfig{
Token: "",
TeamId: "",
ApiUrl: "https://vercel.com/api",
LoginUrl: "https://vercel.com",
TeamSlug: "",
// WriteUserConfigFile writes the given configuration to a user-specific
// configuration file. This is for values that are not shared with a team, such
// as credentials.
func WriteUserConfigFile(fsys afero.Fs, config *TurborepoConfig) error {
path, err := userConfigPath(fsys)
if err != nil {
return err
}
b, err := ioutil.ReadFile(path)
return writeConfigFile(fsys, path, config)
}

// readConfigFile reads a config file at a path
func readConfigFile(fsys afero.Fs, path fs.AbsolutePath, defaults func() *TurborepoConfig) (*TurborepoConfig, error) {
b, err := fs.ReadFile(fsys, path)
if err != nil {
return config, err
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
config := defaults()
jsonErr := json.Unmarshal(b, config)
if jsonErr != nil {
return config, jsonErr
return nil, jsonErr
}
if config.ApiUrl == "https://api.vercel.com" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An optional nice-to-have cleanup for this PR would be to go ahead and rewrite this to the correct value on disk or spit out a log message.

config.ApiUrl = "https://vercel.com/api"
Expand All @@ -78,21 +107,25 @@ func ReadConfigFile(path string) (*TurborepoConfig, error) {
}

// ReadUserConfigFile reads a user config file
func ReadUserConfigFile() (*TurborepoConfig, error) {
path, err := xdg.ConfigFile(filepath.Join("turborepo", "config.json"))
func ReadUserConfigFile(fsys afero.Fs) (*TurborepoConfig, error) {
path, err := userConfigPath(fsys)
if err != nil {
return &TurborepoConfig{
Token: "",
TeamId: "",
ApiUrl: "https://vercel.com/api",
LoginUrl: "https://vercel.com",
TeamSlug: "",
}, err
return nil, err
}
return ReadConfigFile(path)
return readConfigFile(fsys, path, defaultUserConfig)
}

// ReadRepoConfigFile reads the user-specific configuration values
func ReadRepoConfigFile(fsys afero.Fs, repoRoot fs.AbsolutePath) (*TurborepoConfig, error) {
path := repoRoot.Join(".turbo", "config.json")
return readConfigFile(fsys, path, defaultRepoConfig)
}

// DeleteUserConfigFile deletes a user config file
func DeleteUserConfigFile() error {
return WriteUserConfigFile(&TurborepoConfig{})
func DeleteUserConfigFile(fsys afero.Fs) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a significant improvement, but I'm not sure that we ever actually want this behavior? The call site is logout so I feel like we should remove the config for logging-in only.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a little digging, and the only value we use from the user config file is Token. I think what we ought to do is stop reusing the same struct to represent both configs.

At the risk of pushing a significant change down the road, this area of the codebase might be best handled post-cobra migration when we bring in viper, which is built on cobra, to help us resolve our configuration situation a bit better.

I'm not opposed to splitting out the struct now, but I'm a bit hesitant to tackle any more intelligent configuration merging or rewriting, given that I'm hoping most of this code is not [too] long for this world.

path, err := userConfigPath(fsys)
if err != nil {
return err
}
return fs.RemoveFile(fsys, path)
}
139 changes: 139 additions & 0 deletions cli/internal/config/config_file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package config
gsoltis marked this conversation as resolved.
Show resolved Hide resolved

import (
"encoding/json"
"os"
"testing"

"github.com/spf13/afero"
"github.com/vercel/turborepo/cli/internal/fs"
)

func TestReadRepoConfigWhenMissing(t *testing.T) {
fsys := afero.NewMemMapFs()
cwd, err := fs.GetCwd()
if err != nil {
t.Fatalf("getting cwd: %v", err)
}

config, err := ReadRepoConfigFile(fsys, cwd)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, because of xdg, this "read" has side-effects on the OS filesystem even though we issue our own write to fsys. Since we handle our own reads and writes and just use xdg for getting path strings, this isn't really a big deal, but it may be worth noting.

If we ever did a multi-layer FS this sort of weirdness could be an issue.

if err != nil {
t.Errorf("got error reading non-existent config file: %v, want <nil>", err)
}
if config != nil {
t.Errorf("got config value %v, wanted <nil>", config)
}
}

func writePartialInitialConfig(t *testing.T, fsys afero.Fs, repoRoot fs.AbsolutePath) *TurborepoConfig {
path := repoRoot.Join(".turbo", "config.json")
initial := &TurborepoConfig{
TeamSlug: "my-team",
}
toWrite, err := json.Marshal(initial)
if err != nil {
t.Fatalf("marshal config: %v", err)
}
err = fs.WriteFile(fsys, path, toWrite, os.ModePerm)
if err != nil {
t.Fatalf("writing config file: %v", err)
}
return initial
}

func TestRepoConfigIncludesDefaults(t *testing.T) {
fsys := afero.NewMemMapFs()
cwd, err := fs.GetCwd()
if err != nil {
t.Fatalf("getting cwd: %v", err)
}

initial := writePartialInitialConfig(t, fsys, cwd)

config, err := ReadRepoConfigFile(fsys, cwd)
if err != nil {
t.Errorf("ReadRepoConfigFile err got %v, want <nil>", err)
}
defaultConfig := defaultRepoConfig()
if config.ApiUrl != defaultConfig.ApiUrl {
t.Errorf("api url got %v, want %v", config.ApiUrl, defaultConfig.ApiUrl)
}
if config.TeamSlug != initial.TeamSlug {
t.Errorf("team slug got %v, want %v", config.TeamSlug, initial.TeamSlug)
}
}

func TestWriteRepoConfig(t *testing.T) {
fsys := afero.NewMemMapFs()
cwd, err := fs.GetCwd()
if err != nil {
t.Fatalf("getting cwd: %v", err)
}

initial := &TurborepoConfig{}
initial.TeamSlug = "my-team"
err = WriteRepoConfigFile(fsys, cwd, initial)
if err != nil {
t.Errorf("WriteRepoConfigFile got %v, want <nil>", err)
}

config, err := ReadRepoConfigFile(fsys, cwd)
if err != nil {
t.Errorf("ReadRepoConfig err got %v, want <nil>", err)
}
if config.TeamSlug != initial.TeamSlug {
t.Errorf("TeamSlug got %v want %v", config.TeamSlug, initial.TeamSlug)
}
defaultConfig := defaultRepoConfig()
if config.ApiUrl != defaultConfig.ApiUrl {
t.Errorf("ApiUrl got %v, want %v", config.ApiUrl, defaultConfig.ApiUrl)
}
}

func TestReadUserConfigWhenMissing(t *testing.T) {
fsys := afero.NewMemMapFs()
config, err := ReadUserConfigFile(fsys)
if err != nil {
t.Errorf("ReadUserConfig err got %v, want <nil>", err)
}
if config != nil {
t.Errorf("ReadUserConfig on non-existent file got %v, want <nil>", config)
}
}

func TestWriteUserConfig(t *testing.T) {
fsys := afero.NewMemMapFs()
initial := defaultUserConfig()
initial.Token = "my-token"
initial.ApiUrl = "https://api.vercel.com" // should be overridden
err := WriteUserConfigFile(fsys, initial)
if err != nil {
t.Errorf("WriteUserConfigFile err got %v, want <nil>", err)
}

config, err := ReadUserConfigFile(fsys)
if err != nil {
t.Errorf("ReadUserConfig err got %v, want <nil>", err)
}
if config.Token != initial.Token {
t.Errorf("Token got %v want %v", config.Token, initial.Token)
}
// Verify that our legacy ApiUrl was upgraded
defaultConfig := defaultUserConfig()
if config.ApiUrl != defaultConfig.ApiUrl {
t.Errorf("ApiUrl got %v, want %v", config.ApiUrl, defaultConfig.ApiUrl)
}

err = DeleteUserConfigFile(fsys)
if err != nil {
t.Errorf("DeleteUserConfigFile err got %v, want <nil>", err)
}

missing, err := ReadUserConfigFile(fsys)
if err != nil {
t.Errorf("ReadUserConfig err got %v, want <nil>", err)
}
if missing != nil {
t.Errorf("reading deleted config got %v, want <nil>", missing)
}
}
6 changes: 3 additions & 3 deletions cli/internal/fs/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ func CopyFile(from string, to string, mode os.FileMode) error {
return err
}
defer fromFile.Close()
return WriteFile(fromFile, to, mode)
return writeFileFromStream(fromFile, to, mode)
}

// WriteFile writes data from a reader to the file named 'to', with an attempt to perform
// writeFileFromStream writes data from a reader to the file named 'to', with an attempt to perform
// a copy & rename to avoid chaos if anything goes wrong partway.
func WriteFile(fromFile io.Reader, to string, mode os.FileMode) error {
func writeFileFromStream(fromFile io.Reader, to string, mode os.FileMode) error {
gsoltis marked this conversation as resolved.
Show resolved Hide resolved
dir, file := filepath.Split(to)
if dir != "" {
if err := os.MkdirAll(dir, DirPermissions); err != nil {
Expand Down
Loading