github.com/yourbase/yb@v0.7.1/cmd/yb/remote_build.go (about)

     1  package main
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/base64"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"io/ioutil"
    12  	"net/http"
    13  	"net/url"
    14  	"os"
    15  	"path"
    16  	"path/filepath"
    17  	"strconv"
    18  	"strings"
    19  	"time"
    20  
    21  	ggit "gg-scm.io/pkg/git"
    22  	"github.com/gobwas/ws"
    23  	"github.com/gobwas/ws/wsutil"
    24  	"github.com/johnewart/archiver"
    25  	"github.com/spf13/cobra"
    26  	"github.com/ulikunitz/xz"
    27  	"github.com/yourbase/commons/http/headers"
    28  	"github.com/yourbase/yb"
    29  	"github.com/yourbase/yb/internal/config"
    30  	"gopkg.in/src-d/go-git.v4"
    31  	gitplumbing "gopkg.in/src-d/go-git.v4/plumbing"
    32  	"gopkg.in/src-d/go-git.v4/plumbing/object"
    33  	"zombiezen.com/go/log"
    34  )
    35  
    36  type remoteCmd struct {
    37  	cfg            config.Getter
    38  	target         string
    39  	baseCommit     string
    40  	branch         string
    41  	patchData      []byte
    42  	patchPath      string
    43  	repoDir        string
    44  	noAcceleration bool
    45  	disableCache   bool
    46  	disableSkipper bool
    47  	dryRun         bool
    48  	committed      bool
    49  	publicRepo     bool
    50  	backupWorktree bool
    51  	remotes        []*url.URL
    52  }
    53  
    54  func newRemoteCmd(cfg config.Getter) *cobra.Command {
    55  	p := &remoteCmd{
    56  		cfg: cfg,
    57  	}
    58  	c := &cobra.Command{
    59  		Use:   "remotebuild [options] [TARGET]",
    60  		Short: "Build a target remotely",
    61  		Long: `Builds a target using YourBase infrastructure. If no argument is given, ` +
    62  			`uses the target named "` + yb.DefaultTarget + `", if there is one.` +
    63  			"\n\n" +
    64  			`yb remotebuild will search for the .yourbase.yml file in the current ` +
    65  			`directory and its parent directories. The target's commands will be run ` +
    66  			`in the directory the .yourbase.yml file appears in.`,
    67  		Args:                  cobra.MaximumNArgs(1),
    68  		DisableFlagsInUseLine: true,
    69  		SilenceErrors:         true,
    70  		SilenceUsage:          true,
    71  		RunE: func(cmd *cobra.Command, args []string) error {
    72  			p.target = yb.DefaultTarget
    73  			if len(args) > 0 {
    74  				p.target = args[0]
    75  			}
    76  			return p.run(cmd.Context())
    77  		},
    78  		ValidArgsFunction: func(cc *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    79  			if len(args) > 0 {
    80  				return nil, cobra.ShellCompDirectiveNoFileComp
    81  			}
    82  			return autocompleteTargetName(toComplete)
    83  		},
    84  	}
    85  	c.Flags().StringVar(&p.baseCommit, "base-commit", "", "Base commit hash as common ancestor")
    86  	c.Flags().StringVar(&p.branch, "branch", "", "Branch name")
    87  	c.Flags().StringVar(&p.patchPath, "patch-path", "", "Path to save the patch")
    88  	c.Flags().BoolVar(&p.noAcceleration, "no-accel", false, "Disable acceleration")
    89  	c.Flags().BoolVar(&p.disableCache, "disable-cache", false, "Disable cache acceleration")
    90  	c.Flags().BoolVar(&p.disableSkipper, "disable-skipper", false, "Disable skipping steps acceleration")
    91  	c.Flags().BoolVarP(&p.dryRun, "dry-run", "n", false, "Pretend to remote build")
    92  	c.Flags().BoolVar(&p.committed, "committed", false, "Only remote build committed changes")
    93  	c.Flags().BoolVar(&p.backupWorktree, "backup-worktree", false, "Saves uncommitted work into a tarball")
    94  	return c
    95  }
    96  
    97  func (p *remoteCmd) run(ctx context.Context) error {
    98  	targetPackage, _, err := findPackage()
    99  	if err != nil {
   100  		return err
   101  	}
   102  
   103  	target := targetPackage.Targets[p.target]
   104  	if target == nil {
   105  		return fmt.Errorf("%s: no such target (found: %s)", p.target, strings.Join(listTargetNames(targetPackage.Targets), ", "))
   106  	}
   107  
   108  	p.repoDir = targetPackage.Path
   109  	workRepo, err := git.PlainOpen(p.repoDir)
   110  
   111  	if err != nil {
   112  		return fmt.Errorf("opening repository %s: %w", p.repoDir, err)
   113  	}
   114  
   115  	g, err := ggit.New(ggit.Options{
   116  		Dir: targetPackage.Path,
   117  		LogHook: func(ctx context.Context, args []string) {
   118  			log.Debugf(ctx, "running git %s", strings.Join(args, " "))
   119  		},
   120  	})
   121  	if err != nil {
   122  		return err
   123  	}
   124  
   125  	// Show timing feedback and start tracking spent time
   126  	startTime := time.Now()
   127  
   128  	log.Infof(ctx, "Bootstrapping...")
   129  
   130  	list, err := workRepo.Remotes()
   131  
   132  	if err != nil {
   133  		return fmt.Errorf("getting remotes for %s: %w", p.repoDir, err)
   134  	}
   135  
   136  	var repoUrls []string
   137  
   138  	for _, r := range list {
   139  		c := r.Config()
   140  		repoUrls = append(repoUrls, c.URLs...)
   141  	}
   142  
   143  	project, err := p.fetchProject(ctx, repoUrls)
   144  	if err != nil {
   145  		return err
   146  	}
   147  
   148  	if project.Repository == "" {
   149  		projectURL, err := config.UIURL(p.cfg, fmt.Sprintf("%s/%s", project.OrgSlug, project.Label))
   150  		if err != nil {
   151  			return err
   152  		}
   153  
   154  		return fmt.Errorf("empty repository for project %s. Please check your project settings at %s", project.Label, projectURL)
   155  	}
   156  
   157  	// First things first:
   158  	// 1. Define correct branch name
   159  	// 2. Define common ancestor commit
   160  	// 3. Generate patch file
   161  	//    3.1. Comparing every local commits with the one upstream
   162  	//    3.2. Comparing every unstaged/untracked changes with the one upstream
   163  	//    3.3. Save the patch and compress it
   164  	// 4. Submit build!
   165  
   166  	ancestorRef, commitCount, branch, err := fastFindAncestor(ctx, workRepo)
   167  	if err != nil { // Error
   168  		return err
   169  	}
   170  	p.branch = branch
   171  	p.baseCommit = ancestorRef.String()
   172  
   173  	head, err := workRepo.Head()
   174  	if err != nil {
   175  		return fmt.Errorf("couldn't find HEAD commit: %w", err)
   176  	}
   177  	headCommit, err := workRepo.CommitObject(head.Hash())
   178  	if err != nil {
   179  		return fmt.Errorf("couldn't find HEAD commit: %w", err)
   180  	}
   181  	ancestorCommit, err := workRepo.CommitObject(ancestorRef)
   182  	if err != nil {
   183  		return fmt.Errorf("couldn't find merge-base commit: %w", err)
   184  	}
   185  
   186  	// Show feedback: end of bootstrap
   187  	endTime := time.Now()
   188  	bootTime := endTime.Sub(startTime)
   189  	log.Infof(ctx, "Bootstrap finished at %s, taking %s", endTime.Format(longTimeFormat), bootTime.Truncate(time.Millisecond))
   190  
   191  	// Process patches
   192  	startTime = time.Now()
   193  	pGenerationChan := make(chan bool)
   194  	if p.committed && headCommit.Hash.String() != p.baseCommit {
   195  		log.Infof(ctx, "Generating patch for %d commits...", commitCount)
   196  
   197  		patch, err := ancestorCommit.Patch(headCommit)
   198  		if err != nil {
   199  			return fmt.Errorf("patch generation failed: %w", err)
   200  		}
   201  		// This is where the patch is actually generated see #278
   202  		go func(ch chan<- bool) {
   203  			log.Debugf(ctx, "Starting the actual patch generation...")
   204  			p.patchData = []byte(patch.String())
   205  			log.Debugf(ctx, "Patch generation finished, only committed changes")
   206  			ch <- true
   207  		}(pGenerationChan)
   208  	} else if !p.committed {
   209  		// Apply changes that weren't committed yet
   210  		worktree, err := workRepo.Worktree() // current worktree
   211  		if err != nil {
   212  			return fmt.Errorf("couldn't get current worktree: %w", err)
   213  		}
   214  
   215  		log.Infof(ctx, "Generating patch for local changes...")
   216  
   217  		// Save files before committing.
   218  		log.Debugf(ctx, "Start backing up the worktree-save")
   219  		saver, err := newWorktreeSave(targetPackage.Path, ggit.Hash(headCommit.Hash), p.backupWorktree)
   220  		if err != nil {
   221  			return err
   222  		}
   223  		if err := p.traverseChanges(ctx, g, saver); err != nil {
   224  			return err
   225  		}
   226  		resetDone := false
   227  		if err := saver.save(ctx); err != nil {
   228  			return err
   229  		}
   230  		defer func() {
   231  			if !resetDone {
   232  				log.Debugf(ctx, "Reset failed, restoring...")
   233  				if err := saver.restore(ctx); err != nil {
   234  					log.Errorf(ctx,
   235  						"Unable to restore kept files at %s: %v\n"+
   236  							"     Please consider unarchiving that package",
   237  						saver.saveFilePath(),
   238  						err)
   239  				}
   240  			}
   241  		}()
   242  
   243  		log.Debugf(ctx, "Committing temporary changes")
   244  		latest, err := commitTempChanges(worktree, headCommit)
   245  		if err != nil {
   246  			return fmt.Errorf("commit to temporary cloned repository failed: %w", err)
   247  		}
   248  
   249  		tempCommit, err := workRepo.CommitObject(latest)
   250  		if err != nil {
   251  			return fmt.Errorf("can't find commit %q: %w", latest, err)
   252  		}
   253  
   254  		log.Debugf(ctx, "Starting the actual patch generation...")
   255  		patch, err := ancestorCommit.Patch(tempCommit)
   256  		if err != nil {
   257  			return fmt.Errorf("patch generation failed: %w", err)
   258  		}
   259  
   260  		// This is where the patch is actually generated see #278
   261  		p.patchData = []byte(patch.String())
   262  		log.Debugf(ctx, "Actual patch generation finished")
   263  
   264  		log.Debugf(ctx, "Reseting worktree to previous state...")
   265  		// Reset back to HEAD
   266  		if err := worktree.Reset(&git.ResetOptions{
   267  			Commit: headCommit.Hash,
   268  		}); err != nil {
   269  			log.Errorf(ctx, "Unable to reset temporary commit: %v\n    Please try `git reset --hard HEAD~1`", err)
   270  		} else {
   271  			resetDone = true
   272  		}
   273  		log.Debugf(ctx, "Worktree reset done.")
   274  
   275  	}
   276  
   277  	// Show feedback: end of patch generation
   278  	endTime = time.Now()
   279  	patchTime := endTime.Sub(startTime)
   280  	log.Infof(ctx, "Patch finished at %s, taking %s", endTime.Format(longTimeFormat), patchTime.Truncate(time.Millisecond))
   281  	if len(p.patchPath) > 0 && len(p.patchData) > 0 {
   282  		if err := p.savePatch(); err != nil {
   283  			log.Warnf(ctx, "Unable to save copy of generated patch: %v", err)
   284  		}
   285  	}
   286  
   287  	if p.dryRun {
   288  		log.Infof(ctx, "Dry run ended, build not submitted")
   289  		return nil
   290  	}
   291  
   292  	if err := p.submitBuild(ctx, project, target.Tags); err != nil {
   293  		return fmt.Errorf("unable to submit build: %w", err)
   294  	}
   295  	return nil
   296  }
   297  
   298  func commitTempChanges(w *git.Worktree, c *object.Commit) (latest gitplumbing.Hash, err error) {
   299  	if w == nil || c == nil {
   300  		err = fmt.Errorf("Needs a worktree and a commit object")
   301  		return
   302  	}
   303  	latest, err = w.Commit(
   304  		"YourBase remote build",
   305  		&git.CommitOptions{
   306  			Author: &object.Signature{
   307  				Name:  c.Author.Name,
   308  				Email: c.Author.Email,
   309  				When:  time.Now(),
   310  			},
   311  		},
   312  	)
   313  	return
   314  }
   315  
   316  func (p *remoteCmd) traverseChanges(ctx context.Context, g *ggit.Git, saver *worktreeSave) error {
   317  	workTree, err := g.WorkTree(ctx)
   318  	if err != nil {
   319  		return fmt.Errorf("traverse changes: %w", err)
   320  	}
   321  	status, err := g.Status(ctx, ggit.StatusOptions{
   322  		DisableRenames: true,
   323  	})
   324  	if err != nil {
   325  		return fmt.Errorf("traverse changes: %w", err)
   326  	}
   327  
   328  	var addList []ggit.Pathspec
   329  	for _, ent := range status {
   330  		if ent.Code[1] == ' ' {
   331  			// If file is already staged, then skip.
   332  			continue
   333  		}
   334  		var err error
   335  		addList, err = findFilesToAdd(ctx, g, workTree, addList, ent.Name)
   336  		if err != nil {
   337  			return fmt.Errorf("traverse changes: %w", err)
   338  		}
   339  
   340  		if !ent.Code.IsMissing() { // No need to add deletion to the saver, right?
   341  			if err = saver.add(ctx, filepath.FromSlash(string(ent.Name))); err != nil {
   342  				return fmt.Errorf("traverse changes: %w", err)
   343  			}
   344  		}
   345  	}
   346  
   347  	err = g.Add(ctx, addList, ggit.AddOptions{
   348  		IncludeIgnored: true,
   349  	})
   350  	if err != nil {
   351  		return fmt.Errorf("traverse changes: %w", err)
   352  	}
   353  	return nil
   354  }
   355  
   356  // findFilesToAdd finds files to stage in Git, recursing into directories and
   357  // ignoring any non-text files.
   358  func findFilesToAdd(ctx context.Context, g *ggit.Git, workTree string, dst []ggit.Pathspec, file ggit.TopPath) ([]ggit.Pathspec, error) {
   359  	realPath := filepath.Join(workTree, filepath.FromSlash(string(file)))
   360  	fi, err := os.Stat(realPath)
   361  	if os.IsNotExist(err) {
   362  		return dst, nil
   363  	}
   364  	if err != nil {
   365  		return dst, fmt.Errorf("find files to git add: %w", err)
   366  	}
   367  
   368  	if !fi.IsDir() {
   369  		binary, err := isBinary(realPath)
   370  		if err != nil {
   371  			return dst, fmt.Errorf("find files to git add: %w", err)
   372  		}
   373  		log.Debugf(ctx, "%s is binary = %t", file, binary)
   374  		if binary {
   375  			log.Infof(ctx, "Skipping binary file %s", realPath)
   376  			return dst, nil
   377  		}
   378  		return append(dst, file.Pathspec()), nil
   379  	}
   380  
   381  	log.Debugf(ctx, "Added a dir, checking its contents: %s", file)
   382  	dir, err := ioutil.ReadDir(realPath)
   383  	if err != nil {
   384  		return dst, fmt.Errorf("find files to git add: %w", err)
   385  	}
   386  	for _, f := range dir {
   387  		var err error
   388  		dst, err = findFilesToAdd(ctx, g, workTree, dst, ggit.TopPath(path.Join(string(file), f.Name())))
   389  		if err != nil {
   390  			return dst, err
   391  		}
   392  	}
   393  	return dst, nil
   394  }
   395  
   396  // isBinary returns whether a file contains a NUL byte near the beginning of the file.
   397  func isBinary(filePath string) (bool, error) {
   398  	r, err := os.Open(filePath)
   399  	if err != nil {
   400  		return false, err
   401  	}
   402  	defer r.Close()
   403  
   404  	buf := make([]byte, 8000)
   405  	n, err := io.ReadFull(r, buf)
   406  	if err != nil {
   407  		// Ignore EOF, since it's fine for the file to be shorter than the buffer size.
   408  		// Otherwise, wrap the error. We don't fully stop the control flow here because
   409  		// we may still have read enough data to make a determination.
   410  		if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
   411  			err = nil
   412  		} else {
   413  			err = fmt.Errorf("check for binary: %w", err)
   414  		}
   415  	}
   416  	for _, b := range buf[:n] {
   417  		if b == 0 {
   418  			return true, err
   419  		}
   420  	}
   421  	return false, err
   422  }
   423  
   424  func postToAPI(cfg config.Getter, path string, formData url.Values) (*http.Response, error) {
   425  	userToken, err := config.UserToken(cfg)
   426  
   427  	if err != nil {
   428  		return nil, fmt.Errorf("Couldn't get user token: %v", err)
   429  	}
   430  
   431  	apiURL, err := config.APIURL(cfg, path)
   432  	if err != nil {
   433  		return nil, fmt.Errorf("Couldn't determine API URL: %v", err)
   434  	}
   435  	req := &http.Request{
   436  		Method: http.MethodPost,
   437  		URL:    apiURL,
   438  		Header: http.Header{
   439  			http.CanonicalHeaderKey("YB_API_TOKEN"): {userToken},
   440  			headers.ContentType:                     {"application/x-www-form-urlencoded"},
   441  		},
   442  		GetBody: func() (io.ReadCloser, error) {
   443  			return ioutil.NopCloser(strings.NewReader(formData.Encode())), nil
   444  		},
   445  	}
   446  	req.Body, _ = req.GetBody()
   447  	res, err := http.DefaultClient.Do(req)
   448  	if err != nil {
   449  		return nil, err
   450  	}
   451  
   452  	return res, nil
   453  }
   454  
   455  // buildIDFromLogURL returns the build ID in a build log WebSocket URL.
   456  //
   457  // TODO(ch2570): This should come from the API.
   458  func buildIDFromLogURL(u *url.URL) (string, error) {
   459  	// Pattern is /builds/ID/progress
   460  	const prefix = "/builds/"
   461  	const suffix = "/progress"
   462  	if !strings.HasPrefix(u.Path, prefix) || !strings.HasSuffix(u.Path, suffix) {
   463  		return "", fmt.Errorf("build ID for %v: unrecognized path", u)
   464  	}
   465  	id := u.Path[len(prefix) : len(u.Path)-len(suffix)]
   466  	if strings.ContainsRune(id, '/') {
   467  		return "", fmt.Errorf("build ID for %v: unrecognized path", u)
   468  	}
   469  	return id, nil
   470  }
   471  
   472  // An apiProject is a YourBase project as returned by the API.
   473  type apiProject struct {
   474  	ID          int    `json:"id"`
   475  	Label       string `json:"label"`
   476  	Description string `json:"description"`
   477  	Repository  string `json:"repository"`
   478  	OrgSlug     string `json:"organization_slug"`
   479  }
   480  
   481  func (p *remoteCmd) fetchProject(ctx context.Context, urls []string) (*apiProject, error) {
   482  	v := url.Values{}
   483  	fmt.Println()
   484  	log.Infof(ctx, "URLs used to search: %s", urls)
   485  
   486  	for _, u := range urls {
   487  		rem, err := ggit.ParseURL(u)
   488  		if err != nil {
   489  			log.Warnf(ctx, "Invalid remote %s (%v), ignoring", u, err)
   490  			continue
   491  		}
   492  		// We only support GitHub by now
   493  		// TODO create something more generic
   494  		if rem.Host != "github.com" {
   495  			log.Warnf(ctx, "Ignoring remote %s (only github.com supported)", u)
   496  			continue
   497  		}
   498  		p.remotes = append(p.remotes, rem)
   499  		v.Add("urls[]", u)
   500  	}
   501  	resp, err := postToAPI(p.cfg, "search/projects", v)
   502  	if err != nil {
   503  		return nil, fmt.Errorf("Couldn't lookup project on api server: %v", err)
   504  	}
   505  	defer resp.Body.Close()
   506  
   507  	if resp.StatusCode != http.StatusOK {
   508  		log.Debugf(ctx, "Build server returned HTTP Status %d", resp.StatusCode)
   509  		switch resp.StatusCode {
   510  		case http.StatusNonAuthoritativeInfo:
   511  			p.publicRepo = true
   512  		case http.StatusUnauthorized:
   513  			return nil, fmt.Errorf("Unauthorized, authentication failed.\nPlease `yb login` again.")
   514  		case http.StatusPreconditionFailed, http.StatusNotFound:
   515  			return nil, fmt.Errorf("Please verify if this private repository has %s installed.", config.GitHubAppURL())
   516  		default:
   517  			return nil, fmt.Errorf("This is us, not you, please try again in a few minutes.")
   518  		}
   519  	}
   520  
   521  	body, err := ioutil.ReadAll(resp.Body)
   522  	if err != nil {
   523  		return nil, err
   524  	}
   525  	project := new(apiProject)
   526  	err = json.Unmarshal(body, project)
   527  	if err != nil {
   528  		return nil, err
   529  	}
   530  	return project, nil
   531  }
   532  
   533  func (cmd *remoteCmd) savePatch() error {
   534  
   535  	err := ioutil.WriteFile(cmd.patchPath, cmd.patchData, 0644)
   536  
   537  	if err != nil {
   538  		return fmt.Errorf("Couldn't save a local patch file at: %s, because: %v", cmd.patchPath, err)
   539  	}
   540  
   541  	return nil
   542  }
   543  
   544  func (cmd *remoteCmd) submitBuild(ctx context.Context, project *apiProject, tagMap map[string]string) error {
   545  
   546  	startTime := time.Now()
   547  
   548  	userToken, err := config.UserToken(cmd.cfg)
   549  	if err != nil {
   550  		return err
   551  	}
   552  
   553  	patchBuffer := new(bytes.Buffer)
   554  	xzWriter, err := xz.NewWriter(patchBuffer)
   555  	if err != nil {
   556  		return fmt.Errorf("submit build: compress patch: %w", err)
   557  	}
   558  	if _, err := xzWriter.Write(cmd.patchData); err != nil {
   559  		return fmt.Errorf("submit build: compress patch: %w", err)
   560  	}
   561  	if err := xzWriter.Close(); err != nil {
   562  		return fmt.Errorf("submit build: compress patch: %w", err)
   563  	}
   564  
   565  	patchEncoded := base64.StdEncoding.EncodeToString(patchBuffer.Bytes())
   566  
   567  	formData := url.Values{
   568  		"project_id": {strconv.Itoa(project.ID)},
   569  		"repository": {project.Repository},
   570  		"api_key":    {userToken},
   571  		"target":     {cmd.target},
   572  		"patch_data": {patchEncoded},
   573  		"commit":     {cmd.baseCommit},
   574  		"branch":     {cmd.branch},
   575  	}
   576  
   577  	tags := make([]string, 0)
   578  	for k, v := range tagMap {
   579  		tags = append(tags, fmt.Sprintf("%s:%s", k, v))
   580  	}
   581  
   582  	for _, tag := range tags {
   583  		formData.Add("tags[]", tag)
   584  	}
   585  
   586  	if cmd.noAcceleration {
   587  		formData.Add("no-accel", "True")
   588  	}
   589  
   590  	if cmd.disableCache {
   591  		formData.Add("disable-cache", "True")
   592  	}
   593  
   594  	if cmd.disableSkipper {
   595  		formData.Add("disable-skipper", "True")
   596  	}
   597  
   598  	resp, err := postToAPI(cmd.cfg, "builds/cli", formData)
   599  	if err != nil {
   600  		return err
   601  	}
   602  
   603  	defer resp.Body.Close()
   604  	body, err := ioutil.ReadAll(resp.Body)
   605  	if err != nil {
   606  		return fmt.Errorf("Couldn't read response body: %s", err)
   607  	}
   608  	switch resp.StatusCode {
   609  	case 401:
   610  		return fmt.Errorf("Unauthorized, authentication failed.\nPlease `yb login` again.")
   611  	case 403:
   612  		if cmd.publicRepo {
   613  			return fmt.Errorf("This should not happen, please open a support inquery with YB")
   614  		} else {
   615  			return fmt.Errorf("Tried to build a private repository of a organization of which you're not part of.")
   616  		}
   617  	case 412:
   618  		// TODO Show helpful message with App URL to fix GH App installation issue
   619  		return fmt.Errorf("Please verify if this specific repo has %s installed", config.GitHubAppURL())
   620  	case 500:
   621  		return fmt.Errorf("Internal server error")
   622  	}
   623  	//Process simple response from the API
   624  	body = bytes.ReplaceAll(body, []byte(`"`), nil)
   625  	if i := bytes.IndexByte(body, '\n'); i != -1 {
   626  		body = body[:i]
   627  	}
   628  	logURL, err := url.Parse(string(body))
   629  	if err != nil {
   630  		return fmt.Errorf("server response: parse log URL: %w", err)
   631  	}
   632  	if logURL.Scheme != "ws" && logURL.Scheme != "wss" {
   633  		return fmt.Errorf("server response: parse log URL: unhandled scheme %q", logURL.Scheme)
   634  	}
   635  	// Construct UI URL to present to the user.
   636  	// Fine to proceed in the face of errors: this is displayed as a fallback if
   637  	// other things fail.
   638  	var uiURL *url.URL
   639  	if id, err := buildIDFromLogURL(logURL); err != nil {
   640  		log.Warnf(ctx, "Could not construct build link: %v", err)
   641  	} else {
   642  		uiURL, err = config.UIURL(cmd.cfg, "/"+project.OrgSlug+"/"+project.Label+"/builds/"+id)
   643  		if err != nil {
   644  			log.Warnf(ctx, "Could not construct build link: %v", err)
   645  		}
   646  	}
   647  
   648  	endTime := time.Now()
   649  	submitTime := endTime.Sub(startTime)
   650  	log.Infof(ctx, "Submission finished at %s, taking %s", endTime.Format(longTimeFormat), submitTime.Truncate(time.Millisecond))
   651  
   652  	startTime = time.Now()
   653  
   654  	conn, _, _, err := ws.DefaultDialer.Dial(context.Background(), logURL.String())
   655  	if err != nil {
   656  		return fmt.Errorf("Cannot connect: %v", err)
   657  	}
   658  	defer func() {
   659  		if err := conn.Close(); err != nil {
   660  			log.Debugf(ctx, "Cannot close: %v", err)
   661  		}
   662  	}()
   663  
   664  	buildSuccess := false
   665  	buildSetupFinished := false
   666  
   667  	for {
   668  		msg, control, err := wsutil.ReadServerData(conn)
   669  		if err != nil {
   670  			if err != io.EOF {
   671  				log.Debugf(ctx, "Unstable connection: %v", err)
   672  			} else {
   673  				if buildSuccess {
   674  					log.Infof(ctx, "Build Completed!")
   675  				} else {
   676  					log.Errorf(ctx, "Build failed or the connection was interrupted!")
   677  				}
   678  				if uiURL != nil {
   679  					log.Infof(ctx, "Build Log: %v", uiURL)
   680  				}
   681  				return nil
   682  			}
   683  		} else {
   684  			// TODO This depends on build agent output, try to structure this better
   685  			if control.IsData() && strings.Count(string(msg), "Streaming results from build") > 0 {
   686  				fmt.Println()
   687  			} else if control.IsData() && !buildSetupFinished && len(msg) > 0 {
   688  				buildSetupFinished = true
   689  				endTime := time.Now()
   690  				setupTime := endTime.Sub(startTime)
   691  				log.Infof(ctx, "Set up finished at %s, taking %s", endTime.Format(longTimeFormat), setupTime.Truncate(time.Millisecond))
   692  				if cmd.publicRepo {
   693  					log.Infof(ctx, "Building a public repository: '%s'", project.Repository)
   694  				}
   695  				if uiURL != nil {
   696  					log.Infof(ctx, "Build Log: %v", uiURL)
   697  				}
   698  			}
   699  			if !buildSuccess {
   700  				buildSuccess = strings.Count(string(msg), "-- BUILD SUCCEEDED --") > 0
   701  			}
   702  			os.Stdout.Write(msg)
   703  		}
   704  	}
   705  }
   706  
   707  type worktreeSave struct {
   708  	path  string
   709  	hash  ggit.Hash
   710  	files []string
   711  }
   712  
   713  func newWorktreeSave(path string, hash ggit.Hash, enabled bool) (*worktreeSave, error) {
   714  	if !enabled {
   715  		return nil, nil
   716  	}
   717  	if _, err := os.Lstat(path); os.IsNotExist(err) {
   718  		return nil, fmt.Errorf("save worktree state: %w", err)
   719  	}
   720  	return &worktreeSave{
   721  		path: path,
   722  		hash: hash,
   723  	}, nil
   724  }
   725  
   726  func (w *worktreeSave) hasFiles() bool {
   727  	return w != nil && len(w.files) > 0
   728  }
   729  
   730  func (w *worktreeSave) add(ctx context.Context, file string) error {
   731  	if w == nil {
   732  		return nil
   733  	}
   734  	fullPath := filepath.Join(w.path, file)
   735  	if _, err := os.Lstat(fullPath); os.IsNotExist(err) {
   736  		return fmt.Errorf("save worktree state: %w", err)
   737  	}
   738  	log.Debugf(ctx, "Saving %s to the tarball", file)
   739  	w.files = append(w.files, file)
   740  	return nil
   741  }
   742  
   743  func (w *worktreeSave) saveFilePath() string {
   744  	return filepath.Join(w.path, fmt.Sprintf(".yb-worktreesave-%v.tar", w.hash))
   745  }
   746  
   747  func (w *worktreeSave) save(ctx context.Context) error {
   748  	if !w.hasFiles() {
   749  		return nil
   750  	}
   751  	log.Debugf(ctx, "Saving a tarball with all the worktree changes made")
   752  	tar := archiver.Tar{
   753  		MkdirAll: true,
   754  	}
   755  	if err := tar.Archive(w.files, w.saveFilePath()); err != nil {
   756  		return fmt.Errorf("save worktree state: %w", err)
   757  	}
   758  	return nil
   759  }
   760  
   761  func (w *worktreeSave) restore(ctx context.Context) error {
   762  	if !w.hasFiles() {
   763  		return nil
   764  	}
   765  	log.Debugf(ctx, "Restoring the worktree tarball")
   766  	pkgFile := w.saveFilePath()
   767  	if _, err := os.Lstat(pkgFile); os.IsNotExist(err) {
   768  		return fmt.Errorf("restore worktree state: %w", err)
   769  	}
   770  	tar := archiver.Tar{OverwriteExisting: true}
   771  	if err := tar.Unarchive(pkgFile, w.path); err != nil {
   772  		return fmt.Errorf("restore worktree state: %w", err)
   773  	}
   774  	if err := os.Remove(pkgFile); err != nil {
   775  		log.Warnf(ctx, "Failed to clean up temporary worktree save: %v", err)
   776  	}
   777  	return nil
   778  }