github.com/jfrog/jfrog-cli-core/v2@v2.52.0/general/envsetup/envsetup.go (about)

     1  package envsetup
     2  
     3  import (
     4  	"encoding/base64"
     5  	"encoding/json"
     6  	"fmt"
     7  	"github.com/google/uuid"
     8  	"github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/generic"
     9  	"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils"
    10  	"github.com/jfrog/jfrog-cli-core/v2/common/commands"
    11  	"github.com/jfrog/jfrog-cli-core/v2/general"
    12  	"github.com/jfrog/jfrog-cli-core/v2/utils/config"
    13  	"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
    14  	"github.com/jfrog/jfrog-cli-core/v2/utils/ioutils"
    15  	"github.com/jfrog/jfrog-client-go/access/services"
    16  	"github.com/jfrog/jfrog-client-go/http/httpclient"
    17  	clientUtils "github.com/jfrog/jfrog-client-go/utils"
    18  	"github.com/jfrog/jfrog-client-go/utils/errorutils"
    19  	ioUtils "github.com/jfrog/jfrog-client-go/utils/io"
    20  	"github.com/jfrog/jfrog-client-go/utils/io/httputils"
    21  	"github.com/jfrog/jfrog-client-go/utils/log"
    22  	"github.com/pkg/browser"
    23  	"net/http"
    24  	"time"
    25  )
    26  
    27  type OutputFormat string
    28  
    29  const (
    30  	myJfrogEndPoint   = "https://myjfrog-api.jfrog.com/api/v1/activation/cloud/cli/getStatus/"
    31  	syncSleepInterval = 5 * time.Second  // 5 seconds
    32  	maxWaitMinutes    = 30 * time.Minute // 30 minutes
    33  
    34  	// OutputFormat values
    35  	Human   OutputFormat = "human"
    36  	Machine OutputFormat = "machine"
    37  
    38  	// When entering password on terminal the user has limited number of retries.
    39  	enterPasswordMaxRetries = 20
    40  
    41  	MessageIdes = "📦 If you're using VS Code, IntelliJ IDEA, WebStorm, PyCharm, Android Studio or GoLand\n" +
    42  		"   Open the IDE 👉 Install the JFrog extension or plugin 👉 View the JFrog panel"
    43  	MessageDockerDesktop = "📦 Open Docker Desktop and install the JFrog Extension to scan any of your \n" +
    44  		"   local docker images"
    45  	MessageDockerScan = "📦 Scan local Docker images from the terminal by running\n" +
    46  		"   jf docker scan <image name>:<image tag>"
    47  )
    48  
    49  type EnvSetupCommand struct {
    50  	registrationURL string
    51  	// In case encodedConnectionDetails were provided - we have a registered user that was invited to the platform.
    52  	encodedConnectionDetails string
    53  	id                       uuid.UUID
    54  	serverDetails            *config.ServerDetails
    55  	progress                 ioUtils.ProgressMgr
    56  	outputFormat             OutputFormat
    57  }
    58  
    59  func (ftc *EnvSetupCommand) SetRegistrationURL(registrationURL string) *EnvSetupCommand {
    60  	ftc.registrationURL = registrationURL
    61  	return ftc
    62  }
    63  
    64  func (ftc *EnvSetupCommand) SetEncodedConnectionDetails(encodedConnectionDetails string) *EnvSetupCommand {
    65  	ftc.encodedConnectionDetails = encodedConnectionDetails
    66  	return ftc
    67  }
    68  
    69  func (ftc *EnvSetupCommand) ServerDetails() (*config.ServerDetails, error) {
    70  	return nil, nil
    71  }
    72  
    73  func (ftc *EnvSetupCommand) SetProgress(progress ioUtils.ProgressMgr) {
    74  	ftc.progress = progress
    75  }
    76  
    77  func (ftc *EnvSetupCommand) SetOutputFormat(format OutputFormat) *EnvSetupCommand {
    78  	ftc.outputFormat = format
    79  	return ftc
    80  }
    81  
    82  func NewEnvSetupCommand() *EnvSetupCommand {
    83  	return &EnvSetupCommand{
    84  		id: uuid.New(),
    85  	}
    86  }
    87  
    88  // This function is a wrapper around the 'ftc.progress.SetHeadlineMsg(msg)' API,
    89  // to make sure that ftc.progress isn't nil. It can be nil in case the CI environment variable is set.
    90  // In case ftc.progress is nil, the message sent will be prompted to the screen
    91  // without the progress indication.
    92  func (ftc *EnvSetupCommand) setHeadlineMsg(msg string) {
    93  	if ftc.progress != nil {
    94  		ftc.progress.SetHeadlineMsg(msg)
    95  	} else {
    96  		log.Output(msg + "...")
    97  	}
    98  }
    99  
   100  // This function is a wrapper around the 'ftc.progress.clearHeadlineMsg()' API,
   101  // to make sure that ftc.progress isn't nil before clearing it.
   102  // It can be nil in case the CI environment variable is set.
   103  func (ftc *EnvSetupCommand) clearHeadlineMsg() {
   104  	if ftc.progress != nil {
   105  		ftc.progress.ClearHeadlineMsg()
   106  	}
   107  }
   108  
   109  // This function is a wrapper around the 'ftc.progress.Quit()' API,
   110  // to make sure that ftc.progress isn't nil before clearing it.
   111  // It can be nil in case the CI environment variable is set.
   112  func (ftc *EnvSetupCommand) quitProgress() error {
   113  	if ftc.progress != nil {
   114  		return ftc.progress.Quit()
   115  	}
   116  	return nil
   117  }
   118  
   119  func (ftc *EnvSetupCommand) Run() (err error) {
   120  	err = ftc.SetupAndConfigServer()
   121  	if err != nil {
   122  		return
   123  	}
   124  	if ftc.outputFormat == Human {
   125  		// Closes the progress manger and reset the log prints.
   126  		err = ftc.quitProgress()
   127  		if err != nil {
   128  			return
   129  		}
   130  		log.Output()
   131  		log.Output(coreutils.PrintBold("Congrats! You're all set"))
   132  		log.Output("So what's next?")
   133  		message :=
   134  			coreutils.PrintTitle("IDE") + "\n" +
   135  				MessageIdes + "\n\n" +
   136  				coreutils.PrintTitle("Docker") + "\n" +
   137  				"You can scan your local Docker images from the terminal or the Docker Desktop UI\n" +
   138  				MessageDockerScan + "\n" +
   139  				MessageDockerDesktop + "\n\n" +
   140  				coreutils.PrintTitle("Build, scan & deploy") + "\n" +
   141  				"1. 'cd' into your code project directory\n" +
   142  				"2. Run 'jf project init'\n\n" +
   143  				coreutils.PrintTitle("Read more") + "\n" +
   144  				"📦 Read more about how to get started at -\n" +
   145  				"   " + coreutils.PrintLink(coreutils.GettingStartedGuideUrl)
   146  		err = coreutils.PrintTable("", "", message, false)
   147  		if err != nil {
   148  			return
   149  		}
   150  		if ftc.encodedConnectionDetails == "" {
   151  			log.Output(coreutils.PrintBold("Important"))
   152  			log.Output("Please use the email we've just sent you, to verify your email address during the next 48 hours.\n")
   153  		}
   154  	}
   155  	return
   156  }
   157  
   158  func (ftc *EnvSetupCommand) SetupAndConfigServer() (err error) {
   159  	var server *config.ServerDetails
   160  	// If credentials were provided, this means that the user was invited to join an existing JFrog environment.
   161  	// Otherwise, this is a brand-new user, that needs to register and set up a new JFrog environment.
   162  	if ftc.encodedConnectionDetails == "" {
   163  		server, err = ftc.setupNewUser()
   164  	} else {
   165  		server, err = ftc.setupExistingUser()
   166  	}
   167  	if err != nil {
   168  		return
   169  	}
   170  	err = general.ConfigServerWithDeducedId(server, false, false)
   171  	return
   172  }
   173  
   174  func (ftc *EnvSetupCommand) setupNewUser() (server *config.ServerDetails, err error) {
   175  	if ftc.outputFormat == Human {
   176  		ftc.setHeadlineMsg("Just fill out its details in your browser 📝")
   177  		time.Sleep(8 * time.Second)
   178  	} else {
   179  		// Closes the progress manger and reset the log prints.
   180  		err = ftc.quitProgress()
   181  		if err != nil {
   182  			return
   183  		}
   184  	}
   185  	err = browser.OpenURL(ftc.registrationURL + "?id=" + ftc.id.String())
   186  	if err != nil {
   187  		return
   188  	}
   189  	server, err = ftc.getNewServerDetails()
   190  	return
   191  }
   192  
   193  func (ftc *EnvSetupCommand) setupExistingUser() (server *config.ServerDetails, err error) {
   194  	err = ftc.quitProgress()
   195  	if err != nil {
   196  		return
   197  	}
   198  	server, err = ftc.decodeConnectionDetails()
   199  	if err != nil {
   200  		return
   201  	}
   202  	if server.Url == "" {
   203  		err = errorutils.CheckErrorf("The response from JFrog Access does not include a JFrog environment URL")
   204  		return
   205  	}
   206  	if server.AccessToken != "" {
   207  		// If the server details received from JFrog Access include an access token, this access token is
   208  		// short-lived, and we therefore need to replace it with a new long-lived access token, and configure
   209  		// JFrog CLI with it.
   210  		err = GenerateNewLongTermRefreshableAccessToken(server)
   211  		return
   212  	}
   213  	if server.User == "" {
   214  		err = errorutils.CheckErrorf("The response from JFrog Access does not includes a username or access token")
   215  		return
   216  	}
   217  	// Url and accessToken/userName must be provided in the base64 encoded connection details.
   218  	// APIkey/password are optional - In case they were not provided user can enter his password on console.
   219  	// Password will be validated before the config command is being called.
   220  	if server.Password == "" {
   221  		err = ftc.scanAndValidateJFrogPasswordFromConsole(server)
   222  	}
   223  	return
   224  }
   225  
   226  func (ftc *EnvSetupCommand) scanAndValidateJFrogPasswordFromConsole(server *config.ServerDetails) (err error) {
   227  	// User has limited number of retries to enter his correct password.
   228  	// Password validation is operated by Artifactory ping API.
   229  	server.ArtifactoryUrl = clientUtils.AddTrailingSlashIfNeeded(server.Url) + "artifactory/"
   230  	for i := 0; i < enterPasswordMaxRetries; i++ {
   231  		server.Password, err = ioutils.ScanJFrogPasswordFromConsole()
   232  		if err != nil {
   233  			return
   234  		}
   235  		// Validate correct password by using Artifactory ping API.
   236  		pingCmd := generic.NewPingCommand().SetServerDetails(server)
   237  		err = commands.Exec(pingCmd)
   238  		if err == nil {
   239  			// No error while encrypting password => correct password.
   240  			return nil
   241  		}
   242  		log.Output(err.Error())
   243  	}
   244  	err = errorutils.CheckErrorf("bad credentials: Wrong password. ")
   245  	return
   246  }
   247  
   248  // Take the short-lived token and generate a long term (1 year expiry) refreshable accessToken.
   249  func GenerateNewLongTermRefreshableAccessToken(server *config.ServerDetails) (err error) {
   250  	accessManager, err := utils.CreateAccessServiceManager(server, false)
   251  	if err != nil {
   252  		return
   253  	}
   254  	// Create refreshable accessToken with 1 year expiry from the given short expiry token.
   255  	params := createLongExpirationRefreshableTokenParams()
   256  	token, err := accessManager.CreateAccessToken(*params)
   257  	if err != nil {
   258  		return
   259  	}
   260  	server.AccessToken = token.AccessToken
   261  	server.RefreshToken = token.RefreshToken
   262  	return
   263  }
   264  
   265  func createLongExpirationRefreshableTokenParams() *services.CreateTokenParams {
   266  	params := services.CreateTokenParams{}
   267  	// Using the platform's default expiration (1 year by default).
   268  	params.ExpiresIn = nil
   269  	params.Refreshable = clientUtils.Pointer(true)
   270  	params.Audience = "*@*"
   271  	return &params
   272  }
   273  
   274  func (ftc *EnvSetupCommand) decodeConnectionDetails() (server *config.ServerDetails, err error) {
   275  	rawDecodedText, err := base64.StdEncoding.DecodeString(ftc.encodedConnectionDetails)
   276  	if errorutils.CheckError(err) != nil {
   277  		return
   278  	}
   279  	err = json.Unmarshal(rawDecodedText, &server)
   280  	if errorutils.CheckError(err) != nil {
   281  		return nil, err
   282  	}
   283  	return
   284  }
   285  
   286  func (ftc *EnvSetupCommand) CommandName() string {
   287  	return "setup"
   288  }
   289  
   290  // Returns the new server details from My-JFrog
   291  func (ftc *EnvSetupCommand) getNewServerDetails() (serverDetails *config.ServerDetails, err error) {
   292  	requestBody := &myJfrogGetStatusRequest{CliRegistrationId: ftc.id.String()}
   293  	requestContent, err := json.Marshal(requestBody)
   294  	if errorutils.CheckError(err) != nil {
   295  		return nil, err
   296  	}
   297  
   298  	httpClientDetails := httputils.HttpClientDetails{
   299  		Headers: map[string]string{"Content-Type": "application/json"},
   300  	}
   301  	client, err := httpclient.ClientBuilder().Build()
   302  	if err != nil {
   303  		return nil, err
   304  	}
   305  
   306  	// Define the MyJFrog polling logic.
   307  	pollingMessage := fmt.Sprintf("Sync: Get MyJFrog status report. Request ID:%s...", ftc.id)
   308  	pollingErrorMessage := "Sync: Get MyJFrog status request failed. Attempt: %d. Error: %s"
   309  	// The max consecutive polling errors allowed, before completely failing the setup action.
   310  	const maxConsecutiveErrors = 6
   311  	errorsCount := 0
   312  	readyMessageDisplayed := false
   313  	pollingAction := func() (shouldStop bool, responseBody []byte, err error) {
   314  		log.Debug(pollingMessage)
   315  		// Send request to MyJFrog.
   316  		resp, body, err := client.SendPost(myJfrogEndPoint, requestContent, httpClientDetails, "")
   317  		// If an HTTP error occurred.
   318  		if err != nil {
   319  			errorsCount++
   320  			log.Debug(fmt.Sprintf(pollingErrorMessage, errorsCount, err.Error()))
   321  			if errorsCount == maxConsecutiveErrors {
   322  				return true, nil, err
   323  			}
   324  			return false, nil, nil
   325  		}
   326  		// If the response is not the expected 200 or 404.
   327  		if err = errorutils.CheckResponseStatusWithBody(resp, body, http.StatusOK, http.StatusNotFound); err != nil {
   328  			errorsCount++
   329  			log.Debug(fmt.Sprintf(pollingErrorMessage, errorsCount, err.Error()))
   330  			if errorsCount == maxConsecutiveErrors {
   331  				return true, nil, err
   332  			}
   333  			return false, nil, nil
   334  		}
   335  		errorsCount = 0
   336  
   337  		// Wait for 'ready=true' response from MyJFrog
   338  		if resp.StatusCode == http.StatusOK {
   339  			if !readyMessageDisplayed {
   340  				if ftc.outputFormat == Machine {
   341  					log.Output("PREPARING_ENV")
   342  				} else {
   343  					ftc.clearHeadlineMsg()
   344  					ftc.setHeadlineMsg("Almost done! Please hang on while JFrog CLI completes the setup 🛠")
   345  				}
   346  				readyMessageDisplayed = true
   347  			}
   348  			statusResponse := myJfrogGetStatusResponse{}
   349  			if err = json.Unmarshal(body, &statusResponse); err != nil {
   350  				return true, nil, err
   351  			}
   352  			// Got the new server details
   353  			if statusResponse.Ready {
   354  				return true, body, nil
   355  			}
   356  		}
   357  		// The expected 404 response or 200 response without 'Ready'
   358  		return false, nil, nil
   359  	}
   360  
   361  	pollingExecutor := &httputils.PollingExecutor{
   362  		Timeout:         maxWaitMinutes,
   363  		PollingInterval: syncSleepInterval,
   364  		PollingAction:   pollingAction,
   365  	}
   366  
   367  	body, err := pollingExecutor.Execute()
   368  	if err != nil {
   369  		return nil, err
   370  	}
   371  	statusResponse := myJfrogGetStatusResponse{}
   372  	if err = json.Unmarshal(body, &statusResponse); err != nil {
   373  		return nil, errorutils.CheckError(err)
   374  	}
   375  	ftc.clearHeadlineMsg()
   376  	serverDetails = &config.ServerDetails{
   377  		Url:         statusResponse.PlatformUrl,
   378  		AccessToken: statusResponse.AccessToken,
   379  	}
   380  	ftc.serverDetails = serverDetails
   381  	return serverDetails, nil
   382  }
   383  
   384  type myJfrogGetStatusRequest struct {
   385  	CliRegistrationId string `json:"cliRegistrationId,omitempty"`
   386  }
   387  
   388  type myJfrogGetStatusResponse struct {
   389  	CliRegistrationId string `json:"cliRegistrationId,omitempty"`
   390  	Ready             bool   `json:"ready,omitempty"`
   391  	AccessToken       string `json:"accessToken,omitempty"`
   392  	PlatformUrl       string `json:"platformUrl,omitempty"`
   393  }