1use std::borrow::Cow;
31use std::path::{Path, PathBuf};
32use std::process::Command;
33use std::time::{Duration, Instant};
34
35use anyhow::{Context, Result, anyhow};
36use shell_escape::escape;
37use tracing::{debug, error, info, warn};
38use uuid::Uuid;
39
40use crate::binary_hash::{BinaryHashResult, binaries_equivalent, compute_binary_hash};
41use crate::test_change::{TestChangeGuard, TestCodeChange};
42use crate::types::WorkerConfig;
43
44#[derive(Debug, Clone)]
46pub struct VerificationConfig {
47 pub timeout: Duration,
49 pub build_timeout: Duration,
51 pub release_mode: bool,
53 pub cargo_flags: Vec<String>,
55 pub rsync_compression: u32,
57 pub exclude_patterns: Vec<String>,
59 pub clean_before_build: bool,
61 pub remote_base_path: PathBuf,
63}
64
65impl Default for VerificationConfig {
66 fn default() -> Self {
67 Self {
68 timeout: Duration::from_secs(300),
69 build_timeout: Duration::from_secs(180),
70 release_mode: false,
71 cargo_flags: vec![],
72 rsync_compression: 3,
73 exclude_patterns: vec![
74 "target/".to_string(),
75 ".git/objects/".to_string(),
76 "node_modules/".to_string(),
77 ],
78 clean_before_build: false,
79 remote_base_path: PathBuf::from("/tmp/rch_verify"),
80 }
81 }
82}
83
84#[derive(Debug, Clone)]
86pub struct VerificationResult {
87 pub success: bool,
89 pub local_hash: Option<BinaryHashResult>,
91 pub remote_hash: Option<BinaryHashResult>,
93 pub rsync_up_ms: u64,
95 pub compilation_ms: u64,
97 pub rsync_down_ms: u64,
99 pub total_ms: u64,
101 pub bytes_up: u64,
103 pub bytes_down: u64,
105 pub error: Option<String>,
107 pub change_id: String,
109 pub marker_verified: bool,
111}
112
113impl VerificationResult {
114 pub fn failed(error: impl Into<String>, elapsed_ms: u64, change_id: String) -> Self {
116 Self {
117 success: false,
118 local_hash: None,
119 remote_hash: None,
120 rsync_up_ms: 0,
121 compilation_ms: 0,
122 rsync_down_ms: 0,
123 total_ms: elapsed_ms,
124 bytes_up: 0,
125 bytes_down: 0,
126 error: Some(error.into()),
127 change_id,
128 marker_verified: false,
129 }
130 }
131}
132
133pub struct RemoteCompilationTest {
141 worker: WorkerConfig,
143 project_path: PathBuf,
145 config: VerificationConfig,
147 remote_path_suffix: String,
149}
150
151fn sanitize_remote_path_component(component: &str, fallback: &str) -> String {
152 let sanitized = component
153 .chars()
154 .map(|ch| {
155 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
156 ch
157 } else {
158 '-'
159 }
160 })
161 .collect::<String>();
162 let trimmed = sanitized.trim_matches('-');
163 if trimmed.is_empty() {
164 fallback.to_string()
165 } else {
166 trimmed.to_string()
167 }
168}
169
170fn sanitize_remote_path_suffix(suffix: &str) -> String {
171 sanitize_remote_path_component(suffix, "run")
172}
173
174fn shell_escape_path(path: &Path) -> String {
175 escape(path.to_string_lossy()).into_owned()
176}
177
178fn shell_escape_str(value: &str) -> String {
179 escape(Cow::from(value)).into_owned()
180}
181
182impl RemoteCompilationTest {
183 pub fn new(
190 worker: WorkerConfig,
191 project_path: impl Into<PathBuf>,
192 config: VerificationConfig,
193 ) -> Self {
194 Self {
195 worker,
196 project_path: project_path.into(),
197 config,
198 remote_path_suffix: format!("run-{}", Uuid::new_v4()),
199 }
200 }
201
202 pub fn with_remote_path_suffix(mut self, suffix: impl AsRef<str>) -> Self {
204 self.remote_path_suffix = sanitize_remote_path_suffix(suffix.as_ref());
205 self
206 }
207
208 pub fn run(&self) -> Result<VerificationResult> {
218 let start = Instant::now();
219 info!(
220 "Starting remote compilation verification for {:?} on {}",
221 self.project_path, self.worker.id
222 );
223
224 let change = TestCodeChange::for_main_rs(&self.project_path)
226 .with_context(|| "Failed to create test change")?;
227 let change_id = change.change_id.clone();
228 let guard = TestChangeGuard::new(change).with_context(|| "Failed to apply test change")?;
229
230 info!("Applied test change: {}", guard.change_id());
231
232 info!("Building locally for reference hash");
234 let local_build_start = Instant::now();
235 if let Err(e) = self.build_local() {
236 return Ok(VerificationResult::failed(
237 format!("Local build failed: {}", e),
238 start.elapsed().as_millis() as u64,
239 change_id,
240 ));
241 }
242 let local_build_ms = local_build_start.elapsed().as_millis() as u64;
243 debug!("Local build completed in {}ms", local_build_ms);
244
245 let local_binary = self.binary_path();
247 let local_hash = compute_binary_hash(&local_binary)
248 .with_context(|| format!("Failed to hash local binary: {:?}", local_binary))?;
249 info!(
250 "Local build hash: {} (code_hash: {})",
251 &local_hash.full_hash[..16],
252 &local_hash.code_hash[..16]
253 );
254
255 let local_marker_ok = guard.verify_in_binary(&local_binary)?;
257 if !local_marker_ok {
258 return Ok(VerificationResult::failed(
259 "Test marker not found in local binary",
260 start.elapsed().as_millis() as u64,
261 change_id,
262 ));
263 }
264 info!("Test marker verified in local binary");
265
266 info!("Syncing source to worker {}", self.worker.id);
268 let rsync_up_start = Instant::now();
269 let bytes_up = match self.rsync_to_worker() {
270 Ok(bytes) => bytes,
271 Err(e) => {
272 return Ok(VerificationResult::failed(
273 format!("rsync to worker failed: {}", e),
274 start.elapsed().as_millis() as u64,
275 change_id,
276 ));
277 }
278 };
279 let rsync_up_ms = rsync_up_start.elapsed().as_millis() as u64;
280 info!("Synced {} bytes in {}ms", bytes_up, rsync_up_ms);
281
282 info!("Building on worker {}", self.worker.id);
284 let compilation_start = Instant::now();
285 if let Err(e) = self.build_remote() {
286 return Ok(VerificationResult::failed(
287 format!("Remote build failed: {}", e),
288 start.elapsed().as_millis() as u64,
289 change_id,
290 ));
291 }
292 let compilation_ms = compilation_start.elapsed().as_millis() as u64;
293 info!("Remote build completed in {}ms", compilation_ms);
294
295 info!("Syncing artifacts from worker");
297 let rsync_down_start = Instant::now();
298 let bytes_down = match self.rsync_from_worker() {
299 Ok(bytes) => bytes,
300 Err(e) => {
301 return Ok(VerificationResult::failed(
302 format!("rsync from worker failed: {}", e),
303 start.elapsed().as_millis() as u64,
304 change_id,
305 ));
306 }
307 };
308 let rsync_down_ms = rsync_down_start.elapsed().as_millis() as u64;
309 info!("Retrieved {} bytes in {}ms", bytes_down, rsync_down_ms);
310
311 let remote_binary = self.remote_binary_path_local();
313 let remote_hash = match compute_binary_hash(&remote_binary) {
314 Ok(h) => h,
315 Err(e) => {
316 return Ok(VerificationResult::failed(
317 format!("Failed to hash remote binary: {}", e),
318 start.elapsed().as_millis() as u64,
319 change_id,
320 ));
321 }
322 };
323 info!(
324 "Remote build hash: {} (code_hash: {})",
325 &remote_hash.full_hash[..16],
326 &remote_hash.code_hash[..16]
327 );
328
329 let marker_verified = guard.verify_in_binary(&remote_binary)?;
331 if !marker_verified {
332 warn!("Test marker not found in remote binary");
333 }
334
335 let success = binaries_equivalent(&local_hash, &remote_hash);
337 let total_ms = start.elapsed().as_millis() as u64;
338
339 if success {
340 info!(
341 "Verification PASSED: code hashes match (total: {}ms)",
342 total_ms
343 );
344 } else {
345 error!(
346 "Verification FAILED: code hashes differ (local={}, remote={})",
347 &local_hash.code_hash[..16],
348 &remote_hash.code_hash[..16]
349 );
350 }
351
352 Ok(VerificationResult {
354 success,
355 local_hash: Some(local_hash),
356 remote_hash: Some(remote_hash),
357 rsync_up_ms,
358 compilation_ms,
359 rsync_down_ms,
360 total_ms,
361 bytes_up,
362 bytes_down,
363 error: if success {
364 None
365 } else {
366 Some("Code hashes do not match".to_string())
367 },
368 change_id,
369 marker_verified,
370 })
371 }
372
373 fn build_local(&self) -> Result<()> {
375 let mut cmd = Command::new("cargo");
376 cmd.arg("build");
377 if self.config.release_mode {
378 cmd.arg("--release");
379 }
380 for flag in &self.config.cargo_flags {
381 cmd.arg(flag);
382 }
383 cmd.current_dir(&self.project_path);
384
385 debug!("Running local build: {:?}", cmd);
386 let output = cmd.output().context("Failed to execute cargo build")?;
387
388 if !output.status.success() {
389 let stderr = String::from_utf8_lossy(&output.stderr);
390 return Err(anyhow!("cargo build failed: {}", stderr));
391 }
392
393 Ok(())
394 }
395
396 fn build_remote(&self) -> Result<()> {
398 let build_cmd = if self.config.release_mode {
399 "cargo build --release"
400 } else {
401 "cargo build"
402 };
403
404 let ssh_cmd = self.remote_build_command(build_cmd);
405
406 let identity_file = shellexpand::tilde(&self.worker.identity_file).to_string();
407 let mut cmd = Command::new("ssh");
408 cmd.args([
409 "-i",
410 &identity_file,
411 "-o",
412 "StrictHostKeyChecking=accept-new",
413 "-o",
414 "BatchMode=yes",
415 &format!("{}@{}", self.worker.user, self.worker.host),
416 &ssh_cmd,
417 ]);
418
419 debug!("Running remote build via SSH: {:?}", cmd);
420 let output = cmd.output().context("Failed to execute SSH command")?;
421
422 if !output.status.success() {
423 let stderr = String::from_utf8_lossy(&output.stderr);
424 return Err(anyhow!("Remote build failed: {}", stderr));
425 }
426
427 Ok(())
428 }
429
430 fn rsync_to_worker(&self) -> Result<u64> {
432 let remote_path = self.remote_project_path();
433 let identity_file = shellexpand::tilde(&self.worker.identity_file).to_string();
434
435 let escaped_remote_path = shell_escape_path(&remote_path);
437 let mkdir_cmd = format!("mkdir -p -- {}", escaped_remote_path);
438 let mut mkdir = Command::new("ssh");
439 mkdir.args([
440 "-i",
441 &identity_file,
442 "-o",
443 "StrictHostKeyChecking=accept-new",
444 "-o",
445 "BatchMode=yes",
446 &format!("{}@{}", self.worker.user, self.worker.host),
447 &mkdir_cmd,
448 ]);
449 let mkdir_output = mkdir
450 .output()
451 .context("Failed to create remote directory")?;
452 if !mkdir_output.status.success() {
453 let stderr = String::from_utf8_lossy(&mkdir_output.stderr);
454 return Err(anyhow!("remote directory creation failed: {}", stderr));
455 }
456
457 let mut cmd = Command::new("rsync");
459 cmd.args([
460 "-az",
461 "--compress-level",
462 &self.config.rsync_compression.to_string(),
463 "--delete",
464 "-e",
465 &self.rsync_ssh_command(&identity_file),
466 ]);
467
468 for pattern in &self.config.exclude_patterns {
470 cmd.args(["--exclude", pattern]);
471 }
472
473 let src = format!("{}/", self.project_path.display());
475 let dest = format!(
476 "{}@{}:{}",
477 self.worker.user, self.worker.host, escaped_remote_path
478 );
479 cmd.args([&src, &dest]);
480
481 debug!("Running rsync to worker: {:?}", cmd);
482 let output = cmd.output().context("Failed to execute rsync")?;
483
484 if !output.status.success() {
485 let stderr = String::from_utf8_lossy(&output.stderr);
486 return Err(anyhow!("rsync to worker failed: {}", stderr));
487 }
488
489 let stdout = String::from_utf8_lossy(&output.stdout);
491 let bytes = parse_rsync_bytes_transferred(&stdout);
492 Ok(bytes)
493 }
494
495 fn rsync_from_worker(&self) -> Result<u64> {
497 let remote_path = self.remote_project_path();
498 let identity_file = shellexpand::tilde(&self.worker.identity_file).to_string();
499
500 let local_artifact_dir = self.project_path.join("target_remote");
502 std::fs::create_dir_all(&local_artifact_dir)?;
503
504 let profile = if self.config.release_mode {
506 "release"
507 } else {
508 "debug"
509 };
510
511 let mut cmd = Command::new("rsync");
512 cmd.args([
513 "-az",
514 "--compress-level",
515 &self.config.rsync_compression.to_string(),
516 "-e",
517 &self.rsync_ssh_command(&identity_file),
518 ]);
519
520 let remote_target_dir = remote_path.join("target").join(profile);
522 let remote_target_dir_with_slash = format!("{}/", remote_target_dir.display());
523 let remote_target = format!(
524 "{}@{}:{}",
525 self.worker.user,
526 self.worker.host,
527 shell_escape_str(&remote_target_dir_with_slash)
528 );
529 let local_target = format!("{}/", local_artifact_dir.display());
530 cmd.args([&remote_target, &local_target]);
531
532 debug!("Running rsync from worker: {:?}", cmd);
533 let output = cmd.output().context("Failed to execute rsync")?;
534
535 if !output.status.success() {
536 let stderr = String::from_utf8_lossy(&output.stderr);
537 return Err(anyhow!("rsync from worker failed: {}", stderr));
538 }
539
540 let stdout = String::from_utf8_lossy(&output.stdout);
542 let bytes = parse_rsync_bytes_transferred(&stdout);
543 Ok(bytes)
544 }
545
546 fn binary_path(&self) -> PathBuf {
548 let profile = if self.config.release_mode {
549 "release"
550 } else {
551 "debug"
552 };
553
554 let binary_name = self.get_binary_name().unwrap_or_else(|| "main".to_string());
556 self.project_path
557 .join("target")
558 .join(profile)
559 .join(&binary_name)
560 }
561
562 fn remote_binary_path_local(&self) -> PathBuf {
564 let binary_name = self.get_binary_name().unwrap_or_else(|| "main".to_string());
565 self.project_path.join("target_remote").join(&binary_name)
566 }
567
568 fn remote_project_path(&self) -> PathBuf {
570 let project_name = self
571 .project_path
572 .file_name()
573 .and_then(|n| n.to_str())
574 .map(|name| sanitize_remote_path_component(name, "project"))
575 .unwrap_or_else(|| "project".to_string());
576 let remote_path_suffix = sanitize_remote_path_suffix(&self.remote_path_suffix);
577
578 self.config
579 .remote_base_path
580 .join(format!("{project_name}-{remote_path_suffix}"))
581 }
582
583 fn remote_cargo_command(&self, build_cmd: &str) -> String {
585 let mut parts = vec![build_cmd.to_string()];
586 parts.extend(
587 self.config
588 .cargo_flags
589 .iter()
590 .map(|flag| shell_escape_str(flag)),
591 );
592 parts.join(" ")
593 }
594
595 fn remote_build_command(&self, build_cmd: &str) -> String {
597 let remote_path = self.remote_project_path();
598 let cargo_cmd = self.remote_cargo_command(build_cmd);
599 if self.config.clean_before_build {
600 format!(
601 "cd {} && cargo clean && {}",
602 shell_escape_path(&remote_path),
603 cargo_cmd
604 )
605 } else {
606 format!("cd {} && {}", shell_escape_path(&remote_path), cargo_cmd)
607 }
608 }
609
610 fn rsync_ssh_command(&self, identity_file: &str) -> String {
612 format!(
613 "ssh -i {} -o StrictHostKeyChecking=accept-new -o BatchMode=yes",
614 shell_escape_str(identity_file)
615 )
616 }
617
618 fn get_binary_name(&self) -> Option<String> {
620 let cargo_toml = self.project_path.join("Cargo.toml");
621 let content = std::fs::read_to_string(&cargo_toml).ok()?;
622
623 for line in content.lines() {
625 let line = line.trim();
626 if line.starts_with("name")
627 && line.contains('=')
628 && let Some(name) = line.split('=').nth(1)
629 {
630 let name = name.trim().trim_matches('"');
631 return Some(name.to_string());
632 }
633 }
634 None
635 }
636}
637
638fn parse_rsync_bytes_transferred(output: &str) -> u64 {
640 for line in output.lines() {
643 if line.contains("bytes") {
644 let nums: Vec<u64> = line
646 .split_whitespace()
647 .filter_map(|w| w.replace(',', "").parse().ok())
648 .collect();
649 if !nums.is_empty() {
650 return nums.iter().sum();
651 }
652 }
653 }
654 0
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 fn init_test_logging() {
662 let _ = tracing_subscriber::fmt()
663 .with_test_writer()
664 .with_max_level(tracing::Level::DEBUG)
665 .try_init();
666 }
667
668 #[test]
669 fn test_verification_config_default() {
670 init_test_logging();
671 info!("TEST START: test_verification_config_default");
672
673 let config = VerificationConfig::default();
674
675 info!("RESULT: timeout={:?}", config.timeout);
676 info!("RESULT: release_mode={}", config.release_mode);
677 info!("RESULT: rsync_compression={}", config.rsync_compression);
678
679 assert_eq!(config.timeout, Duration::from_secs(300));
680 assert!(!config.release_mode);
681 assert_eq!(config.rsync_compression, 3);
682 assert!(config.exclude_patterns.contains(&"target/".to_string()));
683
684 info!("TEST PASS: test_verification_config_default");
685 }
686
687 #[test]
688 fn test_verification_result_failed() {
689 init_test_logging();
690 info!("TEST START: test_verification_result_failed");
691
692 let result = VerificationResult::failed("Test error", 1000, "RCH_TEST_123".to_string());
693
694 info!("RESULT: success={}", result.success);
695 info!("RESULT: error={:?}", result.error);
696
697 assert!(!result.success);
698 assert_eq!(result.error, Some("Test error".to_string()));
699 assert_eq!(result.total_ms, 1000);
700 assert_eq!(result.change_id, "RCH_TEST_123");
701
702 info!("TEST PASS: test_verification_result_failed");
703 }
704
705 #[test]
706 fn test_parse_rsync_bytes() {
707 init_test_logging();
708 info!("TEST START: test_parse_rsync_bytes");
709
710 let output = "sent 12,345 bytes received 678 bytes 8,682.00 bytes/sec";
711 let bytes = parse_rsync_bytes_transferred(output);
712
713 info!("INPUT: {:?}", output);
714 info!("RESULT: bytes={}", bytes);
715
716 assert!(bytes > 0);
717
718 info!("TEST PASS: test_parse_rsync_bytes");
719 }
720
721 #[test]
722 fn test_parse_rsync_bytes_empty() {
723 init_test_logging();
724 info!("TEST START: test_parse_rsync_bytes_empty");
725
726 let output = "";
727 let bytes = parse_rsync_bytes_transferred(output);
728
729 info!("INPUT: empty string");
730 info!("RESULT: bytes={}", bytes);
731
732 assert_eq!(bytes, 0);
733
734 info!("TEST PASS: test_parse_rsync_bytes_empty");
735 }
736
737 #[test]
738 fn test_remote_compilation_test_paths() {
739 init_test_logging();
740 info!("TEST START: test_remote_compilation_test_paths");
741
742 let config = VerificationConfig::default();
743 let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
744 .with_remote_path_suffix("run-1");
745
746 let remote_path = test.remote_project_path();
747 info!("RESULT: remote_path={:?}", remote_path);
748 assert_eq!(
749 remote_path,
750 PathBuf::from("/tmp/rch_verify/test-project-run-1")
751 );
752
753 info!("TEST PASS: test_remote_compilation_test_paths");
754 }
755
756 #[test]
757 fn test_remote_compilation_paths_are_isolated_by_default() {
758 init_test_logging();
759 info!("TEST START: test_remote_compilation_paths_are_isolated_by_default");
760
761 let config = VerificationConfig::default();
762 let first = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config.clone());
763 let second = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config);
764
765 let first_remote_path = first.remote_project_path();
766 let second_remote_path = second.remote_project_path();
767
768 info!("RESULT: first_remote_path={:?}", first_remote_path);
769 info!("RESULT: second_remote_path={:?}", second_remote_path);
770
771 assert_ne!(first_remote_path, second_remote_path);
772 assert!(
773 first_remote_path
774 .to_string_lossy()
775 .starts_with("/tmp/rch_verify/test-project-run-")
776 );
777 assert!(
778 second_remote_path
779 .to_string_lossy()
780 .starts_with("/tmp/rch_verify/test-project-run-")
781 );
782
783 info!("TEST PASS: test_remote_compilation_paths_are_isolated_by_default");
784 }
785
786 #[test]
787 fn test_remote_project_path_sanitizes_project_and_suffix() {
788 init_test_logging();
789 info!("TEST START: test_remote_project_path_sanitizes_project_and_suffix");
790
791 let config = VerificationConfig::default();
792 let test = RemoteCompilationTest::new(test_worker(), "/tmp/project with spaces", config)
793 .with_remote_path_suffix("../attempt 1");
794
795 let remote_path = test.remote_project_path();
796 info!("RESULT: remote_path={:?}", remote_path);
797 assert_eq!(
798 remote_path,
799 PathBuf::from("/tmp/rch_verify/project-with-spaces-..-attempt-1")
800 );
801
802 info!("TEST PASS: test_remote_project_path_sanitizes_project_and_suffix");
803 }
804
805 #[test]
806 fn test_remote_build_command_includes_cargo_flags_and_clean() {
807 init_test_logging();
808 info!("TEST START: test_remote_build_command_includes_cargo_flags_and_clean");
809
810 let config = VerificationConfig {
811 release_mode: true,
812 cargo_flags: vec!["--features".to_string(), "foo bar".to_string()],
813 clean_before_build: true,
814 ..VerificationConfig::default()
815 };
816 let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
817 .with_remote_path_suffix("run-1");
818
819 let command = test.remote_build_command("cargo build --release");
820 info!("RESULT: command={}", command);
821 assert_eq!(
822 command,
823 "cd /tmp/rch_verify/test-project-run-1 && cargo clean && cargo build --release --features 'foo bar'"
824 );
825
826 info!("TEST PASS: test_remote_build_command_includes_cargo_flags_and_clean");
827 }
828
829 #[test]
830 fn test_remote_build_command_quotes_remote_path() {
831 init_test_logging();
832 info!("TEST START: test_remote_build_command_quotes_remote_path");
833
834 let config = VerificationConfig {
835 remote_base_path: PathBuf::from("/tmp/rch verify"),
836 ..VerificationConfig::default()
837 };
838 let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
839 .with_remote_path_suffix("run-1");
840
841 let command = test.remote_build_command("cargo build");
842 info!("RESULT: command={}", command);
843 assert_eq!(
844 command,
845 "cd '/tmp/rch verify/test-project-run-1' && cargo build"
846 );
847
848 info!("TEST PASS: test_remote_build_command_quotes_remote_path");
849 }
850
851 #[test]
852 fn test_rsync_ssh_command_quotes_identity_path() {
853 init_test_logging();
854 info!("TEST START: test_rsync_ssh_command_quotes_identity_path");
855
856 let config = VerificationConfig::default();
857 let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
858 .with_remote_path_suffix("run-1");
859
860 let command = test.rsync_ssh_command("/tmp/key files/id_ed25519");
861 info!("RESULT: command={}", command);
862 assert_eq!(
863 command,
864 "ssh -i '/tmp/key files/id_ed25519' -o StrictHostKeyChecking=accept-new -o BatchMode=yes"
865 );
866
867 info!("TEST PASS: test_rsync_ssh_command_quotes_identity_path");
868 }
869
870 fn test_worker() -> WorkerConfig {
871 WorkerConfig {
872 id: crate::types::WorkerId::new("test-worker"),
873 host: "localhost".to_string(),
874 user: "testuser".to_string(),
875 identity_file: "~/.ssh/id_rsa".to_string(),
876 total_slots: 4,
877 priority: 100,
878 tags: vec![],
879 }
880 }
881}