feat(cluster): implement distributed clustering with etcd coordination
- Integrate etcd-client for distributed coordination and leader election - Add remote client macros with proper formatting for all services - Implement RequestMetrics for tracking RPC performance and errors - Add rate limiting mechanism across all service endpoints - Create ElectionRequest and ElectionResult message types for leader election - Add role management with primary/replica switching capabilities - Implement health checker with automatic failover detection - Add repository count metrics for cluster monitoring - Update Cargo.toml with etcd-client and dashmap dependencies - Modify RepoEntry to include read_only flag for replica handling - Implement should_accept_election logic to prevent duplicate elections - Add RoleChangedEvent handling for cluster role updates
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
//! Tests for DiskCache and PackCache.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use gitks::disk_cache::DiskCache;
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
tempfile::tempdir().unwrap().path().to_path_buf()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disk_cache_basic_operations() {
|
||||
let dir = temp_dir();
|
||||
let cache = DiskCache::new(dir.clone(), "test-version".to_string(), 300, true);
|
||||
|
||||
// Ensure state creates latest file
|
||||
let state = cache.ensure_state("test_repo.git").unwrap();
|
||||
assert!(!state.is_empty());
|
||||
|
||||
// Same state on second call
|
||||
let state2 = cache.ensure_state("test_repo.git").unwrap();
|
||||
assert_eq!(state, state2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disk_cache_insert_and_lookup() {
|
||||
let dir = temp_dir();
|
||||
let cache = DiskCache::new(dir.clone(), "test-version".to_string(), 300, true);
|
||||
|
||||
let digest = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
let data = b"test cache data";
|
||||
|
||||
// Insert
|
||||
cache.insert("+gitks-cache/cache", digest, data).unwrap();
|
||||
|
||||
// Lookup
|
||||
let result = cache.lookup("+gitks-cache/cache", digest).unwrap();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap(), data.to_vec());
|
||||
|
||||
// Non-existent key
|
||||
let result = cache.lookup("+gitks-cache/cache", "nonexistent").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disk_cache_invalidation() {
|
||||
let dir = temp_dir();
|
||||
let cache = DiskCache::new(dir.clone(), "test-version".to_string(), 300, true);
|
||||
|
||||
let state1 = cache.ensure_state("test_repo.git").unwrap();
|
||||
|
||||
// Invalidate
|
||||
cache.invalidate_repo("test_repo.git");
|
||||
|
||||
// State should change
|
||||
let state2 = cache.ensure_state("test_repo.git").unwrap();
|
||||
assert_ne!(state1, state2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disk_cache_disabled() {
|
||||
let dir = temp_dir();
|
||||
let cache = DiskCache::new(dir.clone(), "test-version".to_string(), 300, false);
|
||||
|
||||
// All operations should succeed but do nothing
|
||||
let state = cache.ensure_state("test_repo.git").unwrap();
|
||||
assert!(!state.is_empty()); // Returns random value
|
||||
|
||||
let result = cache.lookup("+gitks-cache/cache", "anykey").unwrap();
|
||||
assert!(result.is_none()); // Disabled → always None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disk_cache_lease_guard() {
|
||||
let dir = temp_dir();
|
||||
let cache = DiskCache::new(dir.clone(), "test-version".to_string(), 300, true);
|
||||
|
||||
let state1 = cache.ensure_state("test_repo.git").unwrap();
|
||||
|
||||
let mut lease = cache.create_lease("test_repo.git").unwrap();
|
||||
// Lease exists
|
||||
let pending_dir = dir.join("+gitks-cache/state/test_repo.git/pending");
|
||||
assert!(pending_dir.exists());
|
||||
|
||||
// Commit lease (updates latest)
|
||||
lease.commit();
|
||||
|
||||
// Pending should be cleaned up
|
||||
// (may still have dir but no files)
|
||||
let state2 = cache.ensure_state("test_repo.git").unwrap();
|
||||
assert_ne!(state1, state2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha256_digest_determinism() {
|
||||
let dir = temp_dir();
|
||||
let cache = DiskCache::new(dir, "v1".to_string(), 300, true);
|
||||
cache.ensure_state("repo.git").unwrap();
|
||||
|
||||
let key1 = cache
|
||||
.compute_info_refs_key("repo.git", "upload-pack")
|
||||
.unwrap();
|
||||
let key2 = cache
|
||||
.compute_info_refs_key("repo.git", "upload-pack")
|
||||
.unwrap();
|
||||
assert_eq!(key1, key2);
|
||||
|
||||
// Different protocol should produce different key
|
||||
let key3 = cache
|
||||
.compute_info_refs_key("repo.git", "receive-pack")
|
||||
.unwrap();
|
||||
assert_ne!(key1, key3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_expired() {
|
||||
let dir = temp_dir();
|
||||
// Very short max age for testing
|
||||
let cache = DiskCache::new(dir.clone(), "test-version".to_string(), 1, true);
|
||||
|
||||
let digest = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
cache.insert("+gitks-cache/cache", digest, b"data").unwrap();
|
||||
|
||||
// Should be available immediately
|
||||
let result = cache.lookup("+gitks-cache/cache", digest).unwrap();
|
||||
assert!(result.is_some());
|
||||
|
||||
// Wait for expiration
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
|
||||
// Should be expired now
|
||||
let result = cache.lookup("+gitks-cache/cache", digest).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_startup_cleanup() {
|
||||
let dir = temp_dir();
|
||||
let cache = DiskCache::new(dir.clone(), "test-version".to_string(), 300, true);
|
||||
|
||||
// Write some cache data
|
||||
cache
|
||||
.insert("+gitks-cache/cache", "abc123", b"test")
|
||||
.unwrap();
|
||||
assert!(
|
||||
dir.join("+gitks-cache/cache")
|
||||
.join("ab")
|
||||
.join("c123")
|
||||
.exists()
|
||||
);
|
||||
|
||||
// Startup cleanup removes all cache dirs
|
||||
cache.cleanup_on_startup().unwrap();
|
||||
assert!(!dir.join("+gitks-cache/cache").exists());
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Tests for hooks system.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use gitks::hooks::manager::{HookLevel, HookManager};
|
||||
use gitks::hooks::sanitize::{validate_hook_content, validate_hook_name};
|
||||
|
||||
fn temp_repo() -> PathBuf {
|
||||
let dir = tempfile::tempdir().unwrap().path().to_path_buf();
|
||||
// Create a bare git repo
|
||||
let _ = std::process::Command::new("git")
|
||||
.args(["init", "--bare"])
|
||||
.arg(&dir)
|
||||
.output();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_hook_name() {
|
||||
assert!(validate_hook_name("pre-receive").is_ok());
|
||||
assert!(validate_hook_name("update").is_ok());
|
||||
assert!(validate_hook_name("post-receive").is_ok());
|
||||
assert!(validate_hook_name("commit-msg").is_ok());
|
||||
assert!(validate_hook_name("invalid-hook").is_err());
|
||||
assert!(validate_hook_name("").is_err());
|
||||
assert!(validate_hook_name("random-name").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_hook_content_safe() {
|
||||
assert!(validate_hook_content("#!/bin/sh\nexit 0").is_ok());
|
||||
assert!(validate_hook_content("#!/bin/sh\necho 'hello'").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_hook_content_dangerous() {
|
||||
assert!(validate_hook_content("rm -rf /").is_err());
|
||||
assert!(validate_hook_content("shutdown now").is_err());
|
||||
assert!(validate_hook_content("chmod 777 /etc/passwd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_hook_content_size() {
|
||||
let large_content = "x".repeat(70000);
|
||||
assert!(validate_hook_content(&large_content).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_hook_content_null_bytes() {
|
||||
assert!(validate_hook_content("test\0content").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_manager_install_hooks() {
|
||||
let repo = temp_repo();
|
||||
let prefix = repo.parent().unwrap().to_path_buf();
|
||||
let hm = HookManager::new(prefix, None, None, Duration::from_secs(30), true);
|
||||
|
||||
// Install hooks
|
||||
let result = hm.install_hooks(&repo);
|
||||
if result.is_ok() {
|
||||
// Check that hook files were created
|
||||
assert!(repo.join("hooks/pre-receive").exists());
|
||||
assert!(repo.join("hooks/update").exists());
|
||||
assert!(repo.join("hooks/post-receive").exists());
|
||||
}
|
||||
// Installation may fail if git is not available; just check no panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_manager_custom_hooks() {
|
||||
let repo = temp_repo();
|
||||
let prefix = repo.parent().unwrap().to_path_buf();
|
||||
let hm = HookManager::new(prefix, None, None, Duration::from_secs(30), true);
|
||||
|
||||
// Set a custom hook
|
||||
let result = hm.set_custom_hook(&repo, "pre-receive", "#!/bin/sh\nexit 0");
|
||||
if result.is_ok() {
|
||||
assert!(repo.join("custom_hooks/pre-receive/d").exists());
|
||||
}
|
||||
|
||||
// Remove custom hook
|
||||
let result = hm.remove_custom_hook(&repo, "pre-receive");
|
||||
assert!(result.is_ok() || result.is_err()); // Just no panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_manager_disallow_custom() {
|
||||
let repo = temp_repo();
|
||||
let prefix = repo.parent().unwrap().to_path_buf();
|
||||
let hm = HookManager::new(
|
||||
prefix,
|
||||
None,
|
||||
None,
|
||||
Duration::from_secs(30),
|
||||
false, // Disallow custom hooks
|
||||
);
|
||||
|
||||
let result = hm.set_custom_hook(&repo, "pre-receive", "#!/bin/sh\nexit 0");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_level_display() {
|
||||
assert_eq!(HookLevel::Server.to_string(), "server");
|
||||
assert_eq!(HookLevel::Custom.to_string(), "custom");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Tests for snapshot operations.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gitks::snapshot::ops::{create_snapshot, restore_snapshot};
|
||||
use gitks::snapshot::storage::{LocalSnapshotStorage, SnapshotStorageBackend};
|
||||
|
||||
fn temp_bare_repo() -> PathBuf {
|
||||
let dir = tempfile::tempdir().unwrap().path().to_path_buf();
|
||||
let result = std::process::Command::new("git")
|
||||
.args(["init", "--bare"])
|
||||
.arg(&dir)
|
||||
.output()
|
||||
.expect("git init --bare should work");
|
||||
assert!(result.status.success());
|
||||
|
||||
// Create an initial commit so HEAD exists
|
||||
// We need a working tree to create a commit, so we create one temporarily
|
||||
let work_dir = tempfile::tempdir().unwrap().path().to_path_buf();
|
||||
let result2 = std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.arg(&work_dir)
|
||||
.output()
|
||||
.expect("git init should work");
|
||||
assert!(result2.status.success());
|
||||
|
||||
// Create a file and commit
|
||||
std::fs::write(work_dir.join("test.txt"), "hello world").unwrap();
|
||||
let result3 = std::process::Command::new("git")
|
||||
.args(["add", "test.txt"])
|
||||
.current_dir(&work_dir)
|
||||
.output();
|
||||
let _ = result3;
|
||||
|
||||
let result4 = std::process::Command::new("git")
|
||||
.args(["commit", "-m", "initial commit"])
|
||||
.env("GIT_AUTHOR_NAME", "test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@test.com")
|
||||
.env("GIT_COMMITTER_NAME", "test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@test.com")
|
||||
.current_dir(&work_dir)
|
||||
.output();
|
||||
let _ = result4;
|
||||
|
||||
// Push to bare repo
|
||||
let result5 = std::process::Command::new("git")
|
||||
.args(["push", &dir.to_string_lossy(), "master:refs/heads/master"])
|
||||
.current_dir(&work_dir)
|
||||
.output();
|
||||
let _ = result5;
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_create_and_restore() {
|
||||
let repo = temp_bare_repo();
|
||||
let gb = gitks::bare::GitBare::new(repo.clone());
|
||||
|
||||
// Create snapshot
|
||||
let result = create_snapshot(&gb);
|
||||
if let Ok(data) = result {
|
||||
assert!(!data.is_empty());
|
||||
|
||||
// Restore to a different location
|
||||
let target = tempfile::tempdir().unwrap().path().to_path_buf();
|
||||
// Initialize target as bare repo first
|
||||
let init_result = std::process::Command::new("git")
|
||||
.args(["init", "--bare"])
|
||||
.arg(&target)
|
||||
.output();
|
||||
if let Ok(init_output) = init_result
|
||||
&& init_output.status.success()
|
||||
{
|
||||
let restore_result = restore_snapshot(&target, &data);
|
||||
// May succeed or fail depending on git bundle compatibility
|
||||
let _ = restore_result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_snapshot_storage_write_and_read() {
|
||||
let dir = tempfile::tempdir().unwrap().path().to_path_buf();
|
||||
let storage = LocalSnapshotStorage::new(dir);
|
||||
|
||||
// Write snapshot
|
||||
let result = storage.write_snapshot("snap001", "test.git", "abc123", b"bundle_data");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Read snapshot
|
||||
let result = storage.read_snapshot("snap001");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), b"bundle_data".to_vec());
|
||||
|
||||
// Read non-existent
|
||||
let result = storage.read_snapshot("nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_snapshot_storage_list() {
|
||||
let dir = tempfile::tempdir().unwrap().path().to_path_buf();
|
||||
let storage = LocalSnapshotStorage::new(dir);
|
||||
|
||||
// Write two snapshots
|
||||
storage
|
||||
.write_snapshot("snap001", "repo1.git", "abc123", b"data1")
|
||||
.unwrap();
|
||||
storage
|
||||
.write_snapshot("snap002", "repo2.git", "def456", b"data2")
|
||||
.unwrap();
|
||||
|
||||
// List all
|
||||
let result = storage.list_snapshots("");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().len(), 2);
|
||||
|
||||
// List filtered
|
||||
let result = storage.list_snapshots("repo1.git");
|
||||
assert!(result.is_ok());
|
||||
let snapshots = result.unwrap();
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
assert_eq!(snapshots[0].relative_path, "repo1.git");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_snapshot_storage_delete() {
|
||||
let dir = tempfile::tempdir().unwrap().path().to_path_buf();
|
||||
let storage = LocalSnapshotStorage::new(dir);
|
||||
|
||||
storage
|
||||
.write_snapshot("snap001", "test.git", "abc123", b"data")
|
||||
.unwrap();
|
||||
assert!(storage.read_snapshot("snap001").is_ok());
|
||||
|
||||
storage.delete_snapshot("snap001").unwrap();
|
||||
assert!(storage.read_snapshot("snap001").is_err());
|
||||
|
||||
// Delete non-existent
|
||||
assert!(storage.delete_snapshot("nonexistent").is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user