cmd/cloudshell_open/main.go (537 lines of code) (raw):

// Copyright 2019 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "bufio" "context" "errors" "flag" "fmt" "os" "path/filepath" "strings" "time" "github.com/AlecAivazis/survey/v2" "github.com/GoogleCloudPlatform/cloud-run-button/cmd/instrumentless" "google.golang.org/api/transport" "cloud.google.com/go/compute/metadata" "github.com/briandowns/spinner" "github.com/fatih/color" ) const ( flRepoURL = "repo_url" flGitBranch = "git_branch" flSubDir = "dir" flPage = "page" flForceNewClone = "force_new_clone" flContext = "context" reauthCredentialsWaitTimeout = time.Minute * 2 reauthCredentialsPollingInterval = time.Second billingCreateURL = "https://console.cloud.google.com/billing/create" trygcpURL = "https://console.cloud.google.com/trygcp" instrumentlessEvent = "crbutton" artifactRegistry = "cloud-run-source-deploy" ) var ( linkLabel = color.New(color.Bold, color.Underline) parameterLabel = color.New(color.FgHiYellow, color.Bold, color.Underline) errorLabel = color.New(color.FgRed, color.Bold) warningLabel = color.New(color.Bold, color.FgHiYellow) successLabel = color.New(color.Bold, color.FgGreen) successPrefix = fmt.Sprintf("[ %s ]", successLabel.Sprint("✓")) errorPrefix = fmt.Sprintf("[ %s ]", errorLabel.Sprint("✖")) infoPrefix = fmt.Sprintf("[ %s ]", warningLabel.Sprint("!")) // we have to reset the inherited color first from survey.QuestionIcon // see https://github.com/AlecAivazis/survey/issues/193 questionPrefix = fmt.Sprintf("%s %s ]", color.New(color.Reset).Sprint("["), color.New(color.Bold, color.FgYellow).Sprint("?")) questionSelectFocusIcon = "❯" opts runOpts flags = flag.NewFlagSet("cloudshell_open", flag.ContinueOnError) ) func init() { flags.StringVar(&opts.repoURL, flRepoURL, "", "url to git repo") flags.StringVar(&opts.gitBranch, flGitBranch, "", "(optional) branch/revision to use from the git repo") flags.StringVar(&opts.subDir, flSubDir, "", "(optional) sub-directory to deploy in the repo") flags.StringVar(&opts.context, flContext, "", "(optional) arbitrary context") _ = flags.String(flPage, "", "ignored") _ = flags.Bool(flForceNewClone, false, "ignored") } func main() { usage := flags.Usage flags.Usage = func() {} // control when we print usage string if err := flags.Parse(os.Args[1:]); err != nil { if err == flag.ErrHelp { usage() return } else { fmt.Printf("%s flag parsing issue: %+v\n", warningLabel.Sprint("internal warning:"), err) } } if err := run(opts); err != nil { fmt.Printf("%s %+v\n", errorLabel.Sprint("Error:"), err) os.Exit(1) } } type runOpts struct { repoURL string gitBranch string subDir string context string } func logProgress(msg, endMsg, errMsg string) func(bool) { s := spinner.New(spinner.CharSets[9], 300*time.Millisecond) s.Prefix = "[ " s.Suffix = " ] " + msg s.Start() return func(success bool) { s.Stop() if success { if endMsg != "" { fmt.Printf("%s %s\n", successPrefix, endMsg) } } else { fmt.Printf("%s %s\n", errorPrefix, errMsg) } } } func run(opts runOpts) error { ctx := context.Background() highlight := func(s string) string { return color.CyanString(s) } parameter := func(s string) string { return parameterLabel.Sprint(s) } cmdColor := color.New(color.FgHiBlue) repo := opts.repoURL if repo == "" { return fmt.Errorf("--%s not specified", flRepoURL) } trusted := os.Getenv("TRUSTED_ENVIRONMENT") == "true" if !trusted { fmt.Printf("%s You launched this custom Cloud Shell image as \"Do not trust\".\n"+ "In this mode, your credentials are not available and this experience\n"+ "cannot deploy to Cloud Run. Start over and \"Trust\" the image.\n", errorLabel.Sprint("Error:")) return errors.New("aborting due to untrusted cloud shell environment") } end := logProgress("Waiting for Cloud Shell authorization...", "", "Failed to get GCP credentials. Please authorize Cloud Shell if you're presented with a prompt.", ) time.Sleep(time.Second * 2) waitCtx, cancelWait := context.WithTimeout(ctx, reauthCredentialsWaitTimeout) err := waitCredsAvailable(waitCtx, reauthCredentialsPollingInterval) cancelWait() end(err == nil) if err != nil { return err } end = logProgress(fmt.Sprintf("Cloning git repository %s...", highlight(repo)), fmt.Sprintf("Cloned git repository %s.", highlight(repo)), fmt.Sprintf("Failed to clone git repository %s", highlight(repo))) cloneDir, err := handleRepo(repo) if trusted && os.Getenv("SKIP_CLONE_REPORTING") == "" { // TODO(ahmetb) had to introduce SKIP_CLONE_REPORTING env var here // to skip connecting to :8998 while testing locally if this var is set. if err := signalRepoCloneStatus(err == nil); err != nil { return err } } end(err == nil) if err != nil { return err } if opts.gitBranch != "" { if err := gitCheckout(cloneDir, opts.gitBranch); err != nil { return fmt.Errorf("failed to checkout revision %q: %+v", opts.gitBranch, err) } } appDir := cloneDir if opts.subDir != "" { // verify if --dir is valid appDir = filepath.Join(cloneDir, opts.subDir) if fi, err := os.Stat(appDir); err != nil { if os.IsNotExist(err) { return fmt.Errorf("sub-directory doesn't exist in the cloned repository: %s", appDir) } return fmt.Errorf("failed to check sub-directory in the repo: %v", err) } else if !fi.IsDir() { return fmt.Errorf("specified sub-directory path %s is not a directory", appDir) } } appFile, err := getAppFile(appDir) if err != nil { return fmt.Errorf("error attempting to read the app.json from the cloned repository: %+v", err) } project := os.Getenv("GOOGLE_CLOUD_PROJECT") for project == "" { var projects []string for len(projects) == 0 { end = logProgress("Retrieving your projects...", "Queried list of your projects", "Failed to retrieve your projects.", ) projects, err = listProjects() end(err == nil) if err != nil { return err } if len(projects) == 0 { fmt.Print(errorPrefix + " " + warningLabel.Sprint("You don't have any projects to deploy into.")) } } if len(projects) > 1 { fmt.Printf(successPrefix+" Found %s projects in your GCP account.\n", successLabel.Sprintf("%d", len(projects))) } project, err = promptProject(projects) if err != nil { fmt.Println(errorPrefix + " " + warningLabel.Sprint("You need to create a project")) err := promptInstrumentless() if err != nil { return err } } } if err := waitForBilling(project, func(p string) error { projectLabel := color.New(color.Bold, color.FgHiCyan).Sprint(project) fmt.Println(fmt.Sprintf(errorPrefix+" Project %s does not have an active billing account!", projectLabel)) billingAccounts, err := billingAccounts() if err != nil { return fmt.Errorf("could not get billing accounts: %v", err) } useExisting := false if len(billingAccounts) > 0 { useExisting, err = prompUseExistingBillingAccount(project) if err != nil { return err } } if !useExisting { err := promptInstrumentless() if err != nil { return err } } fmt.Println(infoPrefix + " Link the billing account to the project:" + "\n " + linkLabel.Sprintf("https://console.cloud.google.com/billing?project=%s", project)) fmt.Println(questionPrefix + " " + "Once you're done, press " + parameterLabel.Sprint("Enter") + " to continue: ") if _, err := bufio.NewReader(os.Stdin).ReadBytes('\n'); err != nil { return err } // TODO(jamesward) automatically set billing account on project return nil }); err != nil { return err } end = logProgress( fmt.Sprintf("Enabling Cloud Run API on project %s...", highlight(project)), fmt.Sprintf("Enabled Cloud Run API on project %s.", highlight(project)), fmt.Sprintf("Failed to enable required APIs on project %s.", highlight(project))) err = enableAPIs(project, []string{"run.googleapis.com", "artifactregistry.googleapis.com"}) end(err == nil) if err != nil { return err } region := os.Getenv("GOOGLE_CLOUD_REGION") if region == "" { region, err = promptDeploymentRegion(ctx, project) if err != nil { return err } } end = logProgress( fmt.Sprintf("Setting up %s in region %s (if it doesn't already exist)", highlight(artifactRegistry), highlight(region)), fmt.Sprintf("Set up %s in region %s (if it doesn't already exist)", highlight(artifactRegistry), highlight(region)), "Failed to setup artifact registry.") err = createArtifactRegistry(project, region, artifactRegistry) end(err == nil) if err != nil { return err } repoName := filepath.Base(appDir) serviceName := repoName if appFile.Name != "" { serviceName = appFile.Name } serviceName, err = tryFixServiceName(serviceName) if err != nil { return err } image := fmt.Sprintf("%s-docker.pkg.dev/%s/%s/%s", region, project, artifactRegistry, serviceName) existingEnvVars := make(map[string]struct{}) // todo(jamesward) actually determine if the service exists instead of assuming it doesn't if we get an error existingService, err := getService(project, serviceName, region) if err == nil { // service exists existingEnvVars, err = envVars(project, serviceName, region) } neededEnvs := needEnvs(appFile.Env, existingEnvVars) envs, err := promptOrGenerateEnvs(neededEnvs) if err != nil { return err } projectEnv := fmt.Sprintf("GOOGLE_CLOUD_PROJECT=%s", project) regionEnv := fmt.Sprintf("GOOGLE_CLOUD_REGION=%s", region) serviceEnv := fmt.Sprintf("K_SERVICE=%s", serviceName) imageEnv := fmt.Sprintf("IMAGE_URL=%s", image) appDirEnv := fmt.Sprintf("APP_DIR=%s", appDir) inheritedEnv := os.Environ() hookEnvs := append([]string{projectEnv, regionEnv, serviceEnv, imageEnv, appDirEnv}, envs...) for key, value := range existingEnvVars { hookEnvs = append(hookEnvs, fmt.Sprintf("%s=%s", key, value)) } hookEnvs = append(hookEnvs, inheritedEnv...) pushImage := true if appFile.Hooks.PreBuild.Commands != nil { err = runScripts(appDir, appFile.Hooks.PreBuild.Commands, hookEnvs) } skipBuild := appFile.Build.Skip != nil && *appFile.Build.Skip == true skipDocker := false skipJib := false builderImage := "gcr.io/buildpacks/builder:v1" if appFile.Build.Buildpacks.Builder != "" { skipDocker = true skipJib = true builderImage = appFile.Build.Buildpacks.Builder } dockerFileExists, _ := dockerFileExists(appDir) jibMaven, _ := jibMavenConfigured(appDir) if skipBuild { fmt.Println(infoPrefix + " Skipping built-in build methods") } else { end = logProgress(fmt.Sprintf("Building container image %s", highlight(image)), fmt.Sprintf("Built container image %s", highlight(image)), "Failed to build container image.") if !skipDocker && dockerFileExists { fmt.Println(infoPrefix + " Attempting to build this application with its Dockerfile...") fmt.Println(infoPrefix + " FYI, running the following command:") cmdColor.Printf("\tdocker build -t %s %s\n", parameter(image), parameter(appDir)) err = dockerBuild(appDir, image) } else if !skipJib && jibMaven { pushImage = false fmt.Println(infoPrefix + " Attempting to build this application with Jib Maven plugin...") fmt.Println(infoPrefix + " FYI, running the following command:") cmdColor.Printf("\tmvn package jib:build -Dimage=%s\n", parameter(image)) err = jibMavenBuild(appDir, image) } else { fmt.Println(infoPrefix + " Attempting to build this application with Cloud Native Buildpacks (buildpacks.io)...") fmt.Println(infoPrefix + " FYI, running the following command:") cmdColor.Printf("\tpack build %s --path %s --builder %s\n", parameter(image), parameter(appDir), parameter(builderImage)) err = packBuild(appDir, image, builderImage) } end(err == nil) if err != nil { return fmt.Errorf("attempted to build and failed: %s", err) } } if appFile.Hooks.PostBuild.Commands != nil { err = runScripts(appDir, appFile.Hooks.PostBuild.Commands, hookEnvs) } if pushImage { fmt.Println(infoPrefix + " FYI, running the following command:") cmdColor.Printf("\tdocker push %s\n", parameter(image)) end = logProgress("Pushing container image...", "Pushed container image to Google Container Registry.", "Failed to push container image to Google Container Registry.") err = dockerPush(image) end(err == nil) if err != nil { return fmt.Errorf("failed to push image to %s: %+v", image, err) } } if existingService == nil { err = runScripts(appDir, appFile.Hooks.PreCreate.Commands, hookEnvs) if err != nil { return err } } optionsFlags := optionsToFlags(appFile.Options) serviceLabel := highlight(serviceName) fmt.Println(infoPrefix + " FYI, running the following command:") cmdColor.Printf("\tgcloud run deploy %s", parameter(serviceName)) cmdColor.Println("\\") cmdColor.Printf("\t --project=%s", parameter(project)) cmdColor.Println("\\") cmdColor.Printf("\t --platform=%s", parameter("managed")) cmdColor.Println("\\") cmdColor.Printf("\t --region=%s", parameter(region)) cmdColor.Println("\\") cmdColor.Printf("\t --image=%s", parameter(image)) if appFile.Options.Port > 0 { cmdColor.Println("\\") cmdColor.Printf("\t --port=%s", parameter(fmt.Sprintf("%d", appFile.Options.Port))) } if len(envs) > 0 { cmdColor.Println("\\") cmdColor.Printf("\t --update-env-vars=%s", parameter(strings.Join(envs, ","))) } for _, optionFlag := range optionsFlags { cmdColor.Println("\\") cmdColor.Printf("\t %s", optionFlag) } cmdColor.Println("") end = logProgress(fmt.Sprintf("Deploying service %s to Cloud Run...", serviceLabel), fmt.Sprintf("Successfully deployed service %s to Cloud Run.", serviceLabel), "Failed deploying the application to Cloud Run.") url, err := deploy(project, serviceName, image, region, envs, appFile.Options) end(err == nil) if err != nil { return err } hookEnvs = append(hookEnvs, fmt.Sprintf("SERVICE_URL=%s", url)) if existingService == nil { err = runScripts(appDir, appFile.Hooks.PostCreate.Commands, hookEnvs) if err != nil { return err } } fmt.Printf("* This application is billed only when it's handling requests.\n") fmt.Printf("* Manage this application at Cloud Console:\n\t") color.New(color.Underline, color.Bold).Printf("https://console.cloud.google.com/run/detail/%s/%s?project=%s\n", region, serviceName, project) fmt.Printf("* Learn more about Cloud Run:\n\t") color.New(color.Underline, color.Bold).Println("https://cloud.google.com/run/docs") fmt.Printf(successPrefix+" %s%s\n", color.New(color.Bold).Sprint("Your application is now live here:\n\t"), color.New(color.Bold, color.FgGreen, color.Underline).Sprint(url)) return nil } func optionsToFlags(options options) []string { var flags []string authSetting := "--allow-unauthenticated" if options.AllowUnauthenticated != nil && *options.AllowUnauthenticated == false { authSetting = "--no-allow-unauthenticated" } flags = append(flags, authSetting) if options.Memory != "" { memorySetting := fmt.Sprintf("--memory=%s", options.Memory) flags = append(flags, memorySetting) } if options.CPU != "" { cpuSetting := fmt.Sprintf("--cpu=%s", options.CPU) flags = append(flags, cpuSetting) } if options.HTTP2 != nil { if *options.HTTP2 == false { flags = append(flags, "--no-use-http2") } else { flags = append(flags, "--use-http2") } } if options.Concurrency > 0 { concurrencySetting := fmt.Sprintf("--concurrency=%d", options.Concurrency) flags = append(flags, concurrencySetting) } if options.MaxInstances > 0 { maxInstancesSetting := fmt.Sprintf("--max-instances=%d", options.MaxInstances) flags = append(flags, maxInstancesSetting) } return flags } // waitCredsAvailable polls until Cloud Shell VM has available credentials. // Credentials might be missing in the environment for some GSuite users that // need to authenticate every N hours. See internal bug 154573156 for details. func waitCredsAvailable(ctx context.Context, pollInterval time.Duration) error { if os.Getenv("SKIP_GCE_CHECK") == "" && !metadata.OnGCE() { return nil } for { select { case <-ctx.Done(): err := ctx.Err() if err == context.DeadlineExceeded { return errors.New("credentials were not available in the VM, try re-authenticating if Cloud Shell presents an authentication prompt and click the button again") } return err default: v, err := metadata.Get("instance/service-accounts/") if err != nil { return fmt.Errorf("failed to query metadata service to see if credentials are present: %w", err) } if strings.TrimSpace(v) != "" { return nil } time.Sleep(pollInterval) } } } func waitForBilling(projectID string, prompt func(string) error) error { for { ok, err := checkBillingEnabled(projectID) if err != nil { return err } if ok { return nil } if err := prompt(projectID); err != nil { return err } } } // hasSubDirsInPATH determines if anything in PATH is a sub-directory of dir. func hasSubDirsInPATH(dir string) (bool, error) { path := os.Getenv("PATH") if path == "" { return false, errors.New("PATH is empty") } paths := strings.Split(path, string(os.PathListSeparator)) for _, p := range paths { ok, err := isSubPath(dir, p) if err != nil { return false, fmt.Errorf("failure assessing if paths are the same: %v", err) } if ok { return true, nil } } return false, nil } // isSubPath determines b is under a. Both paths are evaluated by computing their abs paths. func isSubPath(a, b string) (bool, error) { a, err := filepath.Abs(a) if err != nil { return false, fmt.Errorf("failed to get absolute path for %s: %+v", a, err) } b, err = filepath.Abs(b) if err != nil { return false, fmt.Errorf("failed to get absolute path for %s: %+v", b, err) } v, err := filepath.Rel(a, b) if err != nil { return false, fmt.Errorf("failed to calculate relative path: %v", err) } return !strings.HasPrefix(v, ".."+string(os.PathSeparator)), nil } func instrumentlessCoupon() (*instrumentless.Coupon, error) { ctx := context.TODO() creds, err := transport.Creds(ctx) if err != nil { return nil, fmt.Errorf("could not get user credentials: %v", err) } token, err := creds.TokenSource.Token() if err != nil { return nil, fmt.Errorf("could not get an auth token: %v", err) } return instrumentless.GetCoupon(instrumentlessEvent, token.AccessToken) } func promptInstrumentless() error { coupon, err := instrumentlessCoupon() if err != nil || coupon == nil { fmt.Println(infoPrefix + " Create a new billing account:") fmt.Println(" " + linkLabel.Sprint(billingCreateURL)) fmt.Println(questionPrefix + " " + "Once you're done, press " + parameterLabel.Sprint("Enter") + " to continue: ") if _, err := bufio.NewReader(os.Stdin).ReadBytes('\n'); err != nil { return err } return nil } code := "" parts := strings.Split(coupon.URL, "code=") if len(parts) == 2 { code = parts[1] } else { return fmt.Errorf("could not get a coupon code") } fmt.Println(infoPrefix + " Open this page:\n " + linkLabel.Sprint(trygcpURL)) fmt.Println(infoPrefix + " Use this coupon code:\n " + code) fmt.Println(questionPrefix + " Once you're done, press " + parameterLabel.Sprint("Enter") + " to continue: ") if _, err := bufio.NewReader(os.Stdin).ReadBytes('\n'); err != nil { return err } return nil } func prompUseExistingBillingAccount(project string) (bool, error) { useExisting := false projectLabel := color.New(color.Bold, color.FgHiCyan).Sprint(project) if err := survey.AskOne(&survey.Confirm{ Default: false, Message: fmt.Sprintf("Would you like to use an existing billing account with project %s?", projectLabel), }, &useExisting, surveyIconOpts); err != nil { return false, fmt.Errorf("could not prompt for confirmation %+v", err) } return useExisting, nil }