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 }