//! IG-M6: Store migration tests. use karapace_store::{ migrate_store, EnvState, LayerKind, LayerManifest, LayerStore, MetadataStore, ObjectStore, StoreLayout, STORE_FORMAT_VERSION, }; use std::fs; use std::path::Path; /// Create a v1-format store with the given number of metadata files. fn create_v1_store(root: &Path, num_envs: usize) { let store_dir = root.join("store"); fs::create_dir_all(store_dir.join("objects")).unwrap(); fs::create_dir_all(store_dir.join("layers")).unwrap(); fs::create_dir_all(store_dir.join("metadata")).unwrap(); fs::create_dir_all(store_dir.join("staging")).unwrap(); fs::create_dir_all(root.join("env")).unwrap(); // Write v1 version file fs::write(store_dir.join("version"), r#"{"format_version": 1}"#).unwrap(); // Write v1-format metadata (missing v2 fields: name, checksum, policy_layer) for i in 0..num_envs { let env_id = format!("env_{i:04}"); let meta_json = serde_json::json!({ "env_id": env_id, "short_id": &env_id[..8], "state": "Built", "manifest_hash": format!("mhash_{i}"), "base_layer": format!("blayer_{i}"), "dependency_layers": [], "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "ref_count": 1 }); fs::write( store_dir.join("metadata").join(&env_id), serde_json::to_string_pretty(&meta_json).unwrap(), ) .unwrap(); } } #[test] fn migrate_v1_store_to_v2() { let dir = tempfile::tempdir().unwrap(); create_v1_store(dir.path(), 2); let result = migrate_store(dir.path()).unwrap(); assert!(result.is_some(), "migration must return Some for v1→v2"); let result = result.unwrap(); assert_eq!(result.from_version, 1); assert_eq!(result.to_version, STORE_FORMAT_VERSION); assert_eq!(result.environments_migrated, 2); // Verify version file now says v2 let layout = StoreLayout::new(dir.path()); layout.verify_version().unwrap(); // Both metadata files must be readable by current MetadataStore let meta_store = MetadataStore::new(layout); let m0 = meta_store.get("env_0000").unwrap(); assert_eq!(m0.env_id.as_str(), "env_0000"); assert_eq!(m0.state, EnvState::Built); let m1 = meta_store.get("env_0001").unwrap(); assert_eq!(m1.env_id.as_str(), "env_0001"); } #[test] fn migrate_preserves_all_metadata_fields() { let dir = tempfile::tempdir().unwrap(); create_v1_store(dir.path(), 1); migrate_store(dir.path()).unwrap(); let layout = StoreLayout::new(dir.path()); let meta_store = MetadataStore::new(layout); let meta = meta_store.get("env_0000").unwrap(); // Original fields preserved assert_eq!(meta.env_id.as_str(), "env_0000"); assert_eq!(meta.short_id.as_str(), "env_0000"); assert_eq!(meta.state, EnvState::Built); assert_eq!(meta.manifest_hash.as_str(), "mhash_0"); assert_eq!(meta.base_layer.as_str(), "blayer_0"); assert!(meta.dependency_layers.is_empty()); assert_eq!(meta.created_at, "2025-01-01T00:00:00Z"); assert_eq!(meta.ref_count, 1); // v2 defaults added assert_eq!(meta.name, None); assert_eq!(meta.policy_layer, None); } #[test] fn migrate_preserves_objects_and_layers() { let dir = tempfile::tempdir().unwrap(); // Start with a normal v2 store to create real objects and layers let layout = StoreLayout::new(dir.path()); layout.initialize().unwrap(); let obj_store = ObjectStore::new(layout.clone()); let layer_store = LayerStore::new(layout.clone()); let h1 = obj_store.put(b"object data 1").unwrap(); let h2 = obj_store.put(b"object data 2").unwrap(); let h3 = obj_store.put(b"object data 3").unwrap(); let layer = LayerManifest { hash: "test_layer".to_owned(), kind: LayerKind::Base, parent: None, object_refs: vec![h1.clone(), h2.clone()], read_only: true, tar_hash: String::new(), }; let lh1 = layer_store.put(&layer).unwrap(); let layer2 = LayerManifest { hash: "test_layer2".to_owned(), kind: LayerKind::Snapshot, parent: Some(lh1.clone()), object_refs: vec![h3.clone()], read_only: false, tar_hash: String::new(), }; let lh2 = layer_store.put(&layer2).unwrap(); // Downgrade version file to v1 fs::write( dir.path().join("store").join("version"), r#"{"format_version": 1}"#, ) .unwrap(); // Run migration migrate_store(dir.path()).unwrap(); // Verify all objects intact let obj_store2 = ObjectStore::new(StoreLayout::new(dir.path())); assert_eq!(obj_store2.get(&h1).unwrap(), b"object data 1"); assert_eq!(obj_store2.get(&h2).unwrap(), b"object data 2"); assert_eq!(obj_store2.get(&h3).unwrap(), b"object data 3"); // Verify all layers intact let layer_store2 = LayerStore::new(StoreLayout::new(dir.path())); let loaded1 = layer_store2.get(&lh1).unwrap(); assert_eq!(loaded1.object_refs.len(), 2); let loaded2 = layer_store2.get(&lh2).unwrap(); assert_eq!(loaded2.kind, LayerKind::Snapshot); // Verify store integrity let report = karapace_store::verify_store_integrity(&StoreLayout::new(dir.path())).unwrap(); assert!( report.failed.is_empty(), "store integrity must pass after migration, failures: {:?}", report.failed ); } #[test] fn migrate_creates_backup() { let dir = tempfile::tempdir().unwrap(); create_v1_store(dir.path(), 0); let result = migrate_store(dir.path()).unwrap().unwrap(); assert!(result.backup_path.exists(), "backup file must exist"); // Backup must contain v1 let backup_content = fs::read_to_string(&result.backup_path).unwrap(); assert!( backup_content.contains("\"format_version\": 1") || backup_content.contains("\"format_version\":1"), "backup must contain format_version 1, got: {backup_content}" ); // Current version must be v2 let current = fs::read_to_string(dir.path().join("store").join("version")).unwrap(); assert!( current.contains(&format!("{STORE_FORMAT_VERSION}")), "version file must now be v{STORE_FORMAT_VERSION}" ); } #[test] fn migrate_idempotent_on_current_version() { let dir = tempfile::tempdir().unwrap(); let layout = StoreLayout::new(dir.path()); layout.initialize().unwrap(); let result = migrate_store(dir.path()).unwrap(); assert!( result.is_none(), "migrate on current-version store must return None" ); // Store unmodified layout.verify_version().unwrap(); } #[test] fn migrate_rejects_future_version() { let dir = tempfile::tempdir().unwrap(); let store_dir = dir.path().join("store"); fs::create_dir_all(&store_dir).unwrap(); fs::write(store_dir.join("version"), r#"{"format_version": 99}"#).unwrap(); let result = migrate_store(dir.path()); assert!(result.is_err(), "future version must be rejected"); let err_msg = format!("{}", result.unwrap_err()); assert!( err_msg.contains("mismatch") || err_msg.contains("Mismatch"), "error must mention version mismatch, got: {err_msg}" ); } #[test] fn migrate_atomic_version_unchanged_on_write_failure() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); create_v1_store(dir.path(), 1); let store_dir = dir.path().join("store"); // Make the store directory non-writable so the final NamedTempFile::new_in(&store_dir) // fails. Metadata migration writes into metadata/ (still writable) but the version // file write into store/ will fail. let original_mode = fs::metadata(&store_dir).unwrap().permissions().mode(); // Remove write permission from store/ dir (keep read+exec for traversal) fs::set_permissions(&store_dir, fs::Permissions::from_mode(0o555)).unwrap(); let result = migrate_store(dir.path()); // Restore permissions for cleanup fs::set_permissions(&store_dir, fs::Permissions::from_mode(original_mode)).unwrap(); // Migration MUST have failed — the version file write requires creating a temp file in store/ assert!( result.is_err(), "migration must fail when store dir is read-only — test is invalid if it succeeds" ); // Version file MUST still say v1 let ver_content = fs::read_to_string(dir.path().join("store").join("version")).unwrap(); assert!( ver_content.contains("\"format_version\": 1") || ver_content.contains("\"format_version\":1"), "version must still be v1 after failed migration, got: {ver_content}" ); // No partial version.backup files should exist (backup also writes to store/) let backup_files: Vec<_> = fs::read_dir(&store_dir) .unwrap() .filter_map(Result::ok) .filter(|e| { e.file_name() .to_string_lossy() .starts_with("version.backup") }) .collect(); // Backup may or may not exist depending on where exactly the failure occurred, // but version must be unchanged regardless. let _ = backup_files; } #[test] fn migrate_corrupted_metadata_fails_and_store_untouched() { let dir = tempfile::tempdir().unwrap(); let store_dir = dir.path().join("store"); // Create a minimal v1 store with a corrupted metadata file fs::create_dir_all(store_dir.join("objects")).unwrap(); fs::create_dir_all(store_dir.join("layers")).unwrap(); fs::create_dir_all(store_dir.join("metadata")).unwrap(); fs::create_dir_all(store_dir.join("staging")).unwrap(); fs::create_dir_all(dir.path().join("env")).unwrap(); fs::write(store_dir.join("version"), r#"{"format_version": 1}"#).unwrap(); // Write corrupted metadata: not a JSON object (it's an array) fs::write(store_dir.join("metadata").join("corrupt_env"), "[1, 2, 3]").unwrap(); // Migration should succeed (corrupt files are warned+skipped) but report 0 migrated let result = migrate_store(dir.path()).unwrap(); assert!(result.is_some()); let result = result.unwrap(); assert_eq!( result.environments_migrated, 0, "corrupted metadata must not count as migrated" ); // The corrupted file must still exist and be unchanged let corrupt_content = fs::read_to_string(store_dir.join("metadata").join("corrupt_env")).unwrap(); assert_eq!( corrupt_content, "[1, 2, 3]", "corrupted file must be untouched" ); // Version file must now be v2 (migration itself succeeded, only metadata was skipped) let layout = StoreLayout::new(dir.path()); layout.verify_version().unwrap(); } #[test] fn migrate_invalid_json_metadata_skipped() { let dir = tempfile::tempdir().unwrap(); let store_dir = dir.path().join("store"); fs::create_dir_all(store_dir.join("objects")).unwrap(); fs::create_dir_all(store_dir.join("layers")).unwrap(); fs::create_dir_all(store_dir.join("metadata")).unwrap(); fs::create_dir_all(store_dir.join("staging")).unwrap(); fs::create_dir_all(dir.path().join("env")).unwrap(); fs::write(store_dir.join("version"), r#"{"format_version": 1}"#).unwrap(); // Write totally invalid JSON fs::write( store_dir.join("metadata").join("broken_env"), "THIS IS NOT JSON AT ALL {{{", ) .unwrap(); // Also write a valid v1 metadata file let valid_meta = serde_json::json!({ "env_id": "valid_env", "short_id": "valid_en", "state": "Built", "manifest_hash": "mh", "base_layer": "bl", "dependency_layers": [], "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "ref_count": 1 }); fs::write( store_dir.join("metadata").join("valid_env"), serde_json::to_string_pretty(&valid_meta).unwrap(), ) .unwrap(); let result = migrate_store(dir.path()).unwrap().unwrap(); // Only the valid one should be migrated assert_eq!(result.environments_migrated, 1); // Invalid file must still exist, unchanged let broken = fs::read_to_string(store_dir.join("metadata").join("broken_env")).unwrap(); assert_eq!(broken, "THIS IS NOT JSON AT ALL {{{"); // Valid file must now have v2 fields let valid = fs::read_to_string(store_dir.join("metadata").join("valid_env")).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&valid).unwrap(); assert!( parsed.get("name").is_some(), "v2 'name' field must be present" ); assert!( parsed.get("checksum").is_some(), "v2 'checksum' field must be present" ); assert!( parsed.get("policy_layer").is_some(), "v2 'policy_layer' field must be present" ); }