Skip to main content

xianyun_update/
lifecyclehooks.rs

1//! Lifecycle hook resolution and execution.
2//!
3//! Ported from `internal/lifecyclehooks/{types,hooks}.go`. See
4//! `PORT_NOTES.md`.
5
6use xianyun_core::config::latest::{CaproniConfig, LifecycleHookConfig, RepositoryConfig};
7use xianyun_core::variables::resolver::Resolver;
8use xianyun_executor::exec::CommandRunner;
9
10/// Ported from `HookType`.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum HookType {
13    /// Ported from `TypeEditModePrepare`: runs after a repository is
14    /// cloned or updated.
15    EditModePrepare,
16    /// Ported from `TypeEditModeStart`: runs before starting an edit mode
17    /// process. The `KUBECONFIG` environment variable will be present.
18    EditModeStart,
19    /// Ported from `TypeEditModeStop`: runs after an edit mode process
20    /// exits, regardless of exit code. The `KUBECONFIG` environment
21    /// variable will be present.
22    EditModeStop,
23}
24
25impl HookType {
26    /// Ported from the Go `HookType` string constant values, used for
27    /// comparison against `LifecycleHookConfig.hook_type`.
28    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/// Ported from `ResolveReloaderHooks`.
38#[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/// Ported from `FilterHooks`.
51#[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/// Ported from `ResolveRepositoryHooks`. Resolves all hooks in the
61/// repository and all associated reloaders.
62#[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/// Errors ported from `RunHooks`'s `fmt.Errorf` wraps.
92#[derive(Debug, thiserror::Error)]
93pub enum HooksError {
94    /// Ported from `fmt.Errorf("parsing hook %v: %w", hook, perr)`. Go's
95    /// `%v` on a `latest.LifecycleHookConfig` struct prints its field
96    /// values; this port keeps only the script text, which is what every
97    /// ported test actually asserts on (`notes.contains("parsing hook")`).
98    #[error("parsing hook {0:?}: {1}")]
99    ParseFailed(String, String),
100    /// Ported from `fmt.Errorf("running %v in repo %s: %w", script, repoName, err)`.
101    /// Go's `%v` on a `[]string` prints `[elem1 elem2]` (space-separated,
102    /// unquoted) rather than Rust's `{:?}` Debug format
103    /// (`["elem1", "elem2"]`), so this variant renders it manually to
104    /// match.
105    #[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/// Ported from `RunHooks`. `kubeconfig_file` is a temporary kubeconfig
114/// (created and owned by the caller). When `Some`, it is exposed to each
115/// hook process via `KUBECONFIG` and `KUBECACHEDIR` environment variables,
116/// so that kubectl/helm invocations inside the hook target the
117/// Caproni-managed cluster. When `None` (e.g. during `edit_mode_prepare`
118/// hooks that run before the cluster is available), hooks run without
119/// cluster env vars set.
120#[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
167/// Ported from `buildHookEnv`. Returns the environment using the caproni
168/// variables from the resolver, appended to the current process
169/// environment, and optionally the kubeconfig if `kubeconfig_file` is
170/// given, for hook subprocesses.
171fn 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
188/// Splits a shell-style command string into words, mirroring the narrow
189/// slice of `github.com/mattn/go-shellwords`'s behavior `hooks.go` relies
190/// on: whitespace-separated words, with single- and double-quoted
191/// sections treated as one word (quotes stripped), and an error on an
192/// unterminated quote.
193fn 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    // --- Ported from TestResolveHooks ---
285
286    #[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    // --- Ported from TestResolveHooksInRepository ---
372
373    #[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    // --- Ported from TestRunHooks ---
460
461    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        // Corrected from the Phase 3 stub's `["make", "prepare"]` (Rust
552        // Debug format) to `[make prepare]`, matching Go's `%v` formatting
553        // of a `[]string` in `fmt.Errorf("running %v in repo %s: %w", ...)`
554        // -- see `PORT_NOTES.md`.
555        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    // --- Ported from TestRunHooks_KubeconfigEnv ---
581
582    // `xianyun_cluster::kubeconfig::Config::create` is implemented now, so
583    // this runs for real. It cannot assert on the `KUBECONFIG=`/
584    // `KUBECACHEDIR=` env vars Go's original inspects (see the comment at
585    // the end of this test): `xianyun-testutil::MockCommandRunner` does
586    // not capture `opts` at all, and `xianyun-testutil` is outside this
587    // crate's scope to change. What it does verify for real is that
588    // `run_hooks` succeeds when handed a materialized kubeconfig file.
589    #[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        // Go's version inspects `opts.Env` inside a `RunAndReturn` closure
615        // for `KUBECONFIG=`/`KUBECACHEDIR=` prefixes; this port's mock
616        // (`testutil::MockCommandRunner`) does not capture the `opts`
617        // argument at all (see its doc comment), so that inspection has no
618        // equivalent here yet. See PORT_NOTES.md.
619        kubeconfig_file.close().unwrap();
620    }
621
622    // --- Ported from TestRunHooks_NoKubeconfig_NoEnv ---
623
624    #[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        // See `run_hooks_kubeconfig_env`: this port's mock does not capture
647        // `opts.Env`, so the assertion that no KUBECONFIG/KUBECACHEDIR
648        // entries are present has no equivalent here yet. See
649        // PORT_NOTES.md.
650    }
651}