Skip to main content

arcbox_oci/
hooks.rs

1//! OCI lifecycle hooks.
2//!
3//! Hooks allow custom actions at various points in the container lifecycle.
4//! Reference: <https://github.com/opencontainers/runtime-spec/blob/main/config.md#posix-platform-hooks>
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9use crate::error::{OciError, Result};
10use crate::state::State;
11
12/// Container lifecycle hooks.
13///
14/// Hooks are executed at specific points during the container lifecycle:
15/// - `create_runtime`: After create operation, before `pivot_root`
16/// - `create_container`: After `pivot_root`, before user process starts
17/// - `start_container`: Before user process executes
18/// - `poststart`: After user process starts
19/// - `poststop`: After container process exits
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct Hooks {
23    /// Hooks run after create operation (DEPRECATED).
24    #[deprecated(note = "Use createRuntime instead")]
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub prestart: Vec<Hook>,
27
28    /// Hooks run during create operation, after environment setup.
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub create_runtime: Vec<Hook>,
31
32    /// Hooks run after `pivot_root` but before user process.
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub create_container: Vec<Hook>,
35
36    /// Hooks run before user process executes.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub start_container: Vec<Hook>,
39
40    /// Hooks run after user process starts.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub poststart: Vec<Hook>,
43
44    /// Hooks run after container process exits.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub poststop: Vec<Hook>,
47}
48
49impl Hooks {
50    /// Create empty hooks configuration.
51    #[must_use]
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Check if any hooks are configured.
57    #[allow(deprecated)]
58    #[must_use]
59    pub fn is_empty(&self) -> bool {
60        self.prestart.is_empty()
61            && self.create_runtime.is_empty()
62            && self.create_container.is_empty()
63            && self.start_container.is_empty()
64            && self.poststart.is_empty()
65            && self.poststop.is_empty()
66    }
67
68    /// Validate all hooks.
69    pub fn validate(&self) -> Result<()> {
70        #[allow(deprecated)]
71        for hook in &self.prestart {
72            hook.validate()?;
73        }
74        for hook in &self.create_runtime {
75            hook.validate()?;
76        }
77        for hook in &self.create_container {
78            hook.validate()?;
79        }
80        for hook in &self.start_container {
81            hook.validate()?;
82        }
83        for hook in &self.poststart {
84            hook.validate()?;
85        }
86        for hook in &self.poststop {
87            hook.validate()?;
88        }
89        Ok(())
90    }
91}
92
93/// A single hook entry.
94///
95/// Hooks receive the container state JSON on stdin when executed.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Hook {
98    /// Absolute path to the hook executable.
99    /// REQUIRED field.
100    pub path: PathBuf,
101
102    /// Arguments passed to the hook (including argv[0]).
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub args: Vec<String>,
105
106    /// Environment variables for the hook.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub env: Vec<String>,
109
110    /// Timeout in seconds (0 or None means no timeout).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub timeout: Option<u32>,
113}
114
115impl Hook {
116    /// Create a new hook with the given path.
117    #[must_use]
118    pub fn new<P: Into<PathBuf>>(path: P) -> Self {
119        Self {
120            path: path.into(),
121            args: Vec::new(),
122            env: Vec::new(),
123            timeout: None,
124        }
125    }
126
127    /// Set hook arguments.
128    #[must_use]
129    pub fn with_args(mut self, args: Vec<String>) -> Self {
130        self.args = args;
131        self
132    }
133
134    /// Set hook environment.
135    #[must_use]
136    pub fn with_env(mut self, env: Vec<String>) -> Self {
137        self.env = env;
138        self
139    }
140
141    /// Set hook timeout.
142    #[must_use]
143    pub const fn with_timeout(mut self, timeout: u32) -> Self {
144        self.timeout = Some(timeout);
145        self
146    }
147
148    /// Validate the hook configuration.
149    pub fn validate(&self) -> Result<()> {
150        // Path must be absolute.
151        if !self.path.is_absolute() {
152            return Err(OciError::InvalidConfig(format!(
153                "hook path must be absolute: {}",
154                self.path.display()
155            )));
156        }
157
158        // Timeout must be positive if set.
159        if let Some(timeout) = self.timeout {
160            if timeout == 0 {
161                return Err(OciError::InvalidConfig(
162                    "hook timeout must be greater than 0".to_string(),
163                ));
164            }
165        }
166
167        Ok(())
168    }
169}
170
171/// Hook execution context.
172///
173/// This structure holds the state that will be passed to hooks
174/// during execution.
175#[derive(Debug, Clone)]
176pub struct HookContext {
177    /// Container state to pass to hooks.
178    pub state: State,
179    /// Bundle path.
180    pub bundle: PathBuf,
181}
182
183impl HookContext {
184    /// Create a new hook context.
185    #[must_use]
186    pub const fn new(state: State, bundle: PathBuf) -> Self {
187        Self { state, bundle }
188    }
189
190    /// Get the state JSON to pass to hooks via stdin.
191    pub fn state_json(&self) -> Result<String> {
192        Ok(serde_json::to_string(&self.state)?)
193    }
194}
195
196/// Hook execution result.
197#[derive(Debug, Clone)]
198pub struct HookResult {
199    /// Exit code of the hook.
200    pub exit_code: i32,
201    /// Standard output.
202    pub stdout: String,
203    /// Standard error.
204    pub stderr: String,
205}
206
207impl HookResult {
208    /// Check if the hook succeeded (exit code 0).
209    #[must_use]
210    pub const fn success(&self) -> bool {
211        self.exit_code == 0
212    }
213}
214
215/// Hook type for categorization.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum HookType {
218    /// Pre-start hooks (deprecated).
219    Prestart,
220    /// Create runtime hooks.
221    CreateRuntime,
222    /// Create container hooks.
223    CreateContainer,
224    /// Start container hooks.
225    StartContainer,
226    /// Post-start hooks.
227    Poststart,
228    /// Post-stop hooks.
229    Poststop,
230}
231
232impl HookType {
233    /// Get the hook type name.
234    #[must_use]
235    pub const fn as_str(&self) -> &'static str {
236        match self {
237            Self::Prestart => "prestart",
238            Self::CreateRuntime => "createRuntime",
239            Self::CreateContainer => "createContainer",
240            Self::StartContainer => "startContainer",
241            Self::Poststart => "poststart",
242            Self::Poststop => "poststop",
243        }
244    }
245}
246
247impl std::fmt::Display for HookType {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        write!(f, "{}", self.as_str())
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_hook_validation_absolute_path() {
259        let hook = Hook::new("/usr/bin/hook");
260        assert!(hook.validate().is_ok());
261    }
262
263    #[test]
264    fn test_hook_validation_relative_path() {
265        let hook = Hook::new("relative/path");
266        assert!(hook.validate().is_err());
267    }
268
269    #[test]
270    fn test_hook_validation_zero_timeout() {
271        let hook = Hook::new("/usr/bin/hook").with_timeout(0);
272        assert!(hook.validate().is_err());
273    }
274
275    #[test]
276    fn test_hook_builder() {
277        let hook = Hook::new("/usr/bin/hook")
278            .with_args(vec!["hook".to_string(), "--config".to_string()])
279            .with_env(vec!["FOO=bar".to_string()])
280            .with_timeout(30);
281
282        assert_eq!(hook.path.to_str().unwrap(), "/usr/bin/hook");
283        assert_eq!(hook.args.len(), 2);
284        assert_eq!(hook.env.len(), 1);
285        assert_eq!(hook.timeout, Some(30));
286    }
287
288    #[test]
289    fn test_hooks_empty() {
290        let hooks = Hooks::new();
291        assert!(hooks.is_empty());
292    }
293
294    #[test]
295    fn test_parse_hooks() {
296        let json = r#"{
297            "createRuntime": [
298                {
299                    "path": "/usr/bin/setup-network",
300                    "args": ["setup-network", "--type=bridge"],
301                    "timeout": 30
302                }
303            ],
304            "poststart": [
305                {
306                    "path": "/usr/bin/notify",
307                    "env": ["NOTIFY_SOCKET=/run/notify.sock"]
308                }
309            ]
310        }"#;
311
312        let hooks: Hooks = serde_json::from_str(json).unwrap();
313        assert_eq!(hooks.create_runtime.len(), 1);
314        assert_eq!(hooks.poststart.len(), 1);
315        assert!(hooks.poststop.is_empty());
316    }
317
318    #[test]
319    fn test_hooks_not_empty() {
320        let mut hooks = Hooks::new();
321        assert!(hooks.is_empty());
322
323        hooks.create_runtime.push(Hook::new("/usr/bin/hook"));
324        assert!(!hooks.is_empty());
325    }
326
327    #[test]
328    fn test_hooks_validate_all_valid() {
329        let hooks = Hooks {
330            create_runtime: vec![Hook::new("/usr/bin/hook1")],
331            create_container: vec![Hook::new("/usr/bin/hook2")],
332            start_container: vec![Hook::new("/usr/bin/hook3")],
333            poststart: vec![Hook::new("/usr/bin/hook4")],
334            poststop: vec![Hook::new("/usr/bin/hook5")],
335            ..Default::default()
336        };
337
338        assert!(hooks.validate().is_ok());
339    }
340
341    #[test]
342    fn test_hooks_validate_invalid_in_create_runtime() {
343        let hooks = Hooks {
344            create_runtime: vec![Hook::new("relative/path")],
345            ..Default::default()
346        };
347
348        assert!(hooks.validate().is_err());
349    }
350
351    #[test]
352    fn test_hooks_validate_invalid_in_poststart() {
353        let hooks = Hooks {
354            poststart: vec![Hook::new("relative/path")],
355            ..Default::default()
356        };
357
358        assert!(hooks.validate().is_err());
359    }
360
361    #[test]
362    fn test_hooks_validate_invalid_in_poststop() {
363        let hooks = Hooks {
364            poststop: vec![Hook::new("relative/path")],
365            ..Default::default()
366        };
367
368        assert!(hooks.validate().is_err());
369    }
370
371    #[test]
372    fn test_hook_type_as_str() {
373        assert_eq!(HookType::Prestart.as_str(), "prestart");
374        assert_eq!(HookType::CreateRuntime.as_str(), "createRuntime");
375        assert_eq!(HookType::CreateContainer.as_str(), "createContainer");
376        assert_eq!(HookType::StartContainer.as_str(), "startContainer");
377        assert_eq!(HookType::Poststart.as_str(), "poststart");
378        assert_eq!(HookType::Poststop.as_str(), "poststop");
379    }
380
381    #[test]
382    fn test_hook_type_display() {
383        assert_eq!(HookType::Prestart.to_string(), "prestart");
384        assert_eq!(HookType::CreateRuntime.to_string(), "createRuntime");
385        assert_eq!(HookType::Poststart.to_string(), "poststart");
386    }
387
388    #[test]
389    fn test_hook_result_success() {
390        let success = HookResult {
391            exit_code: 0,
392            stdout: "output".to_string(),
393            stderr: String::new(),
394        };
395        assert!(success.success());
396
397        let failure = HookResult {
398            exit_code: 1,
399            stdout: String::new(),
400            stderr: "error".to_string(),
401        };
402        assert!(!failure.success());
403    }
404
405    #[test]
406    fn test_hook_context_new() {
407        let state = State::new("test".to_string(), std::path::PathBuf::from("/bundle"));
408        let context = HookContext::new(state, std::path::PathBuf::from("/bundle"));
409
410        assert_eq!(context.state.id, "test");
411        assert_eq!(context.bundle, std::path::PathBuf::from("/bundle"));
412    }
413
414    #[test]
415    fn test_hook_context_state_json() {
416        let state = State::new(
417            "test-container".to_string(),
418            std::path::PathBuf::from("/bundle"),
419        );
420        let context = HookContext::new(state, std::path::PathBuf::from("/bundle"));
421
422        let json = context.state_json().unwrap();
423        assert!(json.contains("test-container"));
424        assert!(json.contains("creating"));
425    }
426
427    #[test]
428    fn test_hook_serialization() {
429        let hook = Hook::new("/usr/bin/test")
430            .with_args(vec!["test".to_string(), "--flag".to_string()])
431            .with_env(vec!["VAR=value".to_string()])
432            .with_timeout(60);
433
434        let json = serde_json::to_string(&hook).unwrap();
435        assert!(json.contains("/usr/bin/test"));
436        assert!(json.contains("--flag"));
437        assert!(json.contains("VAR=value"));
438        assert!(json.contains("60"));
439
440        // Roundtrip.
441        let parsed: Hook = serde_json::from_str(&json).unwrap();
442        assert_eq!(parsed.path, hook.path);
443        assert_eq!(parsed.args, hook.args);
444        assert_eq!(parsed.env, hook.env);
445        assert_eq!(parsed.timeout, hook.timeout);
446    }
447
448    #[test]
449    fn test_hooks_serialization_roundtrip() {
450        let hooks = Hooks {
451            create_runtime: vec![Hook::new("/usr/bin/setup").with_timeout(30)],
452            poststart: vec![
453                Hook::new("/usr/bin/notify")
454                    .with_args(vec!["notify".to_string()])
455                    .with_env(vec!["SOCKET=/run/notify.sock".to_string()]),
456            ],
457            poststop: vec![Hook::new("/usr/bin/cleanup")],
458            ..Default::default()
459        };
460
461        let json = serde_json::to_string(&hooks).unwrap();
462        let parsed: Hooks = serde_json::from_str(&json).unwrap();
463
464        assert_eq!(parsed.create_runtime.len(), 1);
465        assert_eq!(parsed.poststart.len(), 1);
466        assert_eq!(parsed.poststop.len(), 1);
467    }
468
469    #[test]
470    fn test_parse_all_hook_types() {
471        let json = r#"{
472            "createRuntime": [{"path": "/bin/cr"}],
473            "createContainer": [{"path": "/bin/cc"}],
474            "startContainer": [{"path": "/bin/sc"}],
475            "poststart": [{"path": "/bin/ps"}],
476            "poststop": [{"path": "/bin/pp"}]
477        }"#;
478
479        let hooks: Hooks = serde_json::from_str(json).unwrap();
480        assert_eq!(hooks.create_runtime.len(), 1);
481        assert_eq!(hooks.create_container.len(), 1);
482        assert_eq!(hooks.start_container.len(), 1);
483        assert_eq!(hooks.poststart.len(), 1);
484        assert_eq!(hooks.poststop.len(), 1);
485    }
486
487    #[test]
488    fn test_hook_with_valid_timeout() {
489        let hook = Hook::new("/usr/bin/hook").with_timeout(1);
490        assert!(hook.validate().is_ok());
491
492        let hook = Hook::new("/usr/bin/hook").with_timeout(3600);
493        assert!(hook.validate().is_ok());
494    }
495
496    #[test]
497    fn test_hook_no_timeout() {
498        let hook = Hook::new("/usr/bin/hook");
499        assert!(hook.timeout.is_none());
500        assert!(hook.validate().is_ok());
501    }
502
503    #[test]
504    fn test_hook_empty_args_and_env() {
505        let hook = Hook::new("/usr/bin/hook");
506        assert!(hook.args.is_empty());
507        assert!(hook.env.is_empty());
508    }
509
510    #[test]
511    fn test_hook_type_equality() {
512        assert_eq!(HookType::Prestart, HookType::Prestart);
513        assert_ne!(HookType::Prestart, HookType::Poststart);
514    }
515
516    #[test]
517    fn test_hooks_default() {
518        let hooks = Hooks::default();
519        assert!(hooks.is_empty());
520        assert!(hooks.create_runtime.is_empty());
521        assert!(hooks.create_container.is_empty());
522        assert!(hooks.start_container.is_empty());
523        assert!(hooks.poststart.is_empty());
524        assert!(hooks.poststop.is_empty());
525    }
526
527    #[test]
528    fn test_multiple_hooks_per_type() {
529        let hooks = Hooks {
530            poststart: vec![
531                Hook::new("/usr/bin/hook1"),
532                Hook::new("/usr/bin/hook2"),
533                Hook::new("/usr/bin/hook3"),
534            ],
535            ..Default::default()
536        };
537
538        assert_eq!(hooks.poststart.len(), 3);
539        assert!(hooks.validate().is_ok());
540    }
541}