1use xianyun_core::config::latest::{CaproniConfig, LifecycleHookConfig, RepositoryConfig};
7use xianyun_core::variables::resolver::Resolver;
8use xianyun_executor::exec::CommandRunner;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum HookType {
13 EditModePrepare,
16 EditModeStart,
19 EditModeStop,
23}
24
25impl HookType {
26 fn as_str(&self) -> &'static str {
29 match self {
30 HookType::EditModePrepare => "edit_mode_prepare",
31 HookType::EditModeStart => "edit_mode_start",
32 HookType::EditModeStop => "edit_mode_stop",
33 }
34 }
35}
36
37#[must_use]
39pub fn resolve_reloader_hooks(
40 reloader_config: &xianyun_core::config::latest::ReloaderConfig,
41 typ: HookType,
42) -> Vec<LifecycleHookConfig> {
43 let Some(mirror) = &reloader_config.mirror else {
44 return Vec::new();
45 };
46
47 filter_hooks(&mirror.lifecycle_hooks, typ)
48}
49
50#[must_use]
52pub fn filter_hooks(hooks: &[LifecycleHookConfig], typ: HookType) -> Vec<LifecycleHookConfig> {
53 hooks
54 .iter()
55 .filter(|hook| !hook.script.is_empty() && hook.hook_type == typ.as_str())
56 .cloned()
57 .collect()
58}
59
60#[must_use]
63pub fn resolve_repository_hooks(
64 caproni_cfg: &CaproniConfig,
65 repo_name: &str,
66 typ: HookType,
67) -> Vec<LifecycleHookConfig> {
68 let mut hooks = Vec::new();
69
70 if let Some(repo) = caproni_cfg
71 .repositories
72 .as_ref()
73 .and_then(|repos| repos.get(&repo_name.to_string()))
74 {
75 hooks.extend(filter_hooks(&repo.lifecycle_hooks, typ));
76 }
77
78 if let Some(reloaders) = &caproni_cfg.reloaders {
79 for reloader in reloaders.values() {
80 if reloader.repository != repo_name {
81 continue;
82 }
83
84 hooks.extend(resolve_reloader_hooks(&reloader, typ));
85 }
86 }
87
88 hooks
89}
90
91#[derive(Debug, thiserror::Error)]
93pub enum HooksError {
94 #[error("parsing hook {0:?}: {1}")]
99 ParseFailed(String, String),
100 #[error("running [{}] in repo {repo_name}: {source}", command.join(" "))]
106 RunFailed {
107 command: Vec<String>,
108 repo_name: String,
109 source: xianyun_executor::exec::ExecError,
110 },
111}
112
113#[allow(clippy::too_many_arguments)]
121pub fn run_hooks(
122 exec: &dyn CommandRunner,
123 caproni_cfg: &CaproniConfig,
124 resolver: &dyn Resolver,
125 repo_config: &RepositoryConfig,
126 repo_name: &str,
127 hooks: &[LifecycleHookConfig],
128 kubeconfig_file: Option<&xianyun_cluster::kubeconfig::Config>,
129) -> Result<(), HooksError> {
130 let env = build_hook_env(resolver, kubeconfig_file);
131 let repo_dir = caproni_cfg.resolve_path(&repo_config.directory);
132
133 for hook in hooks {
134 tracing::info!(script = %hook.script, repo = repo_name, "running hook");
135
136 let script = parse_shell_words(&hook.script)
137 .map_err(|e| HooksError::ParseFailed(hook.script.clone(), e))?;
138
139 if script.is_empty() {
140 continue;
141 }
142
143 let opts = xianyun_executor::exec::ExecStreamedOpts {
144 env: Some(env.clone()),
145 ..Default::default()
146 };
147
148 let result = exec.run_in_repository(&repo_dir, &script[0], &script[1..], Some(opts));
149
150 tracing::info!(
151 script = %hook.script,
152 repo = repo_name,
153 success = result.is_ok(),
154 "hook finished"
155 );
156
157 result.map_err(|source| HooksError::RunFailed {
158 command: script,
159 repo_name: repo_name.to_string(),
160 source,
161 })?;
162 }
163
164 Ok(())
165}
166
167fn build_hook_env(
172 resolver: &dyn Resolver,
173 kubeconfig_file: Option<&xianyun_cluster::kubeconfig::Config>,
174) -> Vec<String> {
175 let mut env: Vec<String> = std::env::vars().map(|(k, v)| format!("{k}={v}")).collect();
176
177 for (k, v) in resolver.all() {
178 env.push(format!("{k}={v}"));
179 }
180
181 if let Some(kubeconfig_file) = kubeconfig_file {
182 env.extend(kubeconfig_file.environ());
183 }
184
185 env
186}
187
188fn parse_shell_words(input: &str) -> Result<Vec<String>, String> {
194 let mut words = Vec::new();
195 let mut current = String::new();
196 let mut in_word = false;
197 let mut chars = input.chars().peekable();
198
199 while let Some(c) = chars.next() {
200 match c {
201 ' ' | '\t' | '\n' => {
202 if in_word {
203 words.push(std::mem::take(&mut current));
204 in_word = false;
205 }
206 }
207 '\'' | '"' => {
208 in_word = true;
209 let quote = c;
210 let mut closed = false;
211
212 for qc in chars.by_ref() {
213 if qc == quote {
214 closed = true;
215 break;
216 }
217
218 current.push(qc);
219 }
220
221 if !closed {
222 return Err("unterminated quote".to_string());
223 }
224 }
225 _ => {
226 in_word = true;
227 current.push(c);
228 }
229 }
230 }
231
232 if in_word {
233 words.push(current);
234 }
235
236 Ok(words)
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use xianyun_core::config::latest::{MirrorConfig, ReloaderConfig};
243 use xianyun_core::config::types::NetworkConfig;
244 use xianyun_core::orderedmap::OrderedMap;
245
246 use crate::testutil::MockCommandRunner;
247
248 const EXAMPLE_REPO_NAME: &str = "holomatrix";
249
250 fn base_caproni_config() -> CaproniConfig {
251 CaproniConfig {
252 config_dir: "/test".to_string(),
253 cluster: xianyun_core::config::types::ClusterConfig {
254 network: Some(NetworkConfig {
255 hostname: "test.example.com".to_string(),
256 ..Default::default()
257 }),
258 ..Default::default()
259 },
260 repositories: Some(OrderedMap::new()),
261 deployers: Some(OrderedMap::new()),
262 reloaders: Some(OrderedMap::new()),
263 ..Default::default()
264 }
265 }
266
267 #[derive(Default)]
268 struct TestResolver;
269
270 impl Resolver for TestResolver {
271 fn expand(&self, _name: &str) -> String {
272 String::new()
273 }
274
275 fn all(&self) -> std::collections::HashMap<String, String> {
276 std::collections::HashMap::new()
277 }
278
279 fn lookup(&self, _name: &str) -> Option<String> {
280 None
281 }
282 }
283
284 #[test]
287 fn resolve_hooks_returns_matching_hooks() {
288 let hooks = vec![
289 LifecycleHookConfig {
290 hook_type: "edit_mode_start".to_string(),
291 script: "make first".to_string(),
292 ..Default::default()
293 },
294 LifecycleHookConfig {
295 hook_type: "edit_mode_start".to_string(),
296 script: "make second".to_string(),
297 ..Default::default()
298 },
299 LifecycleHookConfig {
300 hook_type: "edit_mode_stop".to_string(),
301 script: "make cleanup".to_string(),
302 ..Default::default()
303 },
304 ];
305 let cfg = ReloaderConfig {
306 mirror: Some(MirrorConfig {
307 lifecycle_hooks: hooks,
308 ..Default::default()
309 }),
310 ..Default::default()
311 };
312
313 let actual = resolve_reloader_hooks(&cfg, HookType::EditModeStart);
314 let actual_scripts: Vec<String> = actual.iter().map(|v| v.script.clone()).collect();
315
316 assert_eq!(
317 actual_scripts,
318 vec!["make first".to_string(), "make second".to_string()]
319 );
320 }
321
322 #[test]
323 fn resolve_hooks_skips_hooks_with_empty_script() {
324 let hooks = vec![
325 LifecycleHookConfig {
326 hook_type: "edit_mode_start".to_string(),
327 script: String::new(),
328 ..Default::default()
329 },
330 LifecycleHookConfig {
331 hook_type: "edit_mode_start".to_string(),
332 script: "make run".to_string(),
333 ..Default::default()
334 },
335 ];
336 let cfg = ReloaderConfig {
337 mirror: Some(MirrorConfig {
338 lifecycle_hooks: hooks,
339 ..Default::default()
340 }),
341 ..Default::default()
342 };
343
344 let actual = resolve_reloader_hooks(&cfg, HookType::EditModeStart);
345 let actual_scripts: Vec<String> = actual.iter().map(|v| v.script.clone()).collect();
346
347 assert_eq!(actual_scripts, vec!["make run".to_string()]);
348 }
349
350 #[test]
351 fn resolve_hooks_skips_hooks_of_different_type() {
352 let hooks = vec![LifecycleHookConfig {
353 hook_type: "edit_mode_stop".to_string(),
354 script: "make cleanup".to_string(),
355 ..Default::default()
356 }];
357 let cfg = ReloaderConfig {
358 mirror: Some(MirrorConfig {
359 lifecycle_hooks: hooks,
360 ..Default::default()
361 }),
362 ..Default::default()
363 };
364
365 let actual = resolve_reloader_hooks(&cfg, HookType::EditModeStart);
366 let actual_scripts: Vec<String> = actual.iter().map(|v| v.script.clone()).collect();
367
368 assert!(actual_scripts.is_empty());
369 }
370
371 #[test]
374 fn resolve_hooks_in_repository_collects_from_matching_reloaders() {
375 let mut cfg = base_caproni_config();
376 cfg.repositories
377 .as_mut()
378 .unwrap()
379 .set(EXAMPLE_REPO_NAME.to_string(), RepositoryConfig::default());
380 cfg.reloaders.as_mut().unwrap().set(
381 "reloader-a".to_string(),
382 ReloaderConfig {
383 repository: EXAMPLE_REPO_NAME.to_string(),
384 mirror: Some(MirrorConfig {
385 lifecycle_hooks: vec![LifecycleHookConfig {
386 hook_type: "edit_mode_prepare".to_string(),
387 script: "make first".to_string(),
388 ..Default::default()
389 }],
390 ..Default::default()
391 }),
392 ..Default::default()
393 },
394 );
395 cfg.reloaders.as_mut().unwrap().set(
396 "reloader-b".to_string(),
397 ReloaderConfig {
398 repository: EXAMPLE_REPO_NAME.to_string(),
399 mirror: Some(MirrorConfig {
400 lifecycle_hooks: vec![LifecycleHookConfig {
401 hook_type: "edit_mode_prepare".to_string(),
402 script: "make second".to_string(),
403 ..Default::default()
404 }],
405 ..Default::default()
406 }),
407 ..Default::default()
408 },
409 );
410
411 let actual = resolve_repository_hooks(&cfg, EXAMPLE_REPO_NAME, HookType::EditModePrepare);
412 let actual_scripts: Vec<String> = actual.iter().map(|v| v.script.clone()).collect();
413
414 assert_eq!(
415 actual_scripts,
416 vec!["make first".to_string(), "make second".to_string()]
417 );
418 }
419
420 #[test]
421 fn resolve_hooks_in_repository_skips_reloaders_for_other_repositories() {
422 let mut cfg = base_caproni_config();
423 cfg.repositories
424 .as_mut()
425 .unwrap()
426 .set(EXAMPLE_REPO_NAME.to_string(), RepositoryConfig::default());
427 cfg.reloaders.as_mut().unwrap().set(
428 "reloader-a".to_string(),
429 ReloaderConfig {
430 repository: "other-repo".to_string(),
431 mirror: Some(MirrorConfig {
432 lifecycle_hooks: vec![LifecycleHookConfig {
433 hook_type: "edit_mode_prepare".to_string(),
434 script: "make prepare".to_string(),
435 ..Default::default()
436 }],
437 ..Default::default()
438 }),
439 ..Default::default()
440 },
441 );
442
443 let actual = resolve_repository_hooks(&cfg, EXAMPLE_REPO_NAME, HookType::EditModePrepare);
444 assert!(actual.is_empty());
445 }
446
447 #[test]
448 fn resolve_hooks_in_repository_returns_nil_when_no_reloaders_exist() {
449 let mut cfg = base_caproni_config();
450 cfg.repositories
451 .as_mut()
452 .unwrap()
453 .set(EXAMPLE_REPO_NAME.to_string(), RepositoryConfig::default());
454
455 let actual = resolve_repository_hooks(&cfg, EXAMPLE_REPO_NAME, HookType::EditModePrepare);
456 assert!(actual.is_empty());
457 }
458
459 fn run_hooks_test_config() -> (CaproniConfig, RepositoryConfig, String) {
462 let cfg = base_caproni_config();
463 let repo_config = RepositoryConfig {
464 directory: "gitlab".to_string(),
465 ..Default::default()
466 };
467 let repo_dir = cfg.resolve_path(&repo_config.directory);
468
469 (cfg, repo_config, repo_dir)
470 }
471
472 #[test]
473 fn run_hooks_runs_all_hooks_in_order() {
474 let (cfg, repo_config, repo_dir) = run_hooks_test_config();
475 let hooks = vec![
476 LifecycleHookConfig {
477 script: "make first".to_string(),
478 ..Default::default()
479 },
480 LifecycleHookConfig {
481 script: "make second --flag".to_string(),
482 ..Default::default()
483 },
484 ];
485
486 let mock_exec = MockCommandRunner::new();
487 mock_exec.expect_run_in_repository(&repo_dir, "make", &["first"], Ok(()));
488 mock_exec.expect_run_in_repository(&repo_dir, "make", &["second", "--flag"], Ok(()));
489
490 let result = run_hooks(
491 &mock_exec,
492 &cfg,
493 &TestResolver,
494 &repo_config,
495 "gitlab",
496 &hooks,
497 None,
498 );
499 assert!(result.is_ok());
500 }
501
502 #[test]
503 fn run_hooks_returns_error_on_script_parse_failure() {
504 let (cfg, repo_config, _repo_dir) = run_hooks_test_config();
505 let hooks = vec![LifecycleHookConfig {
506 script: "unterminated 'single quote".to_string(),
507 ..Default::default()
508 }];
509
510 let mock_exec = MockCommandRunner::new();
511
512 let err = run_hooks(
513 &mock_exec,
514 &cfg,
515 &TestResolver,
516 &repo_config,
517 "gitlab",
518 &hooks,
519 None,
520 )
521 .unwrap_err();
522 assert!(err.to_string().contains("parsing hook"));
523 }
524
525 #[test]
526 fn run_hooks_returns_error_when_executor_fails() {
527 let (cfg, repo_config, repo_dir) = run_hooks_test_config();
528 let hooks = vec![LifecycleHookConfig {
529 script: "make prepare".to_string(),
530 ..Default::default()
531 }];
532
533 let mock_exec = MockCommandRunner::new();
534 mock_exec.expect_run_in_repository(
535 &repo_dir,
536 "make",
537 &["prepare"],
538 Err(xianyun_executor::exec::ExecError::CommandFailed(1)),
539 );
540
541 let err = run_hooks(
542 &mock_exec,
543 &cfg,
544 &TestResolver,
545 &repo_config,
546 "gitlab",
547 &hooks,
548 None,
549 )
550 .unwrap_err();
551 assert!(
556 err.to_string()
557 .contains("running [make prepare] in repo gitlab")
558 );
559 }
560
561 #[test]
562 fn run_hooks_skips_empty_hooks_slice() {
563 let (cfg, repo_config, _repo_dir) = run_hooks_test_config();
564 let hooks: Vec<LifecycleHookConfig> = vec![];
565
566 let mock_exec = MockCommandRunner::new();
567
568 let result = run_hooks(
569 &mock_exec,
570 &cfg,
571 &TestResolver,
572 &repo_config,
573 "gitlab",
574 &hooks,
575 None,
576 );
577 assert!(result.is_ok());
578 }
579
580 #[test]
590 fn run_hooks_kubeconfig_env() {
591 let (cfg, repo_config, repo_dir) = run_hooks_test_config();
592 let hooks = vec![LifecycleHookConfig {
593 script: "make run".to_string(),
594 ..Default::default()
595 }];
596
597 const KUBECONFIG_YAML: &str = "apiVersion: v1\nkind: Config\n";
598 let kubeconfig_file = xianyun_cluster::kubeconfig::Config::create(KUBECONFIG_YAML).unwrap();
599
600 let mock_exec = MockCommandRunner::new();
601 mock_exec.expect_run_in_repository(&repo_dir, "make", &["run"], Ok(()));
602
603 let result = run_hooks(
604 &mock_exec,
605 &cfg,
606 &TestResolver,
607 &repo_config,
608 "gitlab",
609 &hooks,
610 Some(&kubeconfig_file),
611 );
612 assert!(result.is_ok());
613
614 kubeconfig_file.close().unwrap();
620 }
621
622 #[test]
625 fn run_hooks_no_kubeconfig_no_env() {
626 let (cfg, repo_config, repo_dir) = run_hooks_test_config();
627 let hooks = vec![LifecycleHookConfig {
628 script: "make run".to_string(),
629 ..Default::default()
630 }];
631
632 let mock_exec = MockCommandRunner::new();
633 mock_exec.expect_run_in_repository(&repo_dir, "make", &["run"], Ok(()));
634
635 let result = run_hooks(
636 &mock_exec,
637 &cfg,
638 &TestResolver,
639 &repo_config,
640 "gitlab",
641 &hooks,
642 None,
643 );
644 assert!(result.is_ok());
645
646 }
651}