github.com/mutagen-io/mutagen@v0.18.0-rc1/pkg/integration/internal_api_test.go (about) 1 package integration 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "io" 8 "net/http" 9 "os" 10 "path/filepath" 11 "runtime" 12 "testing" 13 14 "github.com/google/uuid" 15 16 "github.com/mutagen-io/mutagen/pkg/forwarding" 17 "github.com/mutagen-io/mutagen/pkg/forwarding/endpoint/local" 18 "github.com/mutagen-io/mutagen/pkg/integration/fixtures/constants" 19 "github.com/mutagen-io/mutagen/pkg/integration/protocols/netpipe" 20 "github.com/mutagen-io/mutagen/pkg/prompting" 21 "github.com/mutagen-io/mutagen/pkg/selection" 22 "github.com/mutagen-io/mutagen/pkg/synchronization" 23 "github.com/mutagen-io/mutagen/pkg/synchronization/compression" 24 "github.com/mutagen-io/mutagen/pkg/synchronization/hashing" 25 "github.com/mutagen-io/mutagen/pkg/url" 26 ) 27 28 func waitForSuccessfulSynchronizationCycle(ctx context.Context, sessionID string, allowScanProblems, allowConflicts, allowTransitionProblems bool) error { 29 // Create a session selection specification. 30 selection := &selection.Selection{ 31 Specifications: []string{sessionID}, 32 } 33 34 // Perform waiting. 35 var previousStateIndex uint64 36 var states []*synchronization.State 37 var err error 38 for { 39 previousStateIndex, states, err = synchronizationManager.List(ctx, selection, previousStateIndex) 40 if err != nil { 41 return fmt.Errorf("unable to list session states: %w", err) 42 } else if len(states) != 1 { 43 return errors.New("invalid number of session states returned") 44 } else if states[0].SuccessfulCycles > 0 { 45 if !allowScanProblems && (len(states[0].AlphaState.ScanProblems) > 0 || len(states[0].BetaState.ScanProblems) > 0) { 46 return errors.New("scan problems detected (and disallowed)") 47 } else if !allowConflicts && len(states[0].Conflicts) > 0 { 48 return errors.New("conflicts detected (and disallowed)") 49 } else if !allowTransitionProblems && (len(states[0].AlphaState.TransitionProblems) > 0 || len(states[0].BetaState.TransitionProblems) > 0) { 50 return errors.New("transition problems detected (and disallowed)") 51 } 52 return nil 53 } 54 } 55 } 56 57 func testSessionLifecycle(ctx context.Context, prompter string, alpha, beta *url.URL, configuration *synchronization.Configuration, allowScanProblems, allowConflicts, allowTransitionProblems bool) error { 58 // Create a session. 59 sessionID, err := synchronizationManager.Create( 60 ctx, 61 alpha, beta, 62 configuration, 63 &synchronization.Configuration{}, 64 &synchronization.Configuration{}, 65 "testSynchronizationSession", 66 nil, 67 false, 68 prompter, 69 ) 70 if err != nil { 71 return fmt.Errorf("unable to create session: %w", err) 72 } 73 74 // Wait for the session to have at least one successful synchronization 75 // cycle. 76 // TODO: Should we add a timeout on this? 77 if err := waitForSuccessfulSynchronizationCycle(ctx, sessionID, allowScanProblems, allowConflicts, allowTransitionProblems); err != nil { 78 return fmt.Errorf("unable to wait for successful synchronization: %w", err) 79 } 80 81 // TODO: Add hook for verifying file contents. 82 83 // TODO: Add hook for verifying presence/absence of particular 84 // conflicts/problems and remove that monitoring from 85 // waitForSuccessfulSynchronizationCycle (maybe have it pass back the 86 // relevant state). 87 88 // Create a session selection specification. 89 selection := &selection.Selection{ 90 Specifications: []string{sessionID}, 91 } 92 93 // Pause the session. 94 if err := synchronizationManager.Pause(ctx, selection, ""); err != nil { 95 return fmt.Errorf("unable to pause session: %w", err) 96 } 97 98 // Resume the session. 99 if err := synchronizationManager.Resume(ctx, selection, ""); err != nil { 100 return fmt.Errorf("unable to resume session: %w", err) 101 } 102 103 // Wait for the session to have at least one additional synchronization 104 // cycle. 105 if err := waitForSuccessfulSynchronizationCycle(ctx, sessionID, allowScanProblems, allowConflicts, allowTransitionProblems); err != nil { 106 return fmt.Errorf("unable to wait for additional synchronization: %w", err) 107 } 108 109 // Attempt an additional resume (this should be a no-op). 110 if err := synchronizationManager.Resume(ctx, selection, ""); err != nil { 111 return fmt.Errorf("unable to perform additional resume: %w", err) 112 } 113 114 // Terminate the session. 115 if err := synchronizationManager.Terminate(ctx, selection, ""); err != nil { 116 return fmt.Errorf("unable to terminate session: %w", err) 117 } 118 119 // TODO: Verify that cleanup took place. 120 121 // Success. 122 return nil 123 } 124 125 func TestSynchronizationBothRootsNil(t *testing.T) { 126 // Allow this test to run in parallel. 127 t.Parallel() 128 129 // Calculate alpha and beta paths. 130 directory := t.TempDir() 131 alphaRoot := filepath.Join(directory, "alpha") 132 betaRoot := filepath.Join(directory, "beta") 133 134 // Compute alpha and beta URLs. 135 alphaURL := &url.URL{Path: alphaRoot} 136 betaURL := &url.URL{Path: betaRoot} 137 138 // Compute configuration. We use defaults for everything. 139 configuration := &synchronization.Configuration{} 140 141 // Test the session lifecycle. 142 if err := testSessionLifecycle(context.Background(), "", alphaURL, betaURL, configuration, false, false, false); err != nil { 143 t.Error("session lifecycle test failed:", err) 144 } 145 } 146 147 func TestSynchronizationGOROOTSrcToBeta(t *testing.T) { 148 // Check the end-to-end test mode and compute the source synchronization 149 // root accordingly. If no mode has been specified, then skip the test. 150 endToEndTestMode := os.Getenv("MUTAGEN_TEST_END_TO_END") 151 var sourceRoot string 152 if endToEndTestMode == "" { 153 t.Skip() 154 } else if endToEndTestMode == "full" { 155 sourceRoot = filepath.Join(runtime.GOROOT(), "src") 156 } else if endToEndTestMode == "slim" { 157 sourceRoot = filepath.Join(runtime.GOROOT(), "src", "bufio") 158 } else { 159 t.Fatal("unknown end-to-end test mode specified:", endToEndTestMode) 160 } 161 162 // Allow the test to run in parallel. 163 t.Parallel() 164 165 // Calculate alpha and beta paths. 166 alphaRoot := sourceRoot 167 betaRoot := filepath.Join(t.TempDir(), "beta") 168 169 // Compute alpha and beta URLs. 170 alphaURL := &url.URL{Path: alphaRoot} 171 betaURL := &url.URL{Path: betaRoot} 172 173 // Compute configuration. We use defaults for everything. 174 configuration := &synchronization.Configuration{} 175 176 // Test the session lifecycle. 177 if err := testSessionLifecycle(context.Background(), "", alphaURL, betaURL, configuration, false, false, false); err != nil { 178 t.Error("session lifecycle test failed:", err) 179 } 180 } 181 182 func TestSynchronizationGOROOTSrcToAlpha(t *testing.T) { 183 // Check the end-to-end test mode and compute the source synchronization 184 // root accordingly. If no mode has been specified, then skip the test. 185 endToEndTestMode := os.Getenv("MUTAGEN_TEST_END_TO_END") 186 var sourceRoot string 187 if endToEndTestMode == "" { 188 t.Skip() 189 } else if endToEndTestMode == "full" { 190 sourceRoot = filepath.Join(runtime.GOROOT(), "src") 191 } else if endToEndTestMode == "slim" { 192 sourceRoot = filepath.Join(runtime.GOROOT(), "src", "bufio") 193 } else { 194 t.Fatal("unknown end-to-end test mode specified:", endToEndTestMode) 195 } 196 197 // Allow the test to run in parallel. 198 t.Parallel() 199 200 // Calculate alpha and beta paths. 201 alphaRoot := filepath.Join(t.TempDir(), "alpha") 202 betaRoot := sourceRoot 203 204 // Compute alpha and beta URLs. 205 alphaURL := &url.URL{Path: alphaRoot} 206 betaURL := &url.URL{Path: betaRoot} 207 208 // Compute configuration. We use defaults for everything. 209 configuration := &synchronization.Configuration{} 210 211 // Test the session lifecycle. 212 if err := testSessionLifecycle(context.Background(), "", alphaURL, betaURL, configuration, false, false, false); err != nil { 213 t.Error("session lifecycle test failed:", err) 214 } 215 } 216 217 func TestSynchronizationGOROOTSrcToBetaInMemory(t *testing.T) { 218 // Define configuration variations. 219 testCases := []*synchronization.Configuration{ 220 {}, 221 { 222 CompressionAlgorithm: compression.Algorithm_AlgorithmNone, 223 }, 224 { 225 HashingAlgorithm: hashing.Algorithm_AlgorithmSHA256, 226 }, 227 } 228 if hashing.Algorithm_AlgorithmXXH128.SupportStatus() == hashing.AlgorithmSupportStatusSupported { 229 testCases = append(testCases, &synchronization.Configuration{ 230 HashingAlgorithm: hashing.Algorithm_AlgorithmXXH128, 231 }) 232 } 233 if compression.Algorithm_AlgorithmZstandard.SupportStatus() == compression.AlgorithmSupportStatusSupported { 234 testCases = append(testCases, &synchronization.Configuration{ 235 CompressionAlgorithm: compression.Algorithm_AlgorithmZstandard, 236 }) 237 } 238 239 // Check the end-to-end test mode and compute the source synchronization 240 // root accordingly. If no mode has been specified, then skip the test. 241 endToEndTestMode := os.Getenv("MUTAGEN_TEST_END_TO_END") 242 var sourceRoot string 243 if endToEndTestMode == "" { 244 t.Skip() 245 } else if endToEndTestMode == "full" { 246 sourceRoot = filepath.Join(runtime.GOROOT(), "src") 247 } else if endToEndTestMode == "slim" { 248 sourceRoot = filepath.Join(runtime.GOROOT(), "src", "bufio") 249 } else { 250 t.Fatal("unknown end-to-end test mode specified:", endToEndTestMode) 251 } 252 253 // Allow the test to run in parallel. 254 t.Parallel() 255 256 // Loop over configurations and test the session lifecycle. 257 for _, configuration := range testCases { 258 // Calculate alpha and beta paths. 259 alphaRoot := sourceRoot 260 betaRoot := filepath.Join(t.TempDir(), "beta") 261 262 // Compute alpha and beta URLs. We use a special protocol with a custom 263 // handler to indicate an in-memory connection. 264 alphaURL := &url.URL{Path: alphaRoot} 265 betaURL := &url.URL{ 266 Protocol: netpipe.Protocol_Netpipe, 267 Path: betaRoot, 268 } 269 270 // Test the session lifecycle. 271 if err := testSessionLifecycle(context.Background(), "", alphaURL, betaURL, configuration, false, false, false); err != nil { 272 t.Error("session lifecycle test failed:", err) 273 } 274 } 275 } 276 277 func TestSynchronizationGOROOTSrcToBetaOverSSH(t *testing.T) { 278 // If localhost SSH support isn't available, then skip this test. 279 if os.Getenv("MUTAGEN_TEST_SSH") != "true" { 280 t.Skip() 281 } 282 283 // Check the end-to-end test mode and compute the source synchronization 284 // root accordingly. If no mode has been specified, then skip the test. 285 endToEndTestMode := os.Getenv("MUTAGEN_TEST_END_TO_END") 286 var sourceRoot string 287 if endToEndTestMode == "" { 288 t.Skip() 289 } else if endToEndTestMode == "full" { 290 sourceRoot = filepath.Join(runtime.GOROOT(), "src") 291 } else if endToEndTestMode == "slim" { 292 sourceRoot = filepath.Join(runtime.GOROOT(), "src", "bufio") 293 } else { 294 t.Fatal("unknown end-to-end test mode specified:", endToEndTestMode) 295 } 296 297 // Allow the test to run in parallel. 298 t.Parallel() 299 300 // Calculate alpha and beta paths. 301 alphaRoot := sourceRoot 302 betaRoot := filepath.Join(t.TempDir(), "beta") 303 304 // Compute alpha and beta URLs. 305 alphaURL := &url.URL{Path: alphaRoot} 306 betaURL := &url.URL{ 307 Protocol: url.Protocol_SSH, 308 Host: "localhost", 309 Path: betaRoot, 310 } 311 312 // Compute configuration. We use defaults for everything. 313 configuration := &synchronization.Configuration{} 314 315 // Test the session lifecycle. 316 if err := testSessionLifecycle(context.Background(), "", alphaURL, betaURL, configuration, false, false, false); err != nil { 317 t.Error("session lifecycle test failed:", err) 318 } 319 } 320 321 // testWindowsDockerTransportPrompter is a prompting.Prompter implementation 322 // that will answer "yes" to all prompts. It's needed to confirm container 323 // restart behavior in the Docker transport on Windows. 324 type testWindowsDockerTransportPrompter struct{} 325 326 func (t *testWindowsDockerTransportPrompter) Message(_ string) error { 327 return nil 328 } 329 330 func (t *testWindowsDockerTransportPrompter) Prompt(_ string) (string, error) { 331 return "yes", nil 332 } 333 334 func TestSynchronizationGOROOTSrcToBetaOverDocker(t *testing.T) { 335 // If Docker test support isn't available, then skip this test. 336 if os.Getenv("MUTAGEN_TEST_DOCKER") != "true" { 337 t.Skip() 338 } 339 340 // Check the end-to-end test mode and compute the source synchronization 341 // root accordingly. If no mode has been specified, then skip the test. 342 endToEndTestMode := os.Getenv("MUTAGEN_TEST_END_TO_END") 343 var sourceRoot string 344 if endToEndTestMode == "" { 345 t.Skip() 346 } else if endToEndTestMode == "full" { 347 sourceRoot = filepath.Join(runtime.GOROOT(), "src") 348 } else if endToEndTestMode == "slim" { 349 sourceRoot = filepath.Join(runtime.GOROOT(), "src", "bufio") 350 } else { 351 t.Fatal("unknown end-to-end test mode specified:", endToEndTestMode) 352 } 353 354 // If we're on a POSIX system, then allow this test to run concurrently with 355 // other tests. On Windows, agent installation into Docker containers 356 // requires temporarily halting the container, meaning that multiple 357 // simultaneous Docker tests could conflict with each other, so we don't 358 // allow Docker-based tests to run concurrently on Windows. 359 if runtime.GOOS != "windows" { 360 t.Parallel() 361 } 362 363 // If we're on Windows, register a prompter that will answer yes to 364 // questions about stoping and restarting containers. 365 var prompter string 366 if runtime.GOOS == "windows" { 367 if p, err := prompting.RegisterPrompter(&testWindowsDockerTransportPrompter{}); err != nil { 368 t.Fatal("unable to register prompter:", err) 369 } else { 370 prompter = p 371 defer prompting.UnregisterPrompter(prompter) 372 } 373 } 374 375 // Create a unique directory name for synchronization into the container. We 376 // don't clean it up, because it will be wiped out when the test container 377 // is deleted. 378 randomUUID, err := uuid.NewRandom() 379 if err != nil { 380 t.Fatal("unable to create random directory UUID:", err) 381 } 382 383 // Calculate alpha and beta paths. 384 alphaRoot := sourceRoot 385 betaRoot := "~/" + randomUUID.String() 386 387 // Grab Docker environment variables. 388 environment := make(map[string]string, len(url.DockerEnvironmentVariables)) 389 for _, variable := range url.DockerEnvironmentVariables { 390 environment[variable] = os.Getenv(variable) 391 } 392 393 // Compute alpha and beta URLs. 394 alphaURL := &url.URL{Path: alphaRoot} 395 betaURL := &url.URL{ 396 Protocol: url.Protocol_Docker, 397 User: os.Getenv("MUTAGEN_TEST_DOCKER_USERNAME"), 398 Host: os.Getenv("MUTAGEN_TEST_DOCKER_CONTAINER_NAME"), 399 Path: betaRoot, 400 Environment: environment, 401 } 402 403 // Verify that the beta URL is valid (this will validate the test 404 // environment variables as well). 405 if err := betaURL.EnsureValid(); err != nil { 406 t.Fatal("beta URL is invalid:", err) 407 } 408 409 // Compute configuration. We use defaults for everything. 410 configuration := &synchronization.Configuration{} 411 412 // Test the session lifecycle. 413 if err := testSessionLifecycle(context.Background(), prompter, alphaURL, betaURL, configuration, false, false, false); err != nil { 414 t.Error("session lifecycle test failed:", err) 415 } 416 } 417 418 func init() { 419 // HACK: Disable lazy listener initialization since it makes test 420 // coordination difficult. 421 local.DisableLazyListenerInitialization = true 422 } 423 424 func TestForwardingToHTTPDemo(t *testing.T) { 425 // If Docker test support isn't available, then skip this test. 426 if os.Getenv("MUTAGEN_TEST_DOCKER") != "true" { 427 t.Skip() 428 } 429 430 // If we're on a POSIX system, then allow this test to run concurrently with 431 // other tests. On Windows, agent installation into Docker containers 432 // requires temporarily halting the container, meaning that multiple 433 // simultaneous Docker tests could conflict with each other, so we don't 434 // allow Docker-based tests to run concurrently on Windows. 435 if runtime.GOOS != "windows" { 436 t.Parallel() 437 } 438 439 // If we're on Windows, register a prompter that will answer yes to 440 // questions about stoping and restarting containers. 441 var prompter string 442 if runtime.GOOS == "windows" { 443 if p, err := prompting.RegisterPrompter(&testWindowsDockerTransportPrompter{}); err != nil { 444 t.Fatal("unable to register prompter:", err) 445 } else { 446 prompter = p 447 defer prompting.UnregisterPrompter(prompter) 448 } 449 } 450 451 // Pick a local listener address. 452 listenerProtocol := "tcp" 453 listenerAddress := "localhost:7070" 454 455 // Compute source and destination URLs. 456 source := &url.URL{ 457 Kind: url.Kind_Forwarding, 458 Protocol: url.Protocol_Local, 459 Path: listenerProtocol + ":" + listenerAddress, 460 } 461 destination := &url.URL{ 462 Kind: url.Kind_Forwarding, 463 Protocol: url.Protocol_Docker, 464 User: os.Getenv("MUTAGEN_TEST_DOCKER_USERNAME"), 465 Host: os.Getenv("MUTAGEN_TEST_DOCKER_CONTAINER_NAME"), 466 Path: "tcp:" + constants.HTTPDemoBindAddress, 467 } 468 469 // Verify that the destination URL is valid (this will validate the test 470 // environment variables as well). 471 if err := destination.EnsureValid(); err != nil { 472 t.Fatal("beta URL is invalid:", err) 473 } 474 475 // Create a function to perform a simple HTTP request and ensure that the 476 // returned contents are as expected. 477 performHTTPRequest := func() error { 478 // Perform the request and defer closure of the response body. 479 response, err := http.Get(fmt.Sprintf("http://%s/", listenerAddress)) 480 if err != nil { 481 return fmt.Errorf("unable to perform HTTP GET: %w", err) 482 } 483 defer response.Body.Close() 484 485 // Read the full body. 486 message, err := io.ReadAll(response.Body) 487 if err != nil { 488 return fmt.Errorf("unable to read response body: %w", err) 489 } 490 491 // Compare the message. 492 if string(message) != constants.HTTPDemoResponse { 493 return errors.New("response does not match expected") 494 } 495 496 // Success. 497 return nil 498 } 499 500 // Create a context to regulate the test. 501 ctx := context.Background() 502 503 // Create a forwarding session. Note that we've disabled lazy listener 504 // initialization using a private API in the init function above, so we can 505 // be sure that the listener has been established (with some non-empty 506 // backlog) by the time creation is complete. 507 sessionID, err := forwardingManager.Create( 508 ctx, 509 source, 510 destination, 511 &forwarding.Configuration{}, 512 &forwarding.Configuration{}, 513 &forwarding.Configuration{}, 514 "testForwardingSession", 515 nil, 516 false, 517 prompter, 518 ) 519 if err != nil { 520 t.Fatal("unable to create session:", err) 521 } 522 523 // Attempt an HTTP request. 524 // TODO: Attempt a more complicated exchange here. Maybe gRPC? 525 if err := performHTTPRequest(); err != nil { 526 t.Error("error performing forwarded HTTP request:", err) 527 } 528 529 // Create a session selection specification. 530 selection := &selection.Selection{ 531 Specifications: []string{sessionID}, 532 } 533 534 // Pause the session. 535 if err := forwardingManager.Pause(ctx, selection, ""); err != nil { 536 t.Error("unable to pause session:", err) 537 } 538 539 // Resume the session. 540 if err := forwardingManager.Resume(ctx, selection, ""); err != nil { 541 t.Error("unable to resume session:", err) 542 } 543 544 // Attempt an HTTP request. 545 // TODO: Attempt a more complicated exchange here. Maybe gRPC? 546 if err := performHTTPRequest(); err != nil { 547 t.Error("error performing forwarded HTTP request:", err) 548 } 549 550 // Attempt an additional resume (this should be a no-op). 551 if err := forwardingManager.Resume(ctx, selection, ""); err != nil { 552 t.Error("unable to perform additional resume:", err) 553 } 554 555 // Terminate the session. 556 if err := forwardingManager.Terminate(ctx, selection, ""); err != nil { 557 t.Error("unable to terminate session:", err) 558 } 559 560 // TODO: Verify that cleanup took place. 561 } 562 563 // TODO: Add forwarding tests using the netpipe protocol.