migrate_test.v

9.3 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}2324fn koh2_binary() ?string {25	path := os.find_abs_path_of_executable('koh') or { return none }26	return path27}2829fn sh(dir string, command string) !string {30	result := os.execute('cd ${dir} && ${command}')31	if result.exit_code != 0 {32		return error('${command} failed: ${result.output}')33	}34	return result.output35}3637fn make_fixture(koh2 string) !Fixture {38	dir := os.join_path(os.temp_dir(), 'koh3_migrate_${os.getpid()}')39	os.rmdir_all(dir) or {}40	os.mkdir_all(dir, os.MkdirParams{})!41	sh(dir, '${koh2} init')!42	// One file big enough for the object store, one small enough to be inlined.43	os.write_file(os.join_path(dir, 'big.txt'), 'x'.repeat(700))!44	os.write_file(os.join_path(dir, 'small.txt'), 'tiny\n')!45	sh(dir, '${koh2} save "first save"')!46	sh(dir, '${koh2} checkpoint "before something"')!47	os.write_file(os.join_path(dir, 'big.txt'), 'y'.repeat(700))!48	sh(dir, '${koh2} save "second save"')!4950	// Identify the first save by its message: koh2 timestamps are whole seconds,51	// so saves made in a row share one, and a filename is a hash. The head comes52	// from Koh 2's own state for the same reason.53	mut first := ''54	for name in os.ls(os.join_path(dir, '.koh', 'snapshots'))! {55		if !name.ends_with('.json') {56			continue57		}58		record :=59			json2.decode[Manifest](os.read_file(os.join_path(dir, '.koh', 'snapshots', name))!)!60		if record.message == 'first save' {61			first = record.id62		}63	}64	assert first != ''65	return Fixture{66		dir:     dir67		first:   first68		second:  koh2_head(dir)69		message: 'first save'70	}71}7273// koh2_head reads the lane head Koh 2 recorded.74fn koh2_head(dir string) string {75	db := storage.connect(os.join_path(dir, '.koh', 'koh.db'), 1000) or { return '' }76	defer {77		db.close()78	}79	mut stmt := db.prepare('SELECT value FROM state WHERE key = ?') or { return '' }80	defer {81		stmt.finalize()82	}83	stmt.bind_text(1, 'head_main') or { return '' }84	if stmt.step() or { return '' } {85		return stmt.text(0)86	}87	return ''88}8990fn test_migrating_a_koh2_repo_keeps_every_save_exactly() {91	koh2 := koh2_binary() or { return }92	fixture := make_fixture(koh2)!93	defer {94		os.rmdir_all(fixture.dir) or {}95	}96	// Flag the first save, so a save-level flag has to become an event.97	sh(fixture.dir, '${koh2} flag ${fixture.first} --reason "sketchy"')!9899	// What the manifests say, read independently of the migration.100	mut manifests := map[string][]string{}101	for name in os.ls(os.join_path(fixture.dir, '.koh', 'snapshots'))! {102		if !name.ends_with('.json') {103			continue104		}105		record := json2.decode[Manifest](os.read_file(os.join_path(fixture.dir, '.koh', 'snapshots', name))!)!106		mut files := []string{}107		for file in record.files {108			files << '${file.path} ${bare_hash(file.hash)}'109		}110		manifests[record.id] = files111	}112113	report := run(fixture.dir, default_lane)!114	// The fixture lives on main, because that is the lane koh2 starts on.115	assert report.lane == 'main'116	assert report.saves == 3117	assert report.checkpoints == 1118	assert report.flags == 1119	assert report.objects >= 3120121	// The swap happened: `.koh` is a file now and the old data is archived.122	assert os.is_file(os.join_path(fixture.dir, '.koh'))123	assert os.is_dir(os.join_path(fixture.dir, '.koh-v2'))124	assert os.is_file(os.join_path(fixture.dir, '.kohignore'))125126	store := storage.open(fixture.dir)!127	defer {128		store.close()129	}130	// Every save kept its ID, its files and their hashes.131	assert history.save_count(store)! == 3132	for id, expected in manifests {133		save := history.by_id(store, id) or {134			assert false, 'save ${id} did not survive the migration'135			return136		}137		assert save.machine == 'koh2'138		mut actual := []string{}139		for file in history.files_of(store, id)! {140			actual << '${file.path} ${file.hash}'141			// Every object is present and hashes back to its key.142			if _ := store.get_object(file.hash) {143			} else {144				assert false, 'object ${file.hash} is missing or corrupt'145			}146		}147		assert actual == expected148	}149	// The checkpoint mark and the parent chain came across.150	middle := history.by_id(store, fixture.first) or { panic('the first save did not survive') }151	assert (history.head(store) or { panic('no head after migrating') }).id == fixture.second152	assert middle.message == 'first save'153154	// The flag became an event, and the working tree is exactly what Koh 3155	// expects once the carried ignore file is saved.156	assert events.flag_state(store, fixture.first)!.flagged157	assert events.flag_state(store, fixture.first)!.reason == 'sketchy'158	entries := snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))!159	mut changed := []string{}160	head_files := snapshot.index_files(history.files_of(store, fixture.second)!)161	for entry in entries {162		if entry.path !in head_files {163			changed << entry.path164		} else if head_files[entry.path].hash != entry.hash {165			changed << entry.path166		}167	}168	// The only difference is the ignore file Koh 3 just carried to the root.169	assert changed == ['.kohignore']170}171172// §13: the migration renames `kepr.build` to `koh.build`, and records the173// rename as a save, so the tree it leaves behind holds no unreviewed work.174fn test_migration_renames_the_koh_2_build_file() {175	koh2 := koh2_binary() or { return }176	fixture := make_fixture(koh2)!177	defer {178		os.rmdir_all(fixture.dir) or {}179	}180	build_file := 'command = true\noutput = out/demo\nplatforms = macos-aarch64\n'181	os.write_file(os.join_path(fixture.dir, 'kepr.build'), build_file)!182	sh(fixture.dir, '${koh2} save "add the build file"')!183184	report := run(fixture.dir, default_lane)!185	assert report.build_renamed186	assert !os.exists(os.join_path(fixture.dir, 'kepr.build'))187	assert os.read_file(os.join_path(fixture.dir, 'koh.build'))! == build_file188189	store := storage.open(fixture.dir)!190	defer {191		store.close()192	}193	head := history.head(store) or { panic('no head after migrating') }194	assert head.message == 'migrate: rename kepr.build to koh.build'195	files := snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))!196	assert snapshot.same_tree(history.files_of(store, head.id)!, files)197}198199// A repo holding both build files is refused in the check phase, while the200// Koh 2 data is still where it was.201fn test_migration_refuses_two_build_files_without_moving_anything() {202	koh2 := koh2_binary() or { return }203	fixture := make_fixture(koh2)!204	defer {205		os.rmdir_all(fixture.dir) or {}206	}207	os.write_file(os.join_path(fixture.dir, 'kepr.build'), 'command = true\n')!208	os.write_file(os.join_path(fixture.dir, 'koh.build'), 'command = true\n')!209	sh(fixture.dir, '${koh2} save "both build files"')!210	if _ := run(fixture.dir, default_lane) {211		assert false, 'both build files must be refused'212	} else {213		assert err.msg().contains('both kepr.build and koh.build')214	}215	assert os.is_dir(os.join_path(fixture.dir, '.koh'))216	assert !os.exists(os.join_path(fixture.dir, '.koh-v2'))217}218219// A repo with no Koh 2 build file migrates exactly as it always did.220fn test_migration_without_a_build_file_is_unchanged() {221	koh2 := koh2_binary() or { return }222	fixture := make_fixture(koh2)!223	defer {224		os.rmdir_all(fixture.dir) or {}225	}226	report := run(fixture.dir, default_lane)!227	assert !report.build_renamed228	assert !os.exists(os.join_path(fixture.dir, 'koh.build'))229	assert os.is_dir(os.join_path(fixture.dir, '.koh-v2'))230	store := storage.open(fixture.dir)!231	defer {232		store.close()233	}234	head := history.head(store) or { panic('no head after migrating') }235	// The migration carries Koh 2's ignore rules to a root .kohignore, so the236	// tree differs from the imported head by exactly that file — which is what237	// the report tells the operator to save.238	saved := history.files_of(store, head.id)!239	mut unsaved := []string{}240	for file in snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))! {241		if !saved.any(it.path == file.path) {242			unsaved << file.path243		}244	}245	assert unsaved == [snapshot.ignore_file]246}247248fn test_migrate_refuses_a_tree_that_does_not_match_the_last_save() {249	koh2 := koh2_binary() or { return }250	fixture := make_fixture(koh2)!251	defer {252		os.rmdir_all(fixture.dir) or {}253	}254	os.write_file(os.join_path(fixture.dir, 'big.txt'), 'unsaved change\n')!255	if _ := run(fixture.dir, default_lane) {256		assert false, 'a dirty tree must be refused'257	} else {258		assert err.msg().contains('save with koh2 first')259	}260	// Nothing was touched: still a Koh 2 repo, no archive, no draft file.261	assert os.is_dir(os.join_path(fixture.dir, '.koh'))262	assert !os.exists(os.join_path(fixture.dir, '.koh-v2'))263	assert !os.exists(os.join_path(fixture.dir, draft_file))264}265266// A damaged manifest naming a path outside the repo must never be read there.267fn test_a_saved_path_is_only_read_inside_the_repo() {268	root := os.join_path(os.temp_dir(), 'koh3_migrate_paths_${os.getpid()}', 'repo')269	os.mkdir_all(root)!270	defer {271		os.rmdir_all(os.dir(root)) or {}272	}273	os.write_file(os.join_path(os.dir(root), 'outside.txt'), 'secret\n')!274	os.write_file(os.join_path(root, 'inside.txt'), 'fine\n')!275	assert disk_hash(root, 'inside.txt') != none276	for path in ['../outside.txt', '/etc/hosts', './inside.txt', 'a//inside.txt'] {277		assert disk_hash(root, path) == none, path278	}279}