github.com/ddev/ddev@v1.23.2-0.20240519125000-d824ffe36ff3/pkg/ddevapp/mutagen.go (about)

     1  package ddevapp
     2  
     3  import (
     4  	"bufio"
     5  	"embed"
     6  	"encoding/json"
     7  	"fmt"
     8  	"os"
     9  	osexec "os/exec"
    10  	"path"
    11  	"path/filepath"
    12  	"runtime"
    13  	"strings"
    14  	"time"
    15  	"unicode"
    16  
    17  	"github.com/ddev/ddev/pkg/archive"
    18  	"github.com/ddev/ddev/pkg/config/types"
    19  	"github.com/ddev/ddev/pkg/dockerutil"
    20  	"github.com/ddev/ddev/pkg/exec"
    21  	"github.com/ddev/ddev/pkg/fileutil"
    22  	"github.com/ddev/ddev/pkg/globalconfig"
    23  	"github.com/ddev/ddev/pkg/nodeps"
    24  	"github.com/ddev/ddev/pkg/output"
    25  	"github.com/ddev/ddev/pkg/util"
    26  	"github.com/ddev/ddev/pkg/version"
    27  	"github.com/ddev/ddev/pkg/versionconstants"
    28  	"github.com/pkg/errors"
    29  )
    30  
    31  const mutagenSignatureLabelName = `com.ddev.volume-signature`
    32  const mutagenConfigFileHashLabelName = `com.ddev.config-hash`
    33  
    34  // SetMutagenVolumeOwnership chowns the volume in use to the current user.
    35  // The Mutagen volume is mounted both in /var/www (where it gets used) and
    36  // also on /tmp/project_mutagen (where it can be chowned without accidentally hitting
    37  // lots of bind-mounted files).
    38  func SetMutagenVolumeOwnership(app *DdevApp) error {
    39  	// Make sure that if we have a volume mount it's got proper ownership
    40  	uidStr, gidStr, _ := util.GetContainerUIDGid()
    41  	util.Verbose("Chowning Mutagen Docker volume for user %s", uidStr)
    42  	_, _, err := app.Exec(
    43  		&ExecOpts{
    44  			Dir: "/tmp",
    45  			Cmd: fmt.Sprintf("sudo chown -R %s:%s /tmp/project_mutagen", uidStr, gidStr),
    46  		})
    47  	if err != nil {
    48  		util.Warning("Failed to chown Mutagen volume: %v", err)
    49  	}
    50  	util.Verbose("Done chowning Mutagen Docker volume; result=%v", err)
    51  
    52  	return err
    53  }
    54  
    55  // MutagenSyncName transforms a projectname string into
    56  // an acceptable mutagen sync "name"
    57  // See restrictions on sync name at https://mutagen.io/documentation/introduction/names-labels-identifiers
    58  // The input must be a valid DNS name (valid DDEV project name)
    59  func MutagenSyncName(name string) string {
    60  	name = strings.ReplaceAll(name, ".", "")
    61  	if len(name) > 0 && unicode.IsNumber(rune(name[0])) {
    62  		name = "a" + name
    63  	}
    64  	return name
    65  }
    66  
    67  // TerminateMutagenSync destroys a Mutagen sync session
    68  // It is not an error if the sync session does not exist
    69  func TerminateMutagenSync(app *DdevApp) error {
    70  	if !app.IsMutagenEnabled() {
    71  		return nil
    72  	}
    73  	syncName := MutagenSyncName(app.Name)
    74  	if MutagenSyncExists(app) {
    75  		_, err := exec.RunHostCommand(globalconfig.GetMutagenPath(), "sync", "terminate", syncName)
    76  		if err != nil {
    77  			return err
    78  		}
    79  		util.Debug("Terminated Mutagen sync session '%s'", syncName)
    80  	}
    81  	return nil
    82  }
    83  
    84  // PauseMutagenSync pauses a Mutagen sync session
    85  func PauseMutagenSync(app *DdevApp) error {
    86  	syncName := MutagenSyncName(app.Name)
    87  	if MutagenSyncExists(app) {
    88  		_, err := exec.RunHostCommand(globalconfig.GetMutagenPath(), "sync", "pause", syncName)
    89  		if err != nil {
    90  			return err
    91  		}
    92  		util.Debug("Paused Mutagen sync session '%s'", syncName)
    93  	}
    94  	return nil
    95  }
    96  
    97  // SyncAndPauseMutagenSession syncs and pauses a Mutagen sync session
    98  func SyncAndPauseMutagenSession(app *DdevApp) error {
    99  	if !app.IsMutagenEnabled() {
   100  		return nil
   101  	}
   102  	if app.Name == "" {
   103  		return fmt.Errorf("no app.Name provided to SyncAndPauseMutagenSession")
   104  	}
   105  	syncName := MutagenSyncName(app.Name)
   106  
   107  	projStatus, _ := app.SiteStatus()
   108  
   109  	if !MutagenSyncExists(app) {
   110  		return nil
   111  	}
   112  
   113  	mutagenStatus, shortResult, longResult, err := app.MutagenStatus()
   114  	if err != nil {
   115  		return fmt.Errorf("mutagenStatus failed, rv=%v, shortResult=%s, longResult=%s, err=%v", mutagenStatus, shortResult, longResult, err)
   116  	}
   117  
   118  	// We don't want to flush if the web container isn't running
   119  	// because mutagen flush will hang forever - disconnected
   120  	if projStatus == SiteRunning && (mutagenStatus == "ok" || mutagenStatus == "problems") {
   121  		err := app.MutagenSyncFlush()
   122  		if err != nil {
   123  			util.Error("Error on 'mutagen sync flush %s': %v", syncName, err)
   124  		}
   125  	}
   126  	err = PauseMutagenSync(app)
   127  	return err
   128  }
   129  
   130  // GetMutagenConfigFilePath returns the canonical location where the mutagen.yml lives
   131  func GetMutagenConfigFilePath(app *DdevApp) string {
   132  	return filepath.Join(app.GetConfigPath("mutagen"), "mutagen.yml")
   133  }
   134  
   135  // GetMutagenConfigFileHash returns the SHA1 hash of the mutagen.yml
   136  func GetMutagenConfigFileHash(app *DdevApp) (string, error) {
   137  	f := GetMutagenConfigFilePath(app)
   138  	hash, err := fileutil.FileHash(f)
   139  	if err != nil {
   140  		return "", err
   141  	}
   142  	return hash, nil
   143  }
   144  
   145  // GetMutagenConfigFile looks to see if there's a project .mutagen.yml
   146  // If nothing is found, returns empty
   147  func GetMutagenConfigFile(app *DdevApp) string {
   148  	projectConfig := GetMutagenConfigFilePath(app)
   149  	if fileutil.FileExists(projectConfig) {
   150  		return projectConfig
   151  	}
   152  	return ""
   153  }
   154  
   155  // CreateOrResumeMutagenSync creates or resumes a sync session
   156  // It detects problems with the sync and errors if there are problems
   157  func CreateOrResumeMutagenSync(app *DdevApp) error {
   158  	syncName := MutagenSyncName(app.Name)
   159  	configFile := GetMutagenConfigFile(app)
   160  	if configFile != "" {
   161  		util.Debug("Using Mutagen config file %s", configFile)
   162  	}
   163  
   164  	container, err := GetContainer(app, "web")
   165  	if err != nil {
   166  		return err
   167  	}
   168  	if container == nil {
   169  		return fmt.Errorf("web container for %s not found", app.Name)
   170  	}
   171  	if container.State != "running" {
   172  		// TODO: Improve or debug this temporary debug usage
   173  		util.Warning("Web container is not running, logs follow")
   174  		logsErr := app.Logs("web", false, false, "100")
   175  		if logsErr != nil {
   176  			util.Warning("Error from getting logs: %v", logsErr)
   177  		}
   178  		return fmt.Errorf("cannot start Mutagen sync because web container is not running: %v", container)
   179  	}
   180  
   181  	sessionExists, err := mutagenSyncSessionExists(app)
   182  	if err != nil {
   183  		return err
   184  	}
   185  	if sessionExists {
   186  		util.Verbose("Resume Mutagen sync if session already exists")
   187  		err := ResumeMutagenSync(app)
   188  		if err != nil {
   189  			return err
   190  		}
   191  	} else {
   192  		vLabel, err := GetMutagenVolumeLabel(app)
   193  		if err != nil {
   194  			return err
   195  		}
   196  
   197  		hLabel, err := GetMutagenConfigFileHash(app)
   198  		if err != nil {
   199  			return err
   200  		}
   201  		// TODO: Consider using a function to specify the Docker beta
   202  		args := []string{"sync", "create", app.AppRoot, fmt.Sprintf("docker:/%s/var/www/html", container.Names[0]), "--no-global-configuration", "--name", syncName, "--label", mutagenSignatureLabelName + "=" + vLabel, "--label", mutagenConfigFileHashLabelName + "=" + hLabel}
   203  		if configFile != "" {
   204  			args = append(args, fmt.Sprintf(`--configuration-file=%s`, configFile))
   205  		}
   206  		// On Windows, permissions can't be inferred from what is on the host side, so force 777 for
   207  		// most things
   208  		if runtime.GOOS == "windows" {
   209  			args = append(args, []string{"--permissions-mode=manual", "--default-file-mode-beta=0777", "--default-directory-mode-beta=0777"}...)
   210  		}
   211  		util.Debug("Creating Mutagen sync: mutagen %v", args)
   212  		out, err := exec.RunHostCommand(globalconfig.GetMutagenPath(), args...)
   213  		if err != nil {
   214  			return fmt.Errorf("failed to mutagen %v (%v), output=%s", args, err, out)
   215  		}
   216  	}
   217  
   218  	util.Verbose("Flushing Mutagen sync session '%s'", syncName)
   219  	flushErr := make(chan error, 1)
   220  	stopGoroutine := make(chan bool, 1)
   221  	firstOutputReceived := make(chan bool, 1)
   222  	defer close(flushErr)
   223  	defer close(stopGoroutine)
   224  	defer close(firstOutputReceived)
   225  
   226  	go func() {
   227  		err = app.MutagenSyncFlush()
   228  		util.Verbose("gofunc flushed Mutagen sync session '%s' err=%v", syncName, err)
   229  		flushErr <- err
   230  		return
   231  	}()
   232  
   233  	// In tests or other non-interactive environments we don't need to show the
   234  	// Mutagen sync monitor output (and it fills up the test logs)
   235  
   236  	if os.Getenv("DDEV_NONINTERACTIVE") != "true" {
   237  		go func() {
   238  			previousStatus := ""
   239  			curStatus := ""
   240  			sigSent := false
   241  			cmd := osexec.Command(globalconfig.GetMutagenPath(), "sync", "monitor", syncName)
   242  			stdout, _ := cmd.StdoutPipe()
   243  			err = cmd.Start()
   244  			buf := bufio.NewReader(stdout)
   245  			for {
   246  				select {
   247  				case <-stopGoroutine:
   248  					_ = cmd.Process.Kill()
   249  					_, _ = cmd.Process.Wait()
   250  					return
   251  				default:
   252  					line, err := buf.ReadBytes('\r')
   253  					if err != nil {
   254  						return
   255  					}
   256  					l := string(line)
   257  					if strings.HasPrefix(l, "Status:") {
   258  						// If we haven't already notified that output is coming in,
   259  						// then notify.
   260  						if !sigSent {
   261  							firstOutputReceived <- true
   262  							sigSent = true
   263  							_, _ = fmt.Fprintf(os.Stderr, "\n")
   264  						}
   265  
   266  						_, _ = fmt.Fprintf(os.Stderr, "%s", l)
   267  						t := strings.Replace(l, " ", "", 2)
   268  						c := strings.Split(t, " ")
   269  						curStatus = c[0]
   270  						if previousStatus != curStatus {
   271  							_, _ = fmt.Fprintf(os.Stderr, "\n")
   272  						}
   273  						previousStatus = curStatus
   274  					}
   275  				}
   276  			}
   277  		}()
   278  	}
   279  
   280  	outputComing := false
   281  	for {
   282  		select {
   283  		// Complete when the MutagenSyncFlush() completes
   284  		case err = <-flushErr:
   285  			return err
   286  		case outputComing = <-firstOutputReceived:
   287  
   288  		// If we haven't yet received any "Status:" output, do a dot every second
   289  		case <-time.After(1 * time.Second):
   290  			if !outputComing {
   291  				_, _ = fmt.Fprintf(os.Stderr, ".")
   292  			}
   293  		}
   294  	}
   295  }
   296  
   297  func ResumeMutagenSync(app *DdevApp) error {
   298  	args := []string{"sync", "resume", MutagenSyncName(app.Name)}
   299  	util.Verbose("Resuming Mutagen sync: mutagen %v", args)
   300  	out, err := exec.RunHostCommand(globalconfig.GetMutagenPath(), args...)
   301  	if err != nil {
   302  		return fmt.Errorf("failed to mutagen %v (%v), output=%s", args, err, out)
   303  	}
   304  	return nil
   305  }
   306  
   307  // mutagenSyncSessionExists determines whether an appropriate Mutagen sync session already exists
   308  // if it finds one with invalid label, it destroys the existing session.
   309  func mutagenSyncSessionExists(app *DdevApp) (bool, error) {
   310  	syncName := MutagenSyncName(app.Name)
   311  	res, err := exec.RunHostCommandSeparateStreams(globalconfig.GetMutagenPath(), "sync", "list", "--template", "{{ json (index . 0) }}", syncName)
   312  	if err != nil {
   313  		if exitError, ok := err.(*osexec.ExitError); ok {
   314  			// If we got an error, but it's that there were no sessions, return false, no err
   315  			if strings.Contains(string(exitError.Stderr), "did not match any sessions") {
   316  				return false, nil
   317  			}
   318  		}
   319  		return false, err
   320  	}
   321  	session := make(map[string]interface{})
   322  	err = json.Unmarshal([]byte(res), &session)
   323  	if err != nil {
   324  		return false, fmt.Errorf("failed to unmarshal Mutagen sync list results '%v': %v", res, err)
   325  	}
   326  
   327  	// Find out if Mutagen session labels has label we found in Docker volume
   328  	if l, ok := session["labels"].(map[string]interface{}); ok {
   329  		vLabel, vLabelErr := GetMutagenVolumeLabel(app)
   330  		if s, ok := l[mutagenSignatureLabelName]; ok && vLabelErr == nil && vLabel != "" && vLabel == s {
   331  			return true, nil
   332  		}
   333  		// If we happen to find a Mutagen session without matching signature, terminate it.
   334  		_ = TerminateMutagenSync(app)
   335  	}
   336  	return false, nil
   337  }
   338  
   339  // MutagenStatus checks to see if there is an error case in Mutagen
   340  // We don't want to do a flush yet in that case.
   341  // Note that the available statuses are at https://github.com/mutagen-io/mutagen/blob/master/pkg/synchronization/state.go#L9
   342  // in func (s Status) Description()
   343  // Can return any of those or "nosession" (with more info) if we didn't find a session at all
   344  func (app *DdevApp) MutagenStatus() (status string, shortResult string, mapResult map[string]interface{}, err error) {
   345  	syncName := MutagenSyncName(app.Name)
   346  
   347  	mutagenDataDirectory := os.Getenv("MUTAGEN_DATA_DIRECTORY")
   348  	fullJSONResult, err := exec.RunHostCommandSeparateStreams(globalconfig.GetMutagenPath(), "sync", "list", "--template", `{{ json (index . 0) }}`, syncName)
   349  	if err != nil {
   350  		stderr := ""
   351  		if exitError, ok := err.(*osexec.ExitError); ok {
   352  			stderr = string(exitError.Stderr)
   353  		}
   354  		return fmt.Sprintf("nosession for MUTAGEN_DATA_DIRECTORY=%s", mutagenDataDirectory), fullJSONResult, nil, fmt.Errorf("failed to Mutagen sync list %s: stderr='%s', err=%v", syncName, stderr, err)
   355  	}
   356  	session := make(map[string]interface{})
   357  	err = json.Unmarshal([]byte(fullJSONResult), &session)
   358  	if err != nil {
   359  		return fmt.Sprintf("nosession for MUTAGEN_DATA_DIRECTORY=%s; failed to unmarshal Mutagen sync list results '%v'", mutagenDataDirectory, fullJSONResult), fullJSONResult, nil, err
   360  	}
   361  
   362  	if paused, ok := session["paused"].(bool); ok && paused == true {
   363  		return "paused", "paused", session, nil
   364  	}
   365  	var ok bool
   366  	if shortResult, ok = session["status"].(string); !ok {
   367  		return "failing", shortResult, session, fmt.Errorf("mutagen sessions may be in invalid state, please `ddev mutagen reset`")
   368  	}
   369  	shortResult = session["status"].(string)
   370  
   371  	// In the odd case where somebody enabled Mutagen when it wasn't actually running
   372  	// show a simpler result
   373  	mounted, err := IsMutagenVolumeMounted(app)
   374  	if !mounted {
   375  		return "not enabled", "", session, nil
   376  	}
   377  	if err != nil {
   378  		return "", "", nil, err
   379  	}
   380  
   381  	problems := false
   382  	if alpha, ok := session["alpha"].(map[string]interface{}); ok {
   383  		if _, ok = alpha["scanProblems"]; ok {
   384  			problems = true
   385  		}
   386  	}
   387  	if beta, ok := session["beta"].(map[string]interface{}); ok {
   388  		if _, ok = beta["scanProblems"]; ok {
   389  			problems = true
   390  		}
   391  	}
   392  	if _, ok := session["conflicts"]; ok {
   393  		problems = true
   394  	}
   395  
   396  	// We're going to assume that if it's applying changes things are still OK,
   397  	// even though there may be a whole list of problems.
   398  	// States from json are in https://github.com/mutagen-io/mutagen/blob/bc07f2f0f3f0aba0aff0514bd4739d75444091fe/pkg/synchronization/state.go#L47-L79
   399  	switch shortResult {
   400  	case "paused":
   401  		return "paused", shortResult, session, nil
   402  	case "transitioning":
   403  		fallthrough
   404  	case "staging-alpha":
   405  		fallthrough
   406  	case "connecting-beta":
   407  		fallthrough
   408  	case "staging-beta":
   409  		fallthrough
   410  	case "reconciling":
   411  		fallthrough
   412  	case "scanning":
   413  		fallthrough
   414  	case "saving":
   415  		fallthrough
   416  	case "watching":
   417  		if !problems {
   418  			status = "ok"
   419  		} else {
   420  			status = "problems"
   421  		}
   422  		return status, shortResult, session, nil
   423  	}
   424  	return "failing", shortResult, session, nil
   425  }
   426  
   427  // GetMutagenSyncID() returns the project sync ID
   428  func (app *DdevApp) GetMutagenSyncID() (id string, err error) {
   429  	syncName := MutagenSyncName(app.Name)
   430  
   431  	identifier, err := exec.RunHostCommand(globalconfig.GetMutagenPath(), "sync", "list", `--template='{{ range . }}{{ .Identifier }}{{ break }}{{ end }}'`, syncName)
   432  	if err != nil {
   433  		return "", fmt.Errorf("failed RunHostCommand, output='%s': %v", identifier, err)
   434  	}
   435  
   436  	return identifier, nil
   437  }
   438  
   439  // MutagenSyncFlush performs a Mutagen sync flush, waits for result, and checks for errors
   440  func (app *DdevApp) MutagenSyncFlush() error {
   441  	if !app.IsMutagenEnabled() {
   442  		return nil
   443  	}
   444  
   445  	container, err := GetContainer(app, "web")
   446  	if err != nil {
   447  		return fmt.Errorf("failed to get web container, err='%v'", err)
   448  	}
   449  
   450  	// Discussions of container.State in
   451  	// https://stackoverflow.com/questions/32427684/what-are-the-possible-states-for-a-docker-container
   452  	// and https://medium.com/@BeNitinAgarwal/lifecycle-of-docker-container-d2da9f85959
   453  	if container.State != "running" {
   454  		return fmt.Errorf("mutagenSyncFlush() not mutagen-syncing project %s with web container is in state %s, but must be 'running'", app.Name, container.State)
   455  	}
   456  	syncName := MutagenSyncName(app.Name)
   457  	if !MutagenSyncExists(app) {
   458  		return errors.Errorf("Mutagen sync session '%s' does not exist", syncName)
   459  	}
   460  	if status, shortResult, session, err := app.MutagenStatus(); err == nil {
   461  		util.Verbose("Mutagen sync %s status='%s', shortResult='%v', session='%v', err='%v'", syncName, status, shortResult, session, err)
   462  		switch status {
   463  		case "paused":
   464  			util.Debug("Mutagen sync %s is paused, so not flushing", syncName)
   465  			return nil
   466  		case "failing":
   467  			util.Warning("Mutagen sync session %s has status '%s': shortResult='%v', err=%v, session contents='%v'", syncName, status, shortResult, err, session)
   468  		default:
   469  			// This extra sync resume recommended by @xenoscopic to catch situation where
   470  			// not paused but also not connected, in which case the flush will fail.
   471  			util.Verbose("Default case resuming Mutagen sync session '%s'", syncName)
   472  			out, err := exec.RunHostCommand(globalconfig.GetMutagenPath(), "sync", "resume", syncName)
   473  			if err != nil {
   474  				return fmt.Errorf("mutagen resume flush %s failed, output=%s, err=%v", syncName, out, err)
   475  			}
   476  			util.Verbose("Default case flushing Mutagen sync session '%s'", syncName)
   477  			out, err = exec.RunHostCommand(globalconfig.GetMutagenPath(), "sync", "flush", syncName)
   478  			if err != nil {
   479  				return fmt.Errorf("mutagen sync flush %s failed, output=%s, err=%v", syncName, out, err)
   480  			}
   481  			util.Verbose("Default case output of Mutagen sync='%s'", out)
   482  		}
   483  	}
   484  
   485  	status, short, _, err := app.MutagenStatus()
   486  	util.Verbose("Mutagen sync status %s in MutagenSyncFlush(): status='%s', short='%s', err='%v'", syncName, status, short, err)
   487  	if (status != "ok" && status != "problems" && status != "paused" && status != "failing") || err != nil {
   488  		return err
   489  	}
   490  	util.Verbose("Flushed Mutagen sync session '%s'", syncName)
   491  	return nil
   492  }
   493  
   494  // MutagenSyncExists detects whether the named sync exists
   495  func MutagenSyncExists(app *DdevApp) bool {
   496  	syncName := MutagenSyncName(app.Name)
   497  
   498  	if !fileutil.FileExists(globalconfig.GetMutagenPath()) {
   499  		return false
   500  	}
   501  	// List syncs with this name that also match appropriate labels
   502  	c := []string{globalconfig.GetMutagenPath(), "sync", "list", syncName}
   503  	out, err := exec.RunHostCommand(c[0], c[1:]...)
   504  	if err != nil && !strings.Contains(out, "Error: unable to locate requested sessions") {
   505  		util.Warning("%v failed: %v output=%v", c, err, out)
   506  	}
   507  	return err == nil
   508  }
   509  
   510  // DownloadMutagen gets the Mutagen binary and related and puts it into
   511  // ~/.ddev/.bin
   512  func DownloadMutagen() error {
   513  	StopMutagenDaemon()
   514  	flavor := runtime.GOOS + "_" + runtime.GOARCH
   515  	globalMutagenDir := filepath.Dir(globalconfig.GetMutagenPath())
   516  	destFile := filepath.Join(globalMutagenDir, "mutagen.tgz")
   517  	mutagenURL := fmt.Sprintf("https://github.com/mutagen-io/mutagen/releases/download/v%s/mutagen_%s_v%s.tar.gz", versionconstants.RequiredMutagenVersion, flavor, versionconstants.RequiredMutagenVersion)
   518  	output.UserOut.Printf("Downloading %s ...", mutagenURL)
   519  
   520  	// Remove the existing file. This may help on macOS to prevent the Gatekeeper's
   521  	// caching bug from confusing with a previously downloaded file?
   522  	// Discussion in https://github.com/mutagen-io/mutagen/issues/290#issuecomment-906612749
   523  	_ = os.Remove(globalconfig.GetMutagenPath())
   524  
   525  	_ = os.MkdirAll(globalMutagenDir, 0777)
   526  	err := util.DownloadFile(destFile, mutagenURL, "true" != os.Getenv("DDEV_NONINTERACTIVE"))
   527  	if err != nil {
   528  		return err
   529  	}
   530  	output.UserOut.Printf("Download complete.")
   531  
   532  	err = archive.Untar(destFile, globalMutagenDir, "")
   533  	_ = os.Remove(destFile)
   534  	if err != nil {
   535  		return err
   536  	}
   537  	err = os.Chmod(globalconfig.GetMutagenPath(), 0755)
   538  	if err != nil {
   539  		return err
   540  	}
   541  
   542  	// Stop daemon in case it was already running somewhere else
   543  	StopMutagenDaemon()
   544  	return nil
   545  }
   546  
   547  // StopMutagenDaemon will try to stop a running Mutagen daemon
   548  // But no problem if there wasn't one
   549  func StopMutagenDaemon() {
   550  	if fileutil.FileExists(globalconfig.GetMutagenPath()) {
   551  		mutagenDataDirectory := os.Getenv("MUTAGEN_DATA_DIRECTORY")
   552  		out, err := exec.RunHostCommand(globalconfig.GetMutagenPath(), "daemon", "stop")
   553  		if err != nil && !strings.Contains(out, "unable to connect to daemon") {
   554  			util.Warning("Unable to stop Mutagen daemon: %v; MUTAGEN_DATA_DIRECTORY=%s", err, mutagenDataDirectory)
   555  		}
   556  		util.Success("Stopped Mutagen daemon")
   557  	}
   558  }
   559  
   560  // StartMutagenDaemon will make sure the daemon is running
   561  func StartMutagenDaemon() {
   562  	if fileutil.FileExists(globalconfig.GetMutagenPath()) {
   563  		out, err := exec.RunHostCommand(globalconfig.GetMutagenPath(), "daemon", "start")
   564  		if err != nil {
   565  			util.Warning("Failed to run Mutagen daemon start: %v, out=%s", err, out)
   566  		}
   567  	}
   568  }
   569  
   570  // DownloadMutagenIfNeededAndEnabled downloads the proper version of Mutagen
   571  // if it's enabled and if it's either not yet installed or has the wrong version.
   572  func DownloadMutagenIfNeededAndEnabled(app *DdevApp) error {
   573  	if !app.IsMutagenEnabled() {
   574  		return nil
   575  	}
   576  	return DownloadMutagenIfNeeded()
   577  }
   578  
   579  // DownloadMutagenIfNeeded downloads Mutagen if we don't have it or there's an update
   580  func DownloadMutagenIfNeeded() error {
   581  	err := os.MkdirAll(globalconfig.GetMutagenDataDirectory(), 0755)
   582  	if err != nil {
   583  		return err
   584  	}
   585  	curVersion, err := version.GetLiveMutagenVersion()
   586  	if err != nil || curVersion != versionconstants.RequiredMutagenVersion {
   587  		err = DownloadMutagen()
   588  		if err != nil {
   589  			return err
   590  		}
   591  	}
   592  	return nil
   593  }
   594  
   595  // MutagenReset stops (with flush), removes the Docker volume, starts again (with flush)
   596  func MutagenReset(app *DdevApp) error {
   597  	if app.IsMutagenEnabled() {
   598  		err := app.Stop(false, false)
   599  		if err != nil {
   600  			return errors.Errorf("Failed to stop project %s: %v", app.Name, err)
   601  		}
   602  		err = dockerutil.RemoveVolume(GetMutagenVolumeName(app))
   603  		if err != nil {
   604  			return err
   605  		}
   606  		util.Debug("Removed Docker volume %s", GetMutagenVolumeName(app))
   607  	}
   608  	err := TerminateMutagenSync(app)
   609  	if err != nil {
   610  		return err
   611  	}
   612  	util.Debug("Terminated Mutagen sync session %s", MutagenSyncName(app.Name))
   613  	return nil
   614  }
   615  
   616  // GetMutagenVolumeName returns the name for the Mutagen Docker volume
   617  func GetMutagenVolumeName(app *DdevApp) string {
   618  	return app.Name + "_" + "project_mutagen"
   619  }
   620  
   621  // MutagenMonitor shows the output of `mutagen sync monitor <syncName>`
   622  func MutagenMonitor(app *DdevApp) {
   623  	syncName := MutagenSyncName(app.Name)
   624  
   625  	// This doesn't actually return; you have to <ctrl-c> to end it
   626  	c := osexec.Command(globalconfig.GetMutagenPath(), "sync", "monitor", syncName)
   627  	// We only need all three of these because of Windows behavior on git-bash with no pty
   628  	c.Stdout = os.Stdout
   629  	c.Stderr = os.Stderr
   630  	c.Stdin = os.Stdin
   631  	_ = c.Run()
   632  }
   633  
   634  //go:embed mutagen_config_assets
   635  var mutagenConfigAssets embed.FS
   636  
   637  // GenerateMutagenYml generates the .ddev/mutagen.yml
   638  func (app *DdevApp) GenerateMutagenYml() error {
   639  	// Prevent running as root for most cases
   640  	// We really don't want ~/.ddev to have root ownership, breaks things.
   641  	if os.Geteuid() == 0 {
   642  		util.Warning("Not generating Mutagen config file because running with root privileges")
   643  		return nil
   644  	}
   645  	mutagenYmlPath := GetMutagenConfigFilePath(app)
   646  	if sigExists, err := fileutil.FgrepStringInFile(mutagenYmlPath, nodeps.DdevFileSignature); err == nil && !sigExists {
   647  		// If the signature doesn't exist, they have taken over the file, so return
   648  		return nil
   649  	}
   650  
   651  	c, err := mutagenConfigAssets.ReadFile(path.Join("mutagen_config_assets", "mutagen.yml"))
   652  	if err != nil {
   653  		return err
   654  	}
   655  	content := string(c)
   656  
   657  	// It's impossible to use posix-raw on traditional windows.
   658  	// But this means that there will be errors with rooted symlinks in the container on windows
   659  	symlinkMode := "posix-raw"
   660  	if runtime.GOOS == "windows" {
   661  		symlinkMode = "portable"
   662  	}
   663  	err = os.MkdirAll(filepath.Dir(mutagenYmlPath), 0755)
   664  	if err != nil {
   665  		return err
   666  	}
   667  
   668  	templateMap := map[string]interface{}{
   669  		"SymlinkMode": symlinkMode,
   670  		"UploadDirs":  app.getUploadDirsRelative(),
   671  	}
   672  
   673  	// If no bind mounts, then we can't ignore UploadDir, must sync it
   674  	if globalconfig.DdevGlobalConfig.NoBindMounts {
   675  		templateMap["UploadDirs"] = []string{}
   676  	}
   677  
   678  	err = fileutil.TemplateStringToFile(content, templateMap, mutagenYmlPath)
   679  	return err
   680  }
   681  
   682  // IsMutagenVolumeMounted checks to see if the Mutagen volume is mounted
   683  func IsMutagenVolumeMounted(app *DdevApp) (bool, error) {
   684  	ctx, client := dockerutil.GetDockerClient()
   685  	container, err := dockerutil.FindContainerByName("ddev-" + app.Name + "-web")
   686  	// If there is no web container found, the volume is not mounted
   687  	if err != nil || container == nil {
   688  		return false, nil
   689  	}
   690  	inspect, err := client.ContainerInspect(ctx, container.ID)
   691  	if err != nil {
   692  		return false, err
   693  	}
   694  	for _, m := range inspect.Mounts {
   695  		if m.Name == app.Name+"_project_mutagen" {
   696  			return true, nil
   697  		}
   698  	}
   699  	return false, nil
   700  }
   701  
   702  // IsMutagenEnabled returns true if Mutagen is enabled locally or globally
   703  // It's also required and set if NoBindMounts is set, since we have to have a way
   704  // to get code on there.
   705  func (app *DdevApp) IsMutagenEnabled() bool {
   706  	return app.GetPerformanceMode() == types.PerformanceModeMutagen || globalconfig.DdevGlobalConfig.NoBindMounts
   707  }
   708  
   709  // GetMutagenVolumeLabel returns the com.ddev.volume-signature on the project_mutagen Docker volume
   710  func GetMutagenVolumeLabel(app *DdevApp) (string, error) {
   711  	labels, err := dockerutil.VolumeLabels(GetMutagenVolumeName(app))
   712  	if err != nil {
   713  		return "", err
   714  	}
   715  	if labels != nil {
   716  		if l, ok := labels[mutagenSignatureLabelName]; ok {
   717  			return l, nil
   718  		}
   719  	}
   720  	return "", nil
   721  }
   722  
   723  // CheckMutagenVolumeSyncCompatibility checks to see if the Mutagen label and volume label
   724  // are the same.
   725  // Compatible if:
   726  //   - No volume (or no volume and no Mutagen sync session)
   727  //   - Volume and Mutagen sync exist and volume label matches Mutagen label
   728  //
   729  // Not compatible if
   730  //   - Volume and Mutagen sync exist and have different labels
   731  //   - Volume exists (with label) but there's no Mutagen sync session matching it. In this case we'd want
   732  //     to start from scratch with a new volume and sync, so we get authoritative files from alpha (host)
   733  //   - Volume has a label that is not based on this Docker context.
   734  //
   735  // Return ok, info, where ok true if compatible, info gives reasoning
   736  func CheckMutagenVolumeSyncCompatibility(app *DdevApp) (ok bool, volumeExists bool, info string) {
   737  	mutagenSyncExists := MutagenSyncExists(app)
   738  	volumeLabel, volumeLabelErr := GetMutagenVolumeLabel(app)
   739  	dockerHostID := dockerutil.GetDockerHostID()
   740  	mutagenLabel := ""
   741  	configFileHashLabel := ""
   742  	var mutagenSyncLabelErr error
   743  	var configFileHashLabelErr error
   744  
   745  	volumeExists = !(volumeLabelErr != nil && dockerutil.IsErrNotFound(volumeLabelErr))
   746  	calculatedConfigFileHash, err := GetMutagenConfigFileHash(app)
   747  	if err != nil {
   748  		util.Warning("Unable to calculate Mutagen config file hash: %v", err)
   749  	}
   750  	if mutagenSyncExists {
   751  		mutagenLabel, mutagenSyncLabelErr = GetMutagenSyncLabel(app)
   752  		if mutagenSyncLabelErr != nil {
   753  			util.Warning("Mutagen sync session '%s' exists but unable to get sync label '%s': '%v' This is normal on upgrade from v1.21.6; error=%v", app.Name, mutagenSignatureLabelName, mutagenLabel, mutagenSyncLabelErr)
   754  		}
   755  		configFileHashLabel, configFileHashLabelErr = GetMutagenConfigFileHashLabel(app)
   756  		if configFileHashLabelErr != nil {
   757  			util.Warning("Mutagen sync session '%s' exists but unable to get sync label '%s': '%v' This is normal on upgrade from v1.21.6; error=%v", app.Name, mutagenConfigFileHashLabelName, configFileHashLabel, configFileHashLabelErr)
   758  		}
   759  	}
   760  	switch {
   761  	case configFileHashLabel != calculatedConfigFileHash:
   762  		return false, volumeExists, "Calculated mutagen.yml hash does not equal session label"
   763  	// If there is no volume, everything is fine, proceed.
   764  	case !volumeExists:
   765  		return true, volumeExists, "No Docker volume exists, so compatible"
   766  	case mutagenSyncLabelErr != nil:
   767  		return false, volumeExists, "Mutagen sync session exists but does not have label"
   768  	// If the labels do not have the current context as first part of label, we have trouble.
   769  	case !strings.HasPrefix(volumeLabel, dockerHostID) || !strings.HasPrefix(mutagenLabel, dockerHostID):
   770  		return false, volumeExists, fmt.Sprintf("Volume label '%s' or sync label '%s' does not start with current dockerHostID (%s)", volumeLabel, mutagenLabel, dockerHostID)
   771  	// if we have labels for both and they match, it's all fine.
   772  	case mutagenLabel == volumeLabel:
   773  		return true, volumeExists, fmt.Sprintf("Volume and Mutagen sync session have the same label: %s", volumeLabel)
   774  	}
   775  
   776  	return false, volumeExists, fmt.Sprintf("CheckMutagenVolumeSyncCompatibility: currentDockerContext=%s mutagenLabel='%s', volumeLabel='%s', mutagenSyncLabelErr='%v', volumeLabelErr='%v'", dockerutil.DockerContext, mutagenLabel, volumeLabel, mutagenSyncLabelErr, volumeLabelErr)
   777  }
   778  
   779  // GetMutagenSyncLabel gets the com.ddev.volume-signature label from an existing sync session
   780  func GetMutagenSyncLabel(app *DdevApp) (string, error) {
   781  	status, _, mapResult, err := app.MutagenStatus()
   782  
   783  	if strings.HasPrefix(status, "nosession") || err != nil {
   784  		return "", fmt.Errorf("no session %s found: %v", MutagenSyncName(app.Name), status)
   785  	}
   786  	if labels, ok := mapResult["labels"].(map[string]interface{}); ok {
   787  		if label, ok := labels[mutagenSignatureLabelName].(string); ok {
   788  			return label, nil
   789  		}
   790  	}
   791  	return "", fmt.Errorf("sync session label not found for sync session %s", MutagenSyncName(app.Name))
   792  }
   793  
   794  // GetMutagenConfigFileHashLabel gets the com.ddev.hash- label from an existing sync session
   795  func GetMutagenConfigFileHashLabel(app *DdevApp) (string, error) {
   796  	status, _, mapResult, err := app.MutagenStatus()
   797  
   798  	if strings.HasPrefix(status, "nosession") || err != nil {
   799  		return "", fmt.Errorf("no session %s found: %v", MutagenSyncName(app.Name), status)
   800  	}
   801  	if labels, ok := mapResult["labels"].(map[string]interface{}); ok {
   802  		if label, ok := labels[mutagenConfigFileHashLabelName].(string); ok {
   803  			return label, nil
   804  		}
   805  	}
   806  	return "", fmt.Errorf("configFilehash label not found for sync session %s", MutagenSyncName(app.Name))
   807  }
   808  
   809  // TerminateAllMutagenSync terminates all sync sessions
   810  func TerminateAllMutagenSync() {
   811  	if fileutil.FileExists(globalconfig.GetMutagenPath()) {
   812  		out, err := exec.RunHostCommand(globalconfig.GetMutagenPath(), "sync", "terminate", "-a")
   813  		if err != nil {
   814  			util.Warning("Could not terminate all Mutagen sessions (mutagen sync terminate -a), output=%s, err=%v", out, err)
   815  		}
   816  	}
   817  }
   818  
   819  // GetDefaultMutagenVolumeSignature gets a new volume signature to be applied to Mutagen volume
   820  func GetDefaultMutagenVolumeSignature(_ *DdevApp) string {
   821  	return fmt.Sprintf("%s-%v", dockerutil.GetDockerHostID(), time.Now().Unix())
   822  }
   823  
   824  // checkMutagenUploadDirs tells people if they are using Mutagen without upload_dir
   825  func (app *DdevApp) checkMutagenUploadDirs() {
   826  	if app.IsMutagenEnabled() && !app.IsUploadDirsWarningDisabled() && len(app.GetUploadDirs()) == 0 {
   827  		util.Warning("You have Mutagen enabled and your '%s' project type doesn't have `upload_dirs` set.", app.Type)
   828  		util.Warning("For faster startup and less disk usage, set upload_dirs to where your user-generated files are stored.")
   829  		util.Warning("If this is intended you can disable this warning with `ddev config --disable-upload-dirs-warning`.")
   830  	}
   831  }