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

Fixing config file lock mechanism #352

Closed
wants to merge 13 commits into from
Closed
87 changes: 55 additions & 32 deletions common/commands/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,34 @@ package commands

import (
"fmt"
"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
"github.com/jfrog/jfrog-cli-core/v2/utils/ioutils"
"github.com/jfrog/jfrog-cli-core/v2/utils/lock"
"io/ioutil"
"reflect"
"strconv"
"strings"
"sync"

"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
"github.com/jfrog/jfrog-cli-core/v2/utils/ioutils"

"github.com/jfrog/jfrog-client-go/auth"

"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils"
"github.com/jfrog/jfrog-cli-core/v2/utils/config"
"github.com/jfrog/jfrog-cli-core/v2/utils/lock"
clientutils "github.com/jfrog/jfrog-client-go/utils"
"github.com/jfrog/jfrog-client-go/utils/errorutils"
"github.com/jfrog/jfrog-client-go/utils/io/fileutils"
"github.com/jfrog/jfrog-client-go/utils/log"
)

type ConfigAction string

const (
AddOrEdit ConfigAction = "AddOrEdit"
Delete ConfigAction = "Delete"
Use ConfigAction = "Use"
Clear ConfigAction = "Clear"
)

// Internal golang locking for the same process.
var mutex sync.Mutex

Expand All @@ -34,10 +42,11 @@ type ConfigCommand struct {
serverId string
// For unit tests
disablePromptUrls bool
cmdType ConfigAction
}

func NewConfigCommand() *ConfigCommand {
return &ConfigCommand{}
func NewConfigCommand(cmdType ConfigAction, serverId string) *ConfigCommand {
return &ConfigCommand{cmdType: cmdType, serverId: serverId}
}

func (cc *ConfigCommand) SetServerId(serverId string) *ConfigCommand {
Expand Down Expand Up @@ -71,7 +80,34 @@ func (cc *ConfigCommand) SetDetails(details *config.ServerDetails) *ConfigComman
}

func (cc *ConfigCommand) Run() error {
return cc.Config()
mutex.Lock()
defer mutex.Unlock()
lockDirPath, err := coreutils.GetJfrogConfigLockDir()
if err != nil {
return err
}
lockFile, err := lock.CreateLock(lockDirPath)
defer func() {
e := lockFile.Unlock()
if err == nil {
err = e
}
}()
if err != nil {
return err
}
switch cc.cmdType {
case AddOrEdit:
return cc.config()
case Delete:
return cc.delete()
case Use:
return cc.use()
case Clear:
return cc.clear()
default:
return fmt.Errorf("Not supported config command type: " + string(cc.cmdType))
Copy link
Contributor

Choose a reason for hiding this comment

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

Not supported... --> Unsupported...

}
}

func (cc *ConfigCommand) ServerDetails() (*config.ServerDetails, error) {
Expand All @@ -90,20 +126,7 @@ func (cc *ConfigCommand) CommandName() string {
return "config"
}

func (cc *ConfigCommand) Config() error {
mutex.Lock()
defer mutex.Unlock()
lockDirPath, err := coreutils.GetJfrogConfigLockDir()
if err != nil {
return err
}
lockFile, err := lock.CreateLock(lockDirPath)
defer lockFile.Unlock()

if err != nil {
return err
}

func (cc *ConfigCommand) config() error {
configurations, err := cc.prepareConfigurationData()
if err != nil {
return err
Expand Down Expand Up @@ -134,7 +157,7 @@ func (cc *ConfigCommand) Config() error {

// Artifactory expects the username to be lower-cased. In case it is not,
// Artifactory will silently save it lower-cased, but the token creation
// REST API will fail with a non lower-cased username.
// REST API will fail with a non-lower-cased username.
cc.details.User = strings.ToLower(cc.details.User)

if len(configurations) == 1 {
Expand Down Expand Up @@ -211,7 +234,7 @@ func (cc *ConfigCommand) prepareConfigurationData() ([]*config.ServerDetails, er
return configurations, err
}

/// Returning the first non empty value:
/// Returning the first non-empty value:
Copy link
Contributor

Choose a reason for hiding this comment

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

/// --> //

// 1. The serverId argument sent.
// 2. details.ServerId
// 3. defaultDetails.ServerId
Expand Down Expand Up @@ -388,7 +411,7 @@ func Import(serverToken string) error {
details: serverDetails,
serverId: serverDetails.ServerId,
}
return configCommand.Config()
return configCommand.config()
}

func Export(serverName string) error {
Expand Down Expand Up @@ -438,14 +461,14 @@ func logIfNotEmpty(value, prefix string, mask bool) {
}
}

func DeleteConfig(serverName string) error {
func (cc *ConfigCommand) delete() error {
configurations, err := config.GetAllServersConfigs()
if err != nil {
return err
}
var isDefault, isFoundName bool
for i, serverDetails := range configurations {
if serverDetails.ServerId == serverName {
if serverDetails.ServerId == cc.serverId {
isDefault = serverDetails.IsDefault
configurations = append(configurations[:i], configurations[i+1:]...)
isFoundName = true
Expand All @@ -459,20 +482,20 @@ func DeleteConfig(serverName string) error {
if isFoundName {
return config.SaveServersConf(configurations)
}
log.Info("\"" + serverName + "\" configuration could not be found.\n")
log.Info("\"" + cc.serverId + "\" configuration could not be found.\n")
return nil
}

// Set the default configuration
func Use(serverId string) error {
func (cc *ConfigCommand) use() error {
configurations, err := config.GetAllServersConfigs()
if err != nil {
return err
}
var serverFound *config.ServerDetails
newDefaultServer := true
for _, serverDetails := range configurations {
if serverDetails.ServerId == serverId {
if serverDetails.ServerId == cc.serverId {
serverFound = serverDetails
if serverDetails.IsDefault {
newDefaultServer = false
Expand All @@ -494,11 +517,11 @@ func Use(serverId string) error {
log.Info(fmt.Sprintf("Using server ID '%s' (%s).", serverFound.ServerId, serverFound.Url))
return nil
}
return errorutils.CheckErrorf("Could not find a server with ID '%s'.", serverId)
return errorutils.CheckErrorf("Could not find a server with ID '%s'.", cc.serverId)
}

func ClearConfig(interactive bool) error {
if interactive {
func (cc *ConfigCommand) clear() error {
if cc.interactive {
confirmed := coreutils.AskYesNo("Are you sure you want to delete all the configurations?", false)
if !confirmed {
return nil
Expand Down
10 changes: 5 additions & 5 deletions common/commands/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,13 @@ func TestBasicAuthOnlyOption(t *testing.T) {
outputConfig, err := configAndGetTestServer(t, inputDetails, true, false)
assert.NoError(t, err)
assert.Equal(t, coreutils.TokenRefreshDisabled, outputConfig.TokenRefreshInterval, "expected refreshable token to be disabled")
assert.NoError(t, DeleteConfig("test"))
assert.NoError(t, NewConfigCommand(Delete, "test").Run())

// Verify setting the option enables refreshable tokens.
outputConfig, err = configAndGetTestServer(t, inputDetails, false, false)
assert.NoError(t, err)
assert.Equal(t, coreutils.TokenRefreshDefaultInterval, outputConfig.TokenRefreshInterval, "expected refreshable token to be enabled")
assert.NoError(t, DeleteConfig("test"))
assert.NoError(t, NewConfigCommand(Delete, "test").Run())
}

func TestExportEmptyConfig(t *testing.T) {
Expand Down Expand Up @@ -161,14 +161,14 @@ func configAndTest(t *testing.T, inputDetails *config.ServerDetails, interactive
outputConfig, err := configAndGetTestServer(t, inputDetails, true, interactive)
assert.NoError(t, err)
assert.Equal(t, configStructToString(inputDetails), configStructToString(outputConfig), "unexpected configuration was saved to file")
assert.NoError(t, DeleteConfig("test"))
assert.NoError(t, NewConfigCommand(Delete, "test").Run())
testExportImport(t, inputDetails)
}

func configAndGetTestServer(t *testing.T, inputDetails *config.ServerDetails, basicAuthOnly, interactive bool) (*config.ServerDetails, error) {
configCmd := NewConfigCommand().SetDetails(inputDetails).SetServerId("test").SetUseBasicAuthOnly(basicAuthOnly).SetInteractive(interactive)
configCmd := NewConfigCommand(AddOrEdit, "test").SetDetails(inputDetails).SetUseBasicAuthOnly(basicAuthOnly).SetInteractive(interactive)
configCmd.disablePromptUrls = true
assert.NoError(t, configCmd.Config())
assert.NoError(t, configCmd.Run())
return GetConfig("test", false)
}

Expand Down
6 changes: 3 additions & 3 deletions general/envsetup/envsetup.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,11 @@ func configServer(server *config.ServerDetails) error {
}
// Take the server name from host name: https://myjfrog.jfrog.com/ -> myjfrog
serverId := strings.Split(u.Host, ".")[0]
configCmd := commands.NewConfigCommand().SetInteractive(false).SetServerId(serverId).SetDetails(server)
if err = configCmd.Config(); err != nil {
configCmd := commands.NewConfigCommand(commands.AddOrEdit, serverId).SetInteractive(false).SetDetails(server)
if err = configCmd.Run(); err != nil {
return err
}
return commands.Use(serverId)
return commands.NewConfigCommand(commands.Use, serverId).SetInteractive(false).SetDetails(server).Run()
}

type myJfrogGetStatusRequest struct {
Expand Down
17 changes: 8 additions & 9 deletions utils/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,25 @@ import (
"bytes"
"encoding/json"
"errors"
accessAuth "github.com/jfrog/jfrog-client-go/access/auth"
pipelinesAuth "github.com/jfrog/jfrog-client-go/pipelines/auth"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/buger/jsonparser"
"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
cliLog "github.com/jfrog/jfrog-cli-core/v2/utils/log"
accessAuth "github.com/jfrog/jfrog-client-go/access/auth"
artifactoryAuth "github.com/jfrog/jfrog-client-go/artifactory/auth"
"github.com/jfrog/jfrog-client-go/auth"
distributionAuth "github.com/jfrog/jfrog-client-go/distribution/auth"
pipelinesAuth "github.com/jfrog/jfrog-client-go/pipelines/auth"
"github.com/jfrog/jfrog-client-go/utils"
"github.com/jfrog/jfrog-client-go/utils/errorutils"
"github.com/jfrog/jfrog-client-go/utils/io/fileutils"
"github.com/jfrog/jfrog-client-go/utils/log"
xrayAuth "github.com/jfrog/jfrog-client-go/xray/auth"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)

func init() {
Expand Down
30 changes: 17 additions & 13 deletions utils/lock/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ func (locks Locks) Less(i, j int) bool {

// Creating a new lock object.
func (lock *Lock) createNewLockFile(lockDirPath string) error {
lock.currentTime = time.Now().UnixNano()
err := fileutils.CreateDirIfNotExist(lockDirPath)
if err != nil {
return err
Expand All @@ -50,13 +49,17 @@ func (lock *Lock) createNewLockFile(lockDirPath string) error {

func (lock *Lock) createFile(folderName string, pid int) error {
// We are creating an empty file with the pid and current time part of the name
lock.fileName = filepath.Join(folderName, "jfrog-cli.conf.lck."+strconv.Itoa(pid)+"."+strconv.FormatInt(lock.currentTime, 10))
lock.fileName = filepath.Join(folderName, "jfrog-cli.conf.lck."+strconv.Itoa(pid)+"."+strconv.FormatInt(time.Now().UnixNano(), 10))
log.Debug("Creating lock file: ", lock.fileName)
file, err := os.OpenFile(lock.fileName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
return errorutils.CheckError(err)
}

fileInfo, err := file.Stat()
if err != nil {
return errorutils.CheckError(err)
}
lock.currentTime = fileInfo.ModTime().UnixNano()
if err = file.Close(); err != nil {
return errorutils.CheckError(err)
}
Expand Down Expand Up @@ -85,7 +88,7 @@ func (lock *Lock) lock() error {
// If the first timestamp in the sorted locks slice is equal to this timestamp
// means that the lock can be acquired
if locks[0].currentTime == lock.currentTime {
// Edge case, if at the same time (by the nano seconds) two different process created two files.
// Edge case, if at the same time (by the nanoseconds) two different process created two files.
// We are checking the PID to know which process can run.
if locks[0].pid != lock.pid {
err := lock.removeOtherLockOrWait(locks[0], &filesList)
Expand Down Expand Up @@ -163,23 +166,24 @@ func (lock *Lock) getLocks(filesList []string) (Locks, error) {
// Slice of all the timestamps that currently the lock directory has
var files Locks
for _, path := range filesList {
fileName := filepath.Base(path)
splitted := strings.Split(fileName, ".")

if len(splitted) != 5 {
return nil, errorutils.CheckErrorf("Failed while parsing the file name: %s located at: %s. Expecting a different format.", fileName, path)
}
// Last element is the timestamp.
time, err := strconv.ParseInt(splitted[4], 10, 64)
fileInfo, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
// If file doesn't exist, then lock already deleted
continue
}
return nil, errorutils.CheckError(err)
Copy link

Choose a reason for hiding this comment

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

Between getting the filesList and calling stat on the file, the other process may well be already done. In that case the file does not exist anymore which is normal and no cause for concern. As such, this error should be ignored here:

Suggested change
return nil, errorutils.CheckError(err)
continue

Copy link
Contributor Author

@sverdlov93 sverdlov93 Mar 29, 2022

Choose a reason for hiding this comment

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

Hi @HWoidt,
Actually, when I worked on the fix I tried using LockFileEx/flock/lockedfile and some others.
Some worked for specific OS only and some were deprecated or unmaintained.
BTW, I saw that Go has already accepted exporting part of the lockedfile code but it's not merged yet.

I improved the Stat section and I will try to look again at the timing issue.

Copy link
Contributor Author

@sverdlov93 sverdlov93 Mar 29, 2022

Choose a reason for hiding this comment

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

@HWoidt
I created also #367 with the same improved config file commands but with the old time.now() mechanism.
Can you try that one also to see if it's better?

}
splitted := strings.Split(fileInfo.Name(), ".")
if len(splitted) != 5 {
return nil, errorutils.CheckErrorf("Failed while parsing the file name: %s located at: %s. Expecting a different format.", fileInfo.Name(), path)
}
pid, err := strconv.Atoi(splitted[3])
if err != nil {
return nil, errorutils.CheckError(err)
}
file := Lock{
currentTime: time,
currentTime: fileInfo.ModTime().UnixNano(),
pid: pid,
fileName: path,
}
Expand Down
7 changes: 1 addition & 6 deletions utils/lock/lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"os"
"path/filepath"
"testing"
"time"
)

var testLockDirPath string
Expand Down Expand Up @@ -180,11 +179,7 @@ func TestCreateFile(t *testing.T) {
}

func getLock(pid int, t *testing.T) (Lock, string) {
currentTime := time.Now().UnixNano()
lock := Lock{
pid: pid,
currentTime: currentTime,
}
lock := Lock{pid: pid}
assert.NotZero(t, testLockDirPath, "An error occurred while initializing testLockDirPath")
assert.NoError(t, fileutils.CreateDirIfNotExist(testLockDirPath))
err := lock.createFile(testLockDirPath, pid)
Expand Down