migrate_test.v

10.4 KB · raw

1//! Migration tests, run against a fixture made by the Koh 2 binary itself.2//!3//! The fixture is why these are worth having: it is not a guess at Koh 2's4//! format, it is the format, including the inlined small files and the object5//! store. When `koh` (Koh 2) is not on the machine these tests skip rather6//! than pretend.78module migrate910import json211import os12import src.events13import src.history14import src.snapshot15import src.storage1617struct Fixture {18	dir     string19	first   string20	second  string21	message string22}2324// koh2_binary finds Koh 2: `koh2` since the cutover, `koh` before it, and never Koh 3.25fn koh2_binary() ?string {26	for name in ['koh2', 'koh'] {27		path := os.find_abs_path_of_executable(name) or { continue }28		if os.execute('${os.quoted_path(path)} --version').output.starts_with('koh v2') {29			return path30		}31	}32	return none33}3435fn sh(dir string, command string) !string {36	result := os.execute('cd ${dir} && ${command}')37	if result.exit_code != 0 {38		return error('${command} failed: ${result.output}')39	}40	return result.output41}4243fn make_fixture(koh2 string) !Fixture {44	dir := os.join_path(os.temp_dir(), 'koh3_migrate_${os.getpid()}')45	os.rmdir_all(dir) or {}46	os.mkdir_all(dir, os.MkdirParams{})!47	sh(dir, '${koh2} init')!48	// One file big enough for the object store, one small enough to be inlined.49	os.write_file(os.join_path(dir, 'big.txt'), 'x'.repeat(700))!50	os.write_file(os.join_path(dir, 'small.txt'), 'tiny\n')!51	sh(dir, '${koh2} save "first save"')!52	sh(dir, '${koh2} checkpoint "before something"')!53	os.write_file(os.join_path(dir, 'big.txt'), 'y'.repeat(700))!54	sh(dir, '${koh2} save "second save"')!5556	// Identify the first save by its message: koh2 timestamps are whole seconds,57	// so saves made in a row share one, and a filename is a hash. The head comes58	// from Koh 2's own state for the same reason.59	mut first := ''60	for name in os.ls(os.join_path(dir, '.koh', 'snapshots'))! {61		if !name.ends_with('.json') {62			continue63		}64		record :=65			json2.decode[Manifest](os.read_file(os.join_path(dir, '.koh', 'snapshots', name))!)!66		if record.message == 'first save' {67			first = record.id68		}69	}70	assert first != ''71	return Fixture{72		dir:     dir73		first:   first74		second:  koh2_head(dir)75		message: 'first save'76	}77}7879// koh2_head reads the lane head Koh 2 recorded.80fn koh2_head(dir string) string {81	db := storage.connect(os.join_path(dir, '.koh', 'koh.db'), 1000) or { return '' }82	defer {83		db.close()84	}85	mut stmt := db.prepare('SELECT value FROM state WHERE key = ?') or { return '' }86	defer {87		stmt.finalize()88	}89	stmt.bind_text(1, 'head_main') or { return '' }90	if stmt.step() or { return '' } {91		return stmt.text(0)92	}93	return ''94}9596fn test_migrating_a_koh2_repo_keeps_every_save_exactly() {97	koh2 := koh2_binary() or { return }98	fixture := make_fixture(koh2)!99	defer {100		os.rmdir_all(fixture.dir) or {}101	}102	// Flag the first save, so a save-level flag has to become an event.103	sh(fixture.dir, '${koh2} flag ${fixture.first} --reason "sketchy"')!104105	// What the manifests say, read independently of the migration.106	mut manifests := map[string][]string{}107	for name in os.ls(os.join_path(fixture.dir, '.koh', 'snapshots'))! {108		if !name.ends_with('.json') {109			continue110		}111		record := json2.decode[Manifest](os.read_file(os.join_path(fixture.dir, '.koh', 'snapshots', name))!)!112		mut files := []string{}113		for file in record.files {114			files << '${file.path} ${bare_hash(file.hash)}'115		}116		manifests[record.id] = files117	}118119	report := run(fixture.dir, default_lane)!120	// The fixture lives on main, because that is the lane koh2 starts on.121	assert report.lane == 'main'122	assert report.saves == 3123	assert report.checkpoints == 1124	assert report.flags == 1125	assert report.objects >= 3126127	// The swap happened: `.koh` is a file now and the old data is archived.128	assert os.is_file(os.join_path(fixture.dir, '.koh'))129	assert os.is_dir(os.join_path(fixture.dir, '.koh-v2'))130	assert os.is_file(os.join_path(fixture.dir, '.kohignore'))131132	store := storage.open(fixture.dir)!133	defer {134		store.close()135	}136	// Every save kept its ID, its files and their hashes.137	assert history.save_count(store)! == 3138	for id, expected in manifests {139		save := history.by_id(store, id) or {140			assert false, 'save ${id} did not survive the migration'141			return142		}143		assert save.machine == 'koh2'144		mut actual := []string{}145		for file in history.files_of(store, id)! {146			actual << '${file.path} ${file.hash}'147			// Every object is present and hashes back to its key.148			if _ := store.get_object(file.hash) {149			} else {150				assert false, 'object ${file.hash} is missing or corrupt'151			}152		}153		assert actual == expected154	}155	// The checkpoint mark and the parent chain came across.156	middle := history.by_id(store, fixture.first) or { panic('the first save did not survive') }157	assert (history.head(store) or { panic('no head after migrating') }).id == fixture.second158	assert middle.message == 'first save'159160	// The flag became an event, and the working tree is exactly what Koh 3161	// expects once the carried ignore file is saved.162	assert events.flag_state(store, fixture.first)!.flagged163	assert events.flag_state(store, fixture.first)!.reason == 'sketchy'164	entries := snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))!165	mut changed := []string{}166	head_files := snapshot.index_files(history.files_of(store, fixture.second)!)167	for entry in entries {168		if entry.path !in head_files {169			changed << entry.path170		} else if head_files[entry.path].hash != entry.hash {171			changed << entry.path172		}173	}174	// The only difference is the ignore file Koh 3 just carried to the root.175	assert changed == ['.kohignore']176}177178// §13: the migration renames `kepr.build` to `koh.build`, and records the179// rename as a save, so the tree it leaves behind holds no unreviewed work.180fn test_migration_renames_the_koh_2_build_file() {181	koh2 := koh2_binary() or { return }182	fixture := make_fixture(koh2)!183	defer {184		os.rmdir_all(fixture.dir) or {}185	}186	build_file := 'command = true\noutput = out/demo\nplatforms = macos-aarch64\n'187	os.write_file(os.join_path(fixture.dir, 'kepr.build'), build_file)!188	sh(fixture.dir, '${koh2} save "add the build file"')!189190	report := run(fixture.dir, default_lane)!191	assert report.build_renamed192	assert !os.exists(os.join_path(fixture.dir, 'kepr.build'))193	assert os.read_file(os.join_path(fixture.dir, 'koh.build'))! == build_file194195	store := storage.open(fixture.dir)!196	defer {197		store.close()198	}199	head := history.head(store) or { panic('no head after migrating') }200	assert head.message == 'migrate: rename kepr.build to koh.build'201	files := snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))!202	assert snapshot.same_tree(history.files_of(store, head.id)!, files)203}204205// A repo holding both build files is refused in the check phase, while the206// Koh 2 data is still where it was.207fn test_migration_refuses_two_build_files_without_moving_anything() {208	koh2 := koh2_binary() or { return }209	fixture := make_fixture(koh2)!210	defer {211		os.rmdir_all(fixture.dir) or {}212	}213	os.write_file(os.join_path(fixture.dir, 'kepr.build'), 'command = true\n')!214	os.write_file(os.join_path(fixture.dir, 'koh.build'), 'command = true\n')!215	sh(fixture.dir, '${koh2} save "both build files"')!216	if _ := run(fixture.dir, default_lane) {217		assert false, 'both build files must be refused'218	} else {219		assert err.msg().contains('both kepr.build and koh.build')220	}221	assert os.is_dir(os.join_path(fixture.dir, '.koh'))222	assert !os.exists(os.join_path(fixture.dir, '.koh-v2'))223}224225// A repo with no Koh 2 build file migrates exactly as it always did.226fn test_migration_without_a_build_file_is_unchanged() {227	koh2 := koh2_binary() or { return }228	fixture := make_fixture(koh2)!229	defer {230		os.rmdir_all(fixture.dir) or {}231	}232	report := run(fixture.dir, default_lane)!233	assert !report.build_renamed234	assert !os.exists(os.join_path(fixture.dir, 'koh.build'))235	assert os.is_dir(os.join_path(fixture.dir, '.koh-v2'))236	store := storage.open(fixture.dir)!237	defer {238		store.close()239	}240	head := history.head(store) or { panic('no head after migrating') }241	// The migration carries Koh 2's ignore rules to a root .kohignore, so the242	// tree differs from the imported head by exactly that file — which is what243	// the report tells the operator to save.244	saved := history.files_of(store, head.id)!245	mut unsaved := []string{}246	for file in snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))! {247		if !saved.any(it.path == file.path) {248			unsaved << file.path249		}250	}251	assert unsaved == [snapshot.ignore_file]252}253254fn test_migrate_refuses_a_tree_that_does_not_match_the_last_save() {255	koh2 := koh2_binary() or { return }256	fixture := make_fixture(koh2)!257	defer {258		os.rmdir_all(fixture.dir) or {}259	}260	os.write_file(os.join_path(fixture.dir, 'big.txt'), 'unsaved change\n')!261	if _ := run(fixture.dir, default_lane) {262		assert false, 'a dirty tree must be refused'263	} else {264		assert err.msg().contains('save with koh2 first')265	}266	// Nothing was touched: still a Koh 2 repo, no archive, no draft file.267	assert os.is_dir(os.join_path(fixture.dir, '.koh'))268	assert !os.exists(os.join_path(fixture.dir, '.koh-v2'))269	assert !os.exists(os.join_path(fixture.dir, draft_file))270}271272// A damaged manifest naming a path outside the repo must never be read there.273fn test_a_saved_path_is_only_read_inside_the_repo() {274	root := os.join_path(os.temp_dir(), 'koh3_migrate_paths_${os.getpid()}', 'repo')275	os.mkdir_all(root)!276	defer {277		os.rmdir_all(os.dir(root)) or {}278	}279	os.write_file(os.join_path(os.dir(root), 'outside.txt'), 'secret\n')!280	os.write_file(os.join_path(root, 'inside.txt'), 'fine\n')!281	assert disk_hash(root, 'inside.txt') != none282	for path in ['../outside.txt', '/etc/hosts', './inside.txt', 'a//inside.txt'] {283		assert disk_hash(root, path) == none, path284	}285}286287fn test_disk_hash_uses_the_shared_path_rule() {288	root := os.join_path(os.temp_dir(), 'koh3_migrate_rule_${os.getpid()}')289	os.mkdir_all(root)!290	defer {291		os.rmdir_all(root) or {}292	}293	for name in ['back\\slash', 'Icon\r'] {294		// Joined by hand: os.join_path turns a backslash into a separator.295		os.write_file('${root}/${name}', 'odd\n')!296		assert disk_hash(root, name) == none, name297	}298}299300fn test_unsafe_manifest_paths_are_dropped_and_named_once() {301	mut line := [302		Manifest{303			id:    'a'304			files: [ManifestFile{305				path: 'ok.txt'306			}, ManifestFile{307				path: 'Icon\r'308			}]309		},310		Manifest{311			id:    'b'312			files: [ManifestFile{313				path: 'Icon\r'314			}, ManifestFile{315				path: '../x'316			}]317		},318	]319	assert drop_unsafe_paths(mut line) == ['../x', 'Icon\\x0d']320	assert line[0].files.map(it.path) == ['ok.txt']321	assert line[1].files.len == 0322}