Skip to main content

rskit_workload/
component.rs

1//! Lifecycle-managed workload component.
2//!
3//! Mirrors gokit's `workload.Component`:
4//! wraps a [`Manager`] built from an injected [`WorkloadRegistry`] and participates in ordered startup,
5//! shutdown, and health reporting through [`rskit_component::Component`].
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use parking_lot::Mutex;
11use rskit_component::{Component, Health};
12use rskit_errors::AppResult;
13use tracing::{debug, info};
14
15use crate::config::WorkloadConfig;
16use crate::manager::Manager;
17use crate::registry::WorkloadRegistry;
18
19/// A lifecycle-managed workload component.
20///
21/// On start it builds the configured backend from the injected registry
22/// and probes it with a health check
23/// so an unreachable backend fails startup rather than reporting healthy;
24/// on stop it releases the manager.
25/// When [`WorkloadConfig::enabled`] is `false` the component is a healthy no-op
26/// and never touches the registry.
27pub struct WorkloadComponent {
28    config: WorkloadConfig,
29    providers: WorkloadRegistry,
30    manager: Mutex<Option<Arc<dyn Manager>>>,
31}
32
33impl WorkloadComponent {
34    /// Create a component with an empty provider registry.
35    ///
36    /// Useful when the component is disabled;
37    /// register providers via [`WorkloadComponent::with_registry`] to run an enabled backend.
38    #[must_use]
39    pub fn new(config: WorkloadConfig) -> Self {
40        Self::with_registry(config, WorkloadRegistry::new())
41    }
42
43    /// Create a component with an explicit provider registry.
44    #[must_use]
45    pub fn with_registry(config: WorkloadConfig, providers: WorkloadRegistry) -> Self {
46        Self {
47            config,
48            providers,
49            manager: Mutex::new(None),
50        }
51    }
52
53    /// Return the underlying manager once the component has started, or `None`.
54    #[must_use]
55    pub fn manager(&self) -> Option<Arc<dyn Manager>> {
56        self.manager.lock().clone()
57    }
58}
59
60#[async_trait]
61impl Component for WorkloadComponent {
62    #[allow(clippy::unnecessary_literal_bound)] // trait fixes the return type to &str
63    fn name(&self) -> &str {
64        "workload"
65    }
66
67    async fn start(&self) -> AppResult<()> {
68        let mut config = self.config.clone();
69        config.apply_defaults();
70
71        if !config.enabled {
72            debug!("workload component disabled — skipping backend init");
73            return Ok(());
74        }
75
76        let manager = self.providers.build(&config).await?;
77        manager.health_check().await?;
78        info!(provider = %config.provider, "workload manager initialized");
79        *self.manager.lock() = Some(manager);
80        Ok(())
81    }
82
83    async fn stop(&self) -> AppResult<()> {
84        debug!("workload component stopping");
85        *self.manager.lock() = None;
86        Ok(())
87    }
88
89    fn health(&self) -> Health {
90        if !self.config.enabled {
91            return Health::healthy("workload (disabled)");
92        }
93        if self.manager.lock().is_none() {
94            return Health::unhealthy("workload", "manager not initialized");
95        }
96        Health::healthy("workload")
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::test_support::{FailingFactory, FakeFactory, UnhealthyFactory};
104    use rskit_errors::ErrorCode;
105
106    fn enabled_registry(
107        factory: Arc<dyn crate::registry::ManagerFactory>,
108    ) -> (WorkloadConfig, WorkloadRegistry) {
109        let mut registry = WorkloadRegistry::new();
110        registry.register("docker", factory).unwrap();
111        let config = WorkloadConfig {
112            enabled: true,
113            provider: "docker".to_string(),
114            ..Default::default()
115        };
116        (config, registry)
117    }
118
119    #[tokio::test]
120    async fn disabled_component_starts_healthy_without_manager() {
121        let component = WorkloadComponent::new(WorkloadConfig::default());
122        assert_eq!(component.name(), "workload");
123        component.start().await.unwrap();
124        assert!(component.manager().is_none());
125        assert!(component.health().is_healthy());
126        component.stop().await.unwrap();
127    }
128
129    #[tokio::test]
130    async fn enabled_component_builds_manager_and_releases_on_stop() {
131        let (config, registry) = enabled_registry(Arc::new(FakeFactory));
132        let component = WorkloadComponent::with_registry(config, registry);
133
134        component.start().await.unwrap();
135        assert!(component.manager().is_some());
136        assert!(component.health().is_healthy());
137
138        component.stop().await.unwrap();
139        assert!(component.manager().is_none());
140        assert!(!component.health().is_healthy());
141    }
142
143    #[tokio::test]
144    async fn enabled_component_before_start_is_unhealthy() {
145        let (config, registry) = enabled_registry(Arc::new(FakeFactory));
146        let component = WorkloadComponent::with_registry(config, registry);
147        assert!(!component.health().is_healthy());
148    }
149
150    #[tokio::test]
151    async fn start_propagates_backend_build_failure() {
152        let (config, registry) = enabled_registry(Arc::new(FailingFactory));
153        let component = WorkloadComponent::with_registry(config, registry);
154        let err = component.start().await.unwrap_err();
155        assert_eq!(err.code(), ErrorCode::Internal);
156        assert!(component.manager().is_none());
157    }
158
159    #[tokio::test]
160    async fn start_fails_when_backend_health_check_fails() {
161        let (config, registry) = enabled_registry(Arc::new(UnhealthyFactory));
162        let component = WorkloadComponent::with_registry(config, registry);
163        let err = component.start().await.unwrap_err();
164        assert_eq!(err.code(), ErrorCode::ServiceUnavailable);
165        assert!(component.manager().is_none());
166    }
167
168    #[tokio::test]
169    async fn enabled_component_with_unregistered_provider_fails_to_start() {
170        let config = WorkloadConfig {
171            enabled: true,
172            provider: "podman".to_string(),
173            ..Default::default()
174        };
175        let component = WorkloadComponent::with_registry(config, WorkloadRegistry::new());
176        assert_eq!(
177            component.start().await.unwrap_err().code(),
178            ErrorCode::NotFound
179        );
180    }
181}