master
go 918 lines 33.4 KB
Raw
1 package migrations
2
3 // NOTE: These migration tests require the local Kubo binary (built with 'make build') to be in PATH.
4 //
5 // To run these tests successfully:
6 // export PATH="$(pwd)/cmd/ipfs:$PATH"
7 // go test ./test/cli/migrations/
8
9 import (
10 "bufio"
11 "context"
12 "encoding/json"
13 "fmt"
14 "io"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "strings"
19 "testing"
20 "time"
21
22 ipfs "github.com/ipfs/kubo"
23 "github.com/ipfs/kubo/test/cli/harness"
24 "github.com/stretchr/testify/assert"
25 "github.com/stretchr/testify/require"
26 )
27
28 // TestMigration16ToLatest tests migration from repo version 16 to the latest version.
29 //
30 // This test uses a real IPFS repository snapshot from Kubo v0.36.0 (the last version that used repo v16).
31 // The intention is to confirm that users can upgrade from Kubo v0.36.0 to the latest version by applying
32 // all intermediate migrations successfully.
33 //
34 // NOTE: This test comprehensively tests all migration methods (daemon --migrate, repo migrate,
35 // and reverse migration) because 16-to-17 was the first embedded migration that did not fetch
36 // external files. It serves as a reference implementation for migration testing.
37 //
38 // Future migrations can have simplified tests (like 17-to-18 in migration_17_to_latest_test.go)
39 // that focus on specific migration logic rather than testing all migration methods.
40 //
41 // If you need to test migration of configuration keys that appeared in later repo versions,
42 // create a new test file migration_N_to_latest_test.go with a separate IPFS repository test vector
43 // from the appropriate Kubo version.
44 func TestMigration16ToLatest(t *testing.T) {
45 t.Parallel()
46
47 // Primary tests using 'ipfs daemon --migrate' command (default in Docker)
48 t.Run("daemon migrate: forward migration with auto values", testDaemonMigrationWithAuto)
49 t.Run("daemon migrate: forward migration without auto values", testDaemonMigrationWithoutAuto)
50 t.Run("daemon migrate: corrupted config handling", testDaemonCorruptedConfigHandling)
51 t.Run("daemon migrate: missing fields handling", testDaemonMissingFieldsHandling)
52
53 // Comparison tests using 'ipfs repo migrate' command
54 t.Run("repo migrate: forward migration with auto values", testRepoMigrationWithAuto)
55 t.Run("repo migrate: backward migration", testRepoBackwardMigration)
56
57 // Temp file and backup cleanup tests
58 t.Run("daemon migrate: no temp files after successful migration", testNoTempFilesAfterSuccessfulMigration)
59 t.Run("daemon migrate: no temp files after failed migration", testNoTempFilesAfterFailedMigration)
60 t.Run("daemon migrate: backup files persist after successful migration", testBackupFilesPersistAfterSuccessfulMigration)
61 t.Run("repo migrate: backup files can revert migration", testBackupFilesCanRevertMigration)
62 t.Run("repo migrate: conversion failure cleans up temp files", testConversionFailureCleanup)
63 }
64
65 // =============================================================================
66 // PRIMARY TESTS: 'ipfs daemon --migrate' command (default in Docker)
67 //
68 // These tests exercise the primary migration path used in production Docker
69 // containers where --migrate is enabled by default. This covers:
70 // - Normal forward migration scenarios
71 // - Error handling with corrupted configs
72 // - Migration with minimal/missing config fields
73 // =============================================================================
74
75 func testDaemonMigrationWithAuto(t *testing.T) {
76 // TEST: Forward migration using 'ipfs daemon --migrate' command (PRIMARY)
77 // Use static v16 repo fixture from real Kubo 0.36 `ipfs init`
78 // NOTE: This test may need to be revised/updated once repo version 18 is released,
79 // at that point only keep tests that use 'ipfs repo migrate'
80 node := setupStaticV16Repo(t)
81
82 configPath := filepath.Join(node.Dir, "config")
83 versionPath := filepath.Join(node.Dir, "version")
84
85 // Static fixture already uses port 0 for random port assignment - no config update needed
86
87 // Run migration using daemon --migrate (automatic during daemon startup)
88 // This is the primary method used in Docker containers
89 // Monitor output until daemon is ready, then shut it down gracefully
90 stdoutOutput, migrationSuccess := runDaemonMigrationWithMonitoring(t, node)
91
92 // Debug: Print the actual output
93 t.Logf("Daemon output:\n%s", stdoutOutput)
94
95 // Verify migration was successful based on monitoring
96 require.True(t, migrationSuccess, "Migration should have been successful")
97 require.Contains(t, stdoutOutput, "applying 16-to-17 repo migration", "Migration should have been triggered")
98 require.Contains(t, stdoutOutput, "Migration 16-to-17 succeeded", "Migration should have completed successfully")
99
100 // Verify version was updated to latest
101 versionData, err := os.ReadFile(versionPath)
102 require.NoError(t, err)
103 expectedVersion := fmt.Sprint(ipfs.RepoVersion)
104 require.Equal(t, expectedVersion, strings.TrimSpace(string(versionData)), "Version should be updated to %s (latest)", expectedVersion)
105
106 // Verify migration results using DRY helper
107 helper := NewMigrationTestHelper(t, configPath)
108 helper.RequireAutoConfDefaults().
109 RequireArrayContains("Bootstrap", "auto").
110 RequireArrayLength("Bootstrap", 1). // Should only contain "auto" when all peers were defaults
111 RequireArrayContains("Routing.DelegatedRouters", "auto").
112 RequireArrayContains("Ipns.DelegatedPublishers", "auto")
113
114 // DNS resolver in static fixture should be empty, so "." should be set to "auto"
115 helper.RequireFieldEquals("DNS.Resolvers[.]", "auto")
116 }
117
118 func testDaemonMigrationWithoutAuto(t *testing.T) {
119 // TEST: Forward migration using 'ipfs daemon --migrate' command (PRIMARY)
120 // Test migration of a config that already has some custom values
121 // NOTE: This test may need to be revised/updated once repo version 18 is released,
122 // at that point only keep tests that use 'ipfs repo migrate'
123 // Should preserve existing settings and only add missing ones
124 node := setupStaticV16Repo(t)
125
126 // Modify the static fixture to add some custom values for testing mixed scenarios
127 configPath := filepath.Join(node.Dir, "config")
128
129 // Read existing config from static fixture
130 var v16Config map[string]any
131 configData, err := os.ReadFile(configPath)
132 require.NoError(t, err)
133 require.NoError(t, json.Unmarshal(configData, &v16Config))
134
135 // Add custom DNS resolver that should be preserved
136 if v16Config["DNS"] == nil {
137 v16Config["DNS"] = map[string]any{}
138 }
139 dnsSection := v16Config["DNS"].(map[string]any)
140 dnsSection["Resolvers"] = map[string]string{
141 ".": "https://custom-dns.example.com/dns-query",
142 "eth.": "https://dns.eth.limo/dns-query", // This is a default that will be replaced with "auto"
143 }
144
145 // Write modified config back
146 modifiedConfigData, err := json.MarshalIndent(v16Config, "", " ")
147 require.NoError(t, err)
148 require.NoError(t, os.WriteFile(configPath, modifiedConfigData, 0644))
149
150 // Static fixture already uses port 0 for random port assignment - no config update needed
151
152 // Run migration using daemon --migrate command (this is a daemon test)
153 // Monitor output until daemon is ready, then shut it down gracefully
154 stdoutOutput, migrationSuccess := runDaemonMigrationWithMonitoring(t, node)
155
156 // Verify migration was successful based on monitoring
157 require.True(t, migrationSuccess, "Migration should have been successful")
158 require.Contains(t, stdoutOutput, "applying 16-to-17 repo migration", "Migration should have been triggered")
159 require.Contains(t, stdoutOutput, "Migration 16-to-17 succeeded", "Migration should have completed successfully")
160
161 // Verify migration results: custom values preserved alongside "auto"
162 helper := NewMigrationTestHelper(t, configPath)
163 helper.RequireAutoConfDefaults().
164 RequireArrayContains("Bootstrap", "auto").
165 RequireFieldEquals("DNS.Resolvers[.]", "https://custom-dns.example.com/dns-query")
166
167 // Check that eth. resolver was replaced with "auto" since it uses a default URL
168 helper.RequireFieldEquals("DNS.Resolvers[eth.]", "auto").
169 RequireFieldEquals("DNS.Resolvers[.]", "https://custom-dns.example.com/dns-query")
170 }
171
172 // =============================================================================
173 // Tests using 'ipfs daemon --migrate' command
174 // =============================================================================
175
176 // Test helper structs and functions for cleaner, more DRY tests
177
178 type ConfigField struct {
179 Path string
180 Expected any
181 Message string
182 }
183
184 type MigrationTestHelper struct {
185 t *testing.T
186 config map[string]any
187 }
188
189 func NewMigrationTestHelper(t *testing.T, configPath string) *MigrationTestHelper {
190 var config map[string]any
191 configData, err := os.ReadFile(configPath)
192 require.NoError(t, err)
193 require.NoError(t, json.Unmarshal(configData, &config))
194
195 return &MigrationTestHelper{t: t, config: config}
196 }
197
198 func (h *MigrationTestHelper) RequireFieldExists(path string) *MigrationTestHelper {
199 value := h.getNestedValue(path)
200 require.NotNil(h.t, value, "Field %s should exist", path)
201 return h
202 }
203
204 func (h *MigrationTestHelper) RequireFieldEquals(path string, expected any) *MigrationTestHelper {
205 value := h.getNestedValue(path)
206 require.Equal(h.t, expected, value, "Field %s should equal %v", path, expected)
207 return h
208 }
209
210 func (h *MigrationTestHelper) RequireArrayContains(path string, expected any) *MigrationTestHelper {
211 value := h.getNestedValue(path)
212 require.IsType(h.t, []any{}, value, "Field %s should be an array", path)
213 array := value.([]any)
214 require.Contains(h.t, array, expected, "Array %s should contain %v", path, expected)
215 return h
216 }
217
218 func (h *MigrationTestHelper) RequireArrayLength(path string, expectedLen int) *MigrationTestHelper {
219 value := h.getNestedValue(path)
220 require.IsType(h.t, []any{}, value, "Field %s should be an array", path)
221 array := value.([]any)
222 require.Len(h.t, array, expectedLen, "Array %s should have length %d", path, expectedLen)
223 return h
224 }
225
226 func (h *MigrationTestHelper) RequireArrayDoesNotContain(path string, notExpected any) *MigrationTestHelper {
227 value := h.getNestedValue(path)
228 require.IsType(h.t, []any{}, value, "Field %s should be an array", path)
229 array := value.([]any)
230 require.NotContains(h.t, array, notExpected, "Array %s should not contain %v", path, notExpected)
231 return h
232 }
233
234 func (h *MigrationTestHelper) RequireFieldAbsent(path string) *MigrationTestHelper {
235 value := h.getNestedValue(path)
236 require.Nil(h.t, value, "Field %s should not exist", path)
237 return h
238 }
239
240 func (h *MigrationTestHelper) RequireAutoConfDefaults() *MigrationTestHelper {
241 // AutoConf section should exist but be empty (using implicit defaults)
242 return h.RequireFieldExists("AutoConf").
243 RequireFieldAbsent("AutoConf.Enabled"). // Should use implicit default (true)
244 RequireFieldAbsent("AutoConf.URL"). // Should use implicit default (mainnet URL)
245 RequireFieldAbsent("AutoConf.RefreshInterval"). // Should use implicit default (24h)
246 RequireFieldAbsent("AutoConf.TLSInsecureSkipVerify") // Should use implicit default (false)
247 }
248
249 func (h *MigrationTestHelper) RequireAutoFieldsSetToAuto() *MigrationTestHelper {
250 return h.RequireArrayContains("Bootstrap", "auto").
251 RequireFieldEquals("DNS.Resolvers[.]", "auto").
252 RequireArrayContains("Routing.DelegatedRouters", "auto").
253 RequireArrayContains("Ipns.DelegatedPublishers", "auto")
254 }
255
256 func (h *MigrationTestHelper) RequireNoAutoValues() *MigrationTestHelper {
257 // Check Bootstrap if it exists
258 if h.getNestedValue("Bootstrap") != nil {
259 h.RequireArrayDoesNotContain("Bootstrap", "auto")
260 }
261
262 // Check DNS.Resolvers if it exists
263 if h.getNestedValue("DNS.Resolvers") != nil {
264 h.RequireMapDoesNotContainValue("DNS.Resolvers", "auto")
265 }
266
267 // Check Routing.DelegatedRouters if it exists
268 if h.getNestedValue("Routing.DelegatedRouters") != nil {
269 h.RequireArrayDoesNotContain("Routing.DelegatedRouters", "auto")
270 }
271
272 // Check Ipns.DelegatedPublishers if it exists
273 if h.getNestedValue("Ipns.DelegatedPublishers") != nil {
274 h.RequireArrayDoesNotContain("Ipns.DelegatedPublishers", "auto")
275 }
276
277 return h
278 }
279
280 func (h *MigrationTestHelper) RequireMapDoesNotContainValue(path string, notExpected any) *MigrationTestHelper {
281 value := h.getNestedValue(path)
282 require.IsType(h.t, map[string]any{}, value, "Field %s should be a map", path)
283 mapValue := value.(map[string]any)
284 for k, v := range mapValue {
285 require.NotEqual(h.t, notExpected, v, "Map %s[%s] should not equal %v", path, k, notExpected)
286 }
287 return h
288 }
289
290 func (h *MigrationTestHelper) getNestedValue(path string) any {
291 segments := h.parseKuboConfigPath(path)
292 current := any(h.config)
293
294 for _, segment := range segments {
295 switch segment.Type {
296 case "field":
297 switch v := current.(type) {
298 case map[string]any:
299 current = v[segment.Key]
300 default:
301 return nil
302 }
303 case "mapKey":
304 switch v := current.(type) {
305 case map[string]any:
306 current = v[segment.Key]
307 default:
308 return nil
309 }
310 default:
311 return nil
312 }
313
314 if current == nil {
315 return nil
316 }
317 }
318
319 return current
320 }
321
322 type PathSegment struct {
323 Type string // "field" or "mapKey"
324 Key string
325 }
326
327 func (h *MigrationTestHelper) parseKuboConfigPath(path string) []PathSegment {
328 var segments []PathSegment
329
330 // Split path into parts, respecting bracket boundaries
331 parts := h.splitKuboConfigPath(path)
332
333 for _, part := range parts {
334 if strings.Contains(part, "[") && strings.HasSuffix(part, "]") {
335 // Handle field[key] notation
336 bracketStart := strings.Index(part, "[")
337 fieldName := part[:bracketStart]
338 mapKey := part[bracketStart+1 : len(part)-1] // Remove [ and ]
339
340 // Add field segment if present
341 if fieldName != "" {
342 segments = append(segments, PathSegment{Type: "field", Key: fieldName})
343 }
344 // Add map key segment
345 segments = append(segments, PathSegment{Type: "mapKey", Key: mapKey})
346 } else {
347 // Regular field access
348 if part != "" {
349 segments = append(segments, PathSegment{Type: "field", Key: part})
350 }
351 }
352 }
353
354 return segments
355 }
356
357 // splitKuboConfigPath splits a path on dots, but preserves bracket sections intact
358 func (h *MigrationTestHelper) splitKuboConfigPath(path string) []string {
359 var parts []string
360 var current strings.Builder
361 inBrackets := false
362
363 for _, r := range path {
364 switch r {
365 case '[':
366 inBrackets = true
367 current.WriteRune(r)
368 case ']':
369 inBrackets = false
370 current.WriteRune(r)
371 case '.':
372 if inBrackets {
373 // Inside brackets, preserve the dot
374 current.WriteRune(r)
375 } else {
376 // Outside brackets, split here
377 if current.Len() > 0 {
378 parts = append(parts, current.String())
379 current.Reset()
380 }
381 }
382 default:
383 current.WriteRune(r)
384 }
385 }
386
387 // Add final part if any
388 if current.Len() > 0 {
389 parts = append(parts, current.String())
390 }
391
392 return parts
393 }
394
395 // setupStaticV16Repo creates a test node using static v16 repo fixture from real Kubo 0.36 `ipfs init`
396 // This ensures tests remain stable regardless of future changes to the IPFS binary
397 // Each test gets its own copy in a temporary directory to allow modifications
398 func setupStaticV16Repo(t *testing.T) *harness.Node {
399 // Get absolute path to static v16 repo fixture
400 v16FixturePath := "testdata/v16-repo"
401
402 // Create a temporary test directory - each test gets its own copy
403 // Sanitize test name for Windows - replace invalid characters
404 sanitizedName := strings.Map(func(r rune) rune {
405 if strings.ContainsRune(`<>:"/\|?*`, r) {
406 return '_'
407 }
408 return r
409 }, t.Name())
410 tmpDir := filepath.Join(t.TempDir(), "migration-test-"+sanitizedName)
411 require.NoError(t, os.MkdirAll(tmpDir, 0755))
412
413 // Convert to absolute path for harness
414 absTmpDir, err := filepath.Abs(tmpDir)
415 require.NoError(t, err)
416
417 // Use the built binary (should be in PATH)
418 node := harness.BuildNode("ipfs", absTmpDir, 0)
419
420 // Replace IPFS_PATH with static fixture files to test directory (creates independent copy per test)
421 cloneStaticRepoFixture(t, v16FixturePath, node.Dir)
422
423 return node
424 }
425
426 // cloneStaticRepoFixture recursively copies the v16 repo fixture to the target directory
427 // It completely removes the target directory contents before copying to ensure no extra files remain
428 func cloneStaticRepoFixture(t *testing.T, srcPath, dstPath string) {
429 srcInfo, err := os.Stat(srcPath)
430 require.NoError(t, err)
431
432 if srcInfo.IsDir() {
433 // Completely remove destination directory and all contents
434 require.NoError(t, os.RemoveAll(dstPath))
435 // Create fresh destination directory
436 require.NoError(t, os.MkdirAll(dstPath, srcInfo.Mode()))
437
438 // Read source directory
439 entries, err := os.ReadDir(srcPath)
440 require.NoError(t, err)
441
442 // Copy each entry recursively
443 for _, entry := range entries {
444 srcEntryPath := filepath.Join(srcPath, entry.Name())
445 dstEntryPath := filepath.Join(dstPath, entry.Name())
446 cloneStaticRepoFixture(t, srcEntryPath, dstEntryPath)
447 }
448 } else {
449 // Copy file (destination directory should already be clean from parent call)
450 srcFile, err := os.Open(srcPath)
451 require.NoError(t, err)
452 defer srcFile.Close()
453
454 dstFile, err := os.Create(dstPath)
455 require.NoError(t, err)
456 defer dstFile.Close()
457
458 _, err = io.Copy(dstFile, srcFile)
459 require.NoError(t, err)
460
461 // Copy file permissions
462 require.NoError(t, dstFile.Chmod(srcInfo.Mode()))
463 }
464 }
465
466 // Placeholder stubs for new test functions - to be implemented
467 func testDaemonCorruptedConfigHandling(t *testing.T) {
468 // TEST: Error handling using 'ipfs daemon --migrate' command with corrupted config (PRIMARY)
469 // Test what happens when config file is corrupted during migration
470 // NOTE: This test may need to be revised/updated once repo version 18 is released,
471 // at that point only keep tests that use 'ipfs repo migrate'
472 node := setupStaticV16Repo(t)
473
474 // Create corrupted config
475 configPath := filepath.Join(node.Dir, "config")
476 corruptedJson := `{"Bootstrap": [invalid json}`
477 require.NoError(t, os.WriteFile(configPath, []byte(corruptedJson), 0644))
478
479 // Write version file indicating v16
480 versionPath := filepath.Join(node.Dir, "version")
481 require.NoError(t, os.WriteFile(versionPath, []byte("16"), 0644))
482
483 // Run daemon with --migrate flag - this should fail gracefully
484 result := node.RunIPFS("daemon", "--migrate")
485
486 // Verify graceful failure handling
487 // The daemon should fail but migration error should be clear
488 errorOutput := result.Stderr.String() + result.Stdout.String()
489 require.True(t, strings.Contains(errorOutput, "json") || strings.Contains(errorOutput, "invalid character"), "Error should mention JSON parsing issue")
490
491 // Verify atomic failure: version and config should remain unchanged
492 versionData, err := os.ReadFile(versionPath)
493 require.NoError(t, err)
494 require.Equal(t, "16", strings.TrimSpace(string(versionData)), "Version should remain unchanged after failed migration")
495
496 originalContent, err := os.ReadFile(configPath)
497 require.NoError(t, err)
498 require.Equal(t, corruptedJson, string(originalContent), "Original config should be unchanged after failed migration")
499 }
500
501 func testDaemonMissingFieldsHandling(t *testing.T) {
502 // TEST: Migration using 'ipfs daemon --migrate' command with minimal config (PRIMARY)
503 // Test migration when config is missing expected fields
504 // NOTE: This test may need to be revised/updated once repo version 18 is released,
505 // at that point only keep tests that use 'ipfs repo migrate'
506 node := setupStaticV16Repo(t)
507
508 // The static fixture already has all required fields, use it as-is
509 configPath := filepath.Join(node.Dir, "config")
510 versionPath := filepath.Join(node.Dir, "version")
511
512 // Static fixture already uses port 0 for random port assignment - no config update needed
513
514 // Run daemon migration
515 stdoutOutput, migrationSuccess := runDaemonMigrationWithMonitoring(t, node)
516
517 // Verify migration was successful
518 require.True(t, migrationSuccess, "Migration should have been successful")
519 require.Contains(t, stdoutOutput, "applying 16-to-17 repo migration", "Migration should have been triggered")
520 require.Contains(t, stdoutOutput, "Migration 16-to-17 succeeded", "Migration should have completed successfully")
521
522 // Verify version was updated to latest
523 versionData, err := os.ReadFile(versionPath)
524 require.NoError(t, err)
525 expectedVersion := fmt.Sprint(ipfs.RepoVersion)
526 require.Equal(t, expectedVersion, strings.TrimSpace(string(versionData)), "Version should be updated to %s (latest)", expectedVersion)
527
528 // Verify migration adds all required fields to minimal config
529 NewMigrationTestHelper(t, configPath).
530 RequireAutoConfDefaults().
531 RequireAutoFieldsSetToAuto().
532 RequireFieldExists("Identity.PeerID") // Original identity preserved from static fixture
533 }
534
535 // =============================================================================
536 // COMPARISON TESTS: 'ipfs repo migrate' command
537 //
538 // These tests verify that repo migrate produces equivalent results to
539 // daemon migrate, and test scenarios specific to repo migrate like
540 // backward migration (which daemon doesn't support).
541 // =============================================================================
542
543 func testRepoMigrationWithAuto(t *testing.T) {
544 // TEST: Forward migration using 'ipfs repo migrate' command (COMPARISON)
545 // Simple comparison test to verify repo migrate produces same results as daemon migrate
546 node := setupStaticV16Repo(t)
547
548 // Use static fixture as-is
549 configPath := filepath.Join(node.Dir, "config")
550
551 // Run migration using 'ipfs repo migrate' command
552 result := node.RunIPFS("repo", "migrate")
553 require.Empty(t, result.Stderr.String(), "Migration should succeed without errors")
554
555 // Verify same results as daemon migrate
556 helper := NewMigrationTestHelper(t, configPath)
557 helper.RequireAutoConfDefaults().
558 RequireArrayContains("Bootstrap", "auto").
559 RequireArrayContains("Routing.DelegatedRouters", "auto").
560 RequireArrayContains("Ipns.DelegatedPublishers", "auto").
561 RequireFieldEquals("DNS.Resolvers[.]", "auto")
562 }
563
564 func testRepoBackwardMigration(t *testing.T) {
565 // TEST: Backward migration using 'ipfs repo migrate --to=16 --allow-downgrade' command
566 // This is kept as repo migrate since daemon doesn't support backward migration
567 node := setupStaticV16Repo(t)
568
569 // Use static fixture as-is
570 configPath := filepath.Join(node.Dir, "config")
571 versionPath := filepath.Join(node.Dir, "version")
572
573 // First run forward migration to get to v17
574 result := node.RunIPFS("repo", "migrate")
575 t.Logf("Forward migration stdout:\n%s", result.Stdout.String())
576 t.Logf("Forward migration stderr:\n%s", result.Stderr.String())
577 require.Empty(t, result.Stderr.String(), "Forward migration should succeed")
578
579 // Verify we're at the latest version
580 versionData, err := os.ReadFile(versionPath)
581 require.NoError(t, err)
582 expectedVersion := fmt.Sprint(ipfs.RepoVersion)
583 require.Equal(t, expectedVersion, strings.TrimSpace(string(versionData)), "Should be at version %s (latest) after forward migration", expectedVersion)
584
585 // Now run reverse migration back to v16
586 result = node.RunIPFS("repo", "migrate", "--to=16", "--allow-downgrade")
587 t.Logf("Backward migration stdout:\n%s", result.Stdout.String())
588 t.Logf("Backward migration stderr:\n%s", result.Stderr.String())
589 require.Empty(t, result.Stderr.String(), "Reverse migration should succeed")
590
591 // Verify version was downgraded to 16
592 versionData, err = os.ReadFile(versionPath)
593 require.NoError(t, err)
594 require.Equal(t, "16", strings.TrimSpace(string(versionData)), "Version should be downgraded to 16")
595
596 // Verify backward migration results: AutoConf removed and no "auto" values remain
597 NewMigrationTestHelper(t, configPath).
598 RequireFieldAbsent("AutoConf").
599 RequireNoAutoValues()
600 }
601
602 // runDaemonMigrationWithMonitoring starts daemon --migrate, monitors output until "Daemon is ready",
603 // then gracefully shuts down the daemon and returns the captured output and success status.
604 // This monitors for all expected migrations from version 16 to latest.
605 func runDaemonMigrationWithMonitoring(t *testing.T, node *harness.Node) (string, bool) {
606 // Monitor migrations from repo v16 to latest
607 return runDaemonWithExpectedMigrations(t, node, 16, ipfs.RepoVersion)
608 }
609
610 // runDaemonWithExpectedMigrations monitors daemon startup for a sequence of migrations from startVersion to endVersion
611 func runDaemonWithExpectedMigrations(t *testing.T, node *harness.Node, startVersion, endVersion int) (string, bool) {
612 // Build list of expected migrations
613 var expectedMigrations []struct {
614 pattern string
615 success string
616 }
617
618 for v := startVersion; v < endVersion; v++ {
619 from := v
620 to := v + 1
621 expectedMigrations = append(expectedMigrations, struct {
622 pattern string
623 success string
624 }{
625 pattern: fmt.Sprintf("applying %d-to-%d repo migration", from, to),
626 success: fmt.Sprintf("Migration %d-to-%d succeeded", from, to),
627 })
628 }
629
630 return runDaemonWithMultipleMigrationMonitoring(t, node, expectedMigrations)
631 }
632
633 // runDaemonWithMultipleMigrationMonitoring monitors daemon startup for multiple sequential migrations
634 func runDaemonWithMultipleMigrationMonitoring(t *testing.T, node *harness.Node, expectedMigrations []struct {
635 pattern string
636 success string
637 }) (string, bool) {
638 // Create context with timeout as safety net
639 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
640 defer cancel()
641
642 // Set up daemon command with output monitoring
643 cmd := exec.CommandContext(ctx, node.IPFSBin, "daemon", "--migrate")
644 cmd.Dir = node.Dir
645
646 // Set environment (especially IPFS_PATH)
647 for k, v := range node.Runner.Env {
648 cmd.Env = append(cmd.Env, k+"="+v)
649 }
650
651 // Set up pipes for output monitoring
652 stdout, err := cmd.StdoutPipe()
653 require.NoError(t, err)
654 stderr, err := cmd.StderrPipe()
655 require.NoError(t, err)
656
657 // Start the daemon
658 err = cmd.Start()
659 require.NoError(t, err)
660
661 var allOutput strings.Builder
662 var daemonReady bool
663
664 // Track which migrations have been detected
665 migrationsDetected := make([]bool, len(expectedMigrations))
666 migrationsSucceeded := make([]bool, len(expectedMigrations))
667
668 // Monitor stdout for completion signals
669 scanner := bufio.NewScanner(stdout)
670 go func() {
671 for scanner.Scan() {
672 line := scanner.Text()
673 allOutput.WriteString(line + "\n")
674
675 // Check for migration messages
676 for i, migration := range expectedMigrations {
677 if strings.Contains(line, migration.pattern) {
678 migrationsDetected[i] = true
679 }
680 if strings.Contains(line, migration.success) {
681 migrationsSucceeded[i] = true
682 }
683 }
684 if strings.Contains(line, "Daemon is ready") {
685 daemonReady = true
686 break // Exit monitoring loop
687 }
688 }
689 }()
690
691 // Also monitor stderr (but don't use it for completion detection)
692 go func() {
693 stderrScanner := bufio.NewScanner(stderr)
694 for stderrScanner.Scan() {
695 line := stderrScanner.Text()
696 allOutput.WriteString("STDERR: " + line + "\n")
697 }
698 }()
699
700 // Wait for daemon ready signal or timeout
701 ticker := time.NewTicker(100 * time.Millisecond)
702 defer ticker.Stop()
703
704 for {
705 select {
706 case <-ctx.Done():
707 // Timeout - kill the process
708 if cmd.Process != nil {
709 _ = cmd.Process.Kill()
710 }
711 t.Logf("Daemon migration timed out after 60 seconds")
712 return allOutput.String(), false
713
714 case <-ticker.C:
715 if daemonReady {
716 // Daemon is ready - shut it down gracefully
717 shutdownCmd := exec.Command(node.IPFSBin, "shutdown")
718 shutdownCmd.Dir = node.Dir
719 for k, v := range node.Runner.Env {
720 shutdownCmd.Env = append(shutdownCmd.Env, k+"="+v)
721 }
722
723 if err := shutdownCmd.Run(); err != nil {
724 t.Logf("Warning: ipfs shutdown failed: %v", err)
725 // Force kill if graceful shutdown fails
726 if cmd.Process != nil {
727 _ = cmd.Process.Kill()
728 }
729 }
730
731 // Wait for process to exit
732 _ = cmd.Wait()
733
734 // Check all migrations were detected and succeeded
735 allDetected := true
736 allSucceeded := true
737 for i := range expectedMigrations {
738 if !migrationsDetected[i] {
739 allDetected = false
740 t.Logf("Migration %s was not detected", expectedMigrations[i].pattern)
741 }
742 if !migrationsSucceeded[i] {
743 allSucceeded = false
744 t.Logf("Migration %s did not succeed", expectedMigrations[i].success)
745 }
746 }
747
748 return allOutput.String(), allDetected && allSucceeded
749 }
750
751 // Check if process has exited (e.g., due to startup failure after migration)
752 if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
753 // Process exited - migration may have completed but daemon failed to start
754 // This is expected for corrupted config tests
755
756 // Check all migrations status
757 allDetected := true
758 allSucceeded := true
759 for i := range expectedMigrations {
760 if !migrationsDetected[i] {
761 allDetected = false
762 }
763 if !migrationsSucceeded[i] {
764 allSucceeded = false
765 }
766 }
767
768 return allOutput.String(), allDetected && allSucceeded
769 }
770 }
771 }
772 }
773
774 // =============================================================================
775 // TEMP FILE AND BACKUP CLEANUP TESTS
776 // =============================================================================
777
778 // Helper functions for test cleanup assertions
779 func assertNoTempFiles(t *testing.T, dir string, msgAndArgs ...any) {
780 t.Helper()
781 tmpFiles, err := filepath.Glob(filepath.Join(dir, ".tmp-*"))
782 require.NoError(t, err)
783 assert.Empty(t, tmpFiles, msgAndArgs...)
784 }
785
786 func backupPath(configPath string, fromVer, toVer int) string {
787 return fmt.Sprintf("%s.%d-to-%d.bak", configPath, fromVer, toVer)
788 }
789
790 func setupDaemonCmd(ctx context.Context, node *harness.Node, args ...string) *exec.Cmd {
791 cmd := exec.CommandContext(ctx, node.IPFSBin, args...)
792 cmd.Dir = node.Dir
793 for k, v := range node.Runner.Env {
794 cmd.Env = append(cmd.Env, k+"="+v)
795 }
796 return cmd
797 }
798
799 func testNoTempFilesAfterSuccessfulMigration(t *testing.T) {
800 node := setupStaticV16Repo(t)
801
802 // Run successful migration
803 _, migrationSuccess := runDaemonMigrationWithMonitoring(t, node)
804 require.True(t, migrationSuccess, "migration should succeed")
805
806 assertNoTempFiles(t, node.Dir, "no temp files should remain after successful migration")
807 }
808
809 func testNoTempFilesAfterFailedMigration(t *testing.T) {
810 node := setupStaticV16Repo(t)
811
812 // Corrupt config to force migration failure
813 configPath := filepath.Join(node.Dir, "config")
814 corruptedJson := `{"Bootstrap": ["auto",` // Invalid JSON
815 require.NoError(t, os.WriteFile(configPath, []byte(corruptedJson), 0644))
816
817 // Attempt migration (should fail)
818 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
819 defer cancel()
820
821 cmd := setupDaemonCmd(ctx, node, "daemon", "--migrate")
822 output, _ := cmd.CombinedOutput()
823 t.Logf("Failed migration output: %s", output)
824
825 assertNoTempFiles(t, node.Dir, "no temp files should remain after failed migration")
826 }
827
828 func testBackupFilesPersistAfterSuccessfulMigration(t *testing.T) {
829 node := setupStaticV16Repo(t)
830
831 // Run migration from v16 to latest (v18)
832 _, migrationSuccess := runDaemonMigrationWithMonitoring(t, node)
833 require.True(t, migrationSuccess, "migration should succeed")
834
835 // Check for backup files from each migration step
836 configPath := filepath.Join(node.Dir, "config")
837 backup16to17 := backupPath(configPath, 16, 17)
838 backup17to18 := backupPath(configPath, 17, 18)
839
840 // Both backup files should exist
841 assert.FileExists(t, backup16to17, "16-to-17 backup should exist")
842 assert.FileExists(t, backup17to18, "17-to-18 backup should exist")
843
844 // Verify backup files contain valid JSON
845 data16to17, err := os.ReadFile(backup16to17)
846 require.NoError(t, err)
847 var config16to17 map[string]any
848 require.NoError(t, json.Unmarshal(data16to17, &config16to17), "16-to-17 backup should be valid JSON")
849
850 data17to18, err := os.ReadFile(backup17to18)
851 require.NoError(t, err)
852 var config17to18 map[string]any
853 require.NoError(t, json.Unmarshal(data17to18, &config17to18), "17-to-18 backup should be valid JSON")
854 }
855
856 func testBackupFilesCanRevertMigration(t *testing.T) {
857 node := setupStaticV16Repo(t)
858
859 configPath := filepath.Join(node.Dir, "config")
860 versionPath := filepath.Join(node.Dir, "version")
861
862 // Read original v16 config
863 originalConfig, err := os.ReadFile(configPath)
864 require.NoError(t, err)
865
866 // Migrate to v17 only
867 result := node.RunIPFS("repo", "migrate", "--to=17")
868 require.Empty(t, result.Stderr.String(), "migration to v17 should succeed")
869
870 // Verify backup exists
871 backup16to17 := backupPath(configPath, 16, 17)
872 assert.FileExists(t, backup16to17, "16-to-17 backup should exist")
873
874 // Manually revert using backup
875 backupData, err := os.ReadFile(backup16to17)
876 require.NoError(t, err)
877 require.NoError(t, os.WriteFile(configPath, backupData, 0600))
878 require.NoError(t, os.WriteFile(versionPath, []byte("16"), 0644))
879
880 // Verify config matches original
881 revertedConfig, err := os.ReadFile(configPath)
882 require.NoError(t, err)
883 assert.JSONEq(t, string(originalConfig), string(revertedConfig), "reverted config should match original")
884
885 // Verify version is back to 16
886 versionData, err := os.ReadFile(versionPath)
887 require.NoError(t, err)
888 assert.Equal(t, "16", strings.TrimSpace(string(versionData)), "version should be reverted to 16")
889 }
890
891 func testConversionFailureCleanup(t *testing.T) {
892 // This test verifies that when a migration's conversion function fails,
893 // all temporary files are cleaned up properly
894 node := setupStaticV16Repo(t)
895
896 configPath := filepath.Join(node.Dir, "config")
897
898 // Create a corrupted config that will cause conversion to fail during JSON parsing
899 // The migration will read this, attempt to parse as JSON, and fail
900 corruptedJson := `{"Bootstrap": ["auto",` // Invalid JSON - missing closing bracket
901 require.NoError(t, os.WriteFile(configPath, []byte(corruptedJson), 0644))
902
903 // Attempt migration (should fail during conversion)
904 result := node.RunIPFS("repo", "migrate")
905 require.NotEmpty(t, result.Stderr.String(), "migration should fail with error")
906
907 assertNoTempFiles(t, node.Dir, "no temp files should remain after conversion failure")
908
909 // Verify no backup files were created (failure happened before backup)
910 backupFiles, err := filepath.Glob(filepath.Join(node.Dir, "config.*.bak"))
911 require.NoError(t, err)
912 assert.Empty(t, backupFiles, "no backup files should be created on conversion failure")
913
914 // Verify corrupted config is unchanged (atomic operations prevented overwrite)
915 currentConfig, err := os.ReadFile(configPath)
916 require.NoError(t, err)
917 assert.Equal(t, corruptedJson, string(currentConfig), "corrupted config should remain unchanged")
918 }