Skip to main content

greentic_setup/
reload.rs

1//! Hot reload types and diffing for bundle changes.
2//!
3//! When a bundle is updated via the admin API, the reload module computes
4//! what changed (added/removed/changed packs, providers, tenants) and
5//! produces a [`ReloadPlan`] that the consuming runtime can apply.
6//!
7//! The actual runtime reload (swapping `Arc<RunnerHost>`, draining connections)
8//! lives in the consuming crate (e.g. greentic-operator).
9
10use std::collections::BTreeSet;
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15use crate::discovery::{DetectedProvider, DiscoveryResult};
16
17/// A computed diff between two bundle states.
18#[derive(Clone, Debug, Default, Serialize)]
19pub struct BundleDiff {
20    /// Packs added since the last state.
21    pub packs_added: Vec<DetectedProvider>,
22    /// Packs removed since the last state.
23    pub packs_removed: Vec<DetectedProvider>,
24    /// Packs that changed (same provider_id, different file content).
25    pub packs_changed: Vec<DetectedProvider>,
26    /// Provider IDs added to the registry.
27    pub providers_added: Vec<String>,
28    /// Provider IDs removed from the registry.
29    pub providers_removed: Vec<String>,
30    /// Tenants added.
31    pub tenants_added: Vec<String>,
32    /// Tenants removed.
33    pub tenants_removed: Vec<String>,
34}
35
36impl BundleDiff {
37    /// Returns `true` if there are no changes.
38    pub fn is_empty(&self) -> bool {
39        self.packs_added.is_empty()
40            && self.packs_removed.is_empty()
41            && self.packs_changed.is_empty()
42            && self.providers_added.is_empty()
43            && self.providers_removed.is_empty()
44            && self.tenants_added.is_empty()
45            && self.tenants_removed.is_empty()
46    }
47
48    /// Total number of changes across all categories.
49    pub fn change_count(&self) -> usize {
50        self.packs_added.len()
51            + self.packs_removed.len()
52            + self.packs_changed.len()
53            + self.providers_added.len()
54            + self.providers_removed.len()
55            + self.tenants_added.len()
56            + self.tenants_removed.len()
57    }
58}
59
60/// A plan for applying a bundle diff at runtime.
61#[derive(Clone, Debug, Serialize)]
62pub struct ReloadPlan {
63    pub bundle: PathBuf,
64    pub diff: BundleDiff,
65    pub actions: Vec<ReloadAction>,
66}
67
68/// Individual reload action.
69#[derive(Clone, Debug, Serialize, Deserialize)]
70#[serde(tag = "kind", rename_all = "snake_case")]
71pub enum ReloadAction {
72    /// Load a new WASM component into the runtime.
73    LoadComponent { provider_id: String, path: PathBuf },
74    /// Unload a WASM component from the runtime.
75    UnloadComponent { provider_id: String },
76    /// Reload a changed WASM component (unload + load).
77    ReloadComponent { provider_id: String, path: PathBuf },
78    /// Update the provider route table.
79    UpdateRoutes,
80    /// Re-run the resolver to regenerate manifests.
81    RunResolver,
82    /// Seed secrets for newly added packs.
83    SeedSecrets { provider_id: String },
84}
85
86/// Compute the diff between two discovery results (previous and current state).
87pub fn diff_discoveries(prev: &DiscoveryResult, curr: &DiscoveryResult) -> BundleDiff {
88    let prev_ids: BTreeSet<&str> = prev
89        .providers
90        .iter()
91        .map(|p| p.provider_id.as_str())
92        .collect();
93    let curr_ids: BTreeSet<&str> = curr
94        .providers
95        .iter()
96        .map(|p| p.provider_id.as_str())
97        .collect();
98
99    let added_ids: BTreeSet<&&str> = curr_ids.difference(&prev_ids).collect();
100    let removed_ids: BTreeSet<&&str> = prev_ids.difference(&curr_ids).collect();
101
102    let packs_added: Vec<DetectedProvider> = curr
103        .providers
104        .iter()
105        .filter(|p| added_ids.contains(&&p.provider_id.as_str()))
106        .cloned()
107        .collect();
108
109    let packs_removed: Vec<DetectedProvider> = prev
110        .providers
111        .iter()
112        .filter(|p| removed_ids.contains(&&p.provider_id.as_str()))
113        .cloned()
114        .collect();
115
116    // Changed packs: same ID but different file path (content change detection
117    // via path — full content hashing is left to the consumer).
118    let packs_changed: Vec<DetectedProvider> = curr
119        .providers
120        .iter()
121        .filter(|cp| {
122            if added_ids.contains(&&cp.provider_id.as_str()) {
123                return false;
124            }
125            prev.providers
126                .iter()
127                .any(|pp| pp.provider_id == cp.provider_id && pp.pack_path != cp.pack_path)
128        })
129        .cloned()
130        .collect();
131
132    BundleDiff {
133        packs_added,
134        packs_removed,
135        packs_changed,
136        providers_added: Vec::new(),
137        providers_removed: Vec::new(),
138        tenants_added: Vec::new(),
139        tenants_removed: Vec::new(),
140    }
141}
142
143/// Build a reload plan from a bundle diff.
144pub fn plan_reload(bundle: &std::path::Path, diff: &BundleDiff) -> ReloadPlan {
145    let mut actions = Vec::new();
146
147    for pack in &diff.packs_added {
148        actions.push(ReloadAction::LoadComponent {
149            provider_id: pack.provider_id.clone(),
150            path: pack.pack_path.clone(),
151        });
152        actions.push(ReloadAction::SeedSecrets {
153            provider_id: pack.provider_id.clone(),
154        });
155    }
156
157    for pack in &diff.packs_removed {
158        actions.push(ReloadAction::UnloadComponent {
159            provider_id: pack.provider_id.clone(),
160        });
161    }
162
163    for pack in &diff.packs_changed {
164        actions.push(ReloadAction::ReloadComponent {
165            provider_id: pack.provider_id.clone(),
166            path: pack.pack_path.clone(),
167        });
168    }
169
170    if !diff.packs_added.is_empty()
171        || !diff.packs_removed.is_empty()
172        || !diff.packs_changed.is_empty()
173    {
174        actions.push(ReloadAction::UpdateRoutes);
175    }
176
177    if !diff.tenants_added.is_empty() || !diff.tenants_removed.is_empty() {
178        actions.push(ReloadAction::RunResolver);
179    }
180
181    ReloadPlan {
182        bundle: bundle.to_path_buf(),
183        diff: diff.clone(),
184        actions,
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::discovery::{DetectedDomains, ProviderIdSource};
192
193    fn make_provider(id: &str, path: &str) -> DetectedProvider {
194        DetectedProvider {
195            provider_id: id.to_string(),
196            display_name: None,
197            domain: "messaging".to_string(),
198            pack_path: PathBuf::from(path),
199            id_source: ProviderIdSource::Manifest,
200        }
201    }
202
203    fn make_discovery(providers: Vec<DetectedProvider>) -> DiscoveryResult {
204        DiscoveryResult {
205            domains: DetectedDomains {
206                messaging: true,
207                events: false,
208                oauth: false,
209                state: false,
210                secrets: false,
211            },
212            providers,
213        }
214    }
215
216    #[test]
217    fn empty_diff_when_same() {
218        let disc = make_discovery(vec![make_provider("telegram", "/a/telegram.gtpack")]);
219        let diff = diff_discoveries(&disc, &disc);
220        assert!(diff.is_empty());
221        assert_eq!(diff.change_count(), 0);
222    }
223
224    #[test]
225    fn detects_added_packs() {
226        let prev = make_discovery(vec![make_provider("telegram", "/a/telegram.gtpack")]);
227        let curr = make_discovery(vec![
228            make_provider("telegram", "/a/telegram.gtpack"),
229            make_provider("slack", "/a/slack.gtpack"),
230        ]);
231        let diff = diff_discoveries(&prev, &curr);
232        assert_eq!(diff.packs_added.len(), 1);
233        assert_eq!(diff.packs_added[0].provider_id, "slack");
234        assert!(diff.packs_removed.is_empty());
235    }
236
237    #[test]
238    fn detects_removed_packs() {
239        let prev = make_discovery(vec![
240            make_provider("telegram", "/a/telegram.gtpack"),
241            make_provider("slack", "/a/slack.gtpack"),
242        ]);
243        let curr = make_discovery(vec![make_provider("telegram", "/a/telegram.gtpack")]);
244        let diff = diff_discoveries(&prev, &curr);
245        assert!(diff.packs_added.is_empty());
246        assert_eq!(diff.packs_removed.len(), 1);
247        assert_eq!(diff.packs_removed[0].provider_id, "slack");
248    }
249
250    #[test]
251    fn detects_changed_packs() {
252        let prev = make_discovery(vec![make_provider("telegram", "/a/v1/telegram.gtpack")]);
253        let curr = make_discovery(vec![make_provider("telegram", "/a/v2/telegram.gtpack")]);
254        let diff = diff_discoveries(&prev, &curr);
255        assert!(diff.packs_added.is_empty());
256        assert!(diff.packs_removed.is_empty());
257        assert_eq!(diff.packs_changed.len(), 1);
258    }
259
260    #[test]
261    fn plan_reload_generates_actions() {
262        let diff = BundleDiff {
263            packs_added: vec![make_provider("slack", "/a/slack.gtpack")],
264            packs_removed: vec![make_provider("teams", "/a/teams.gtpack")],
265            packs_changed: vec![make_provider("telegram", "/a/telegram.gtpack")],
266            ..Default::default()
267        };
268        let plan = plan_reload(std::path::Path::new("/bundle"), &diff);
269        // Added: LoadComponent + SeedSecrets
270        // Removed: UnloadComponent
271        // Changed: ReloadComponent
272        // + UpdateRoutes
273        assert_eq!(plan.actions.len(), 5);
274    }
275
276    #[test]
277    fn empty_diff_no_actions() {
278        let diff = BundleDiff::default();
279        let plan = plan_reload(std::path::Path::new("/bundle"), &diff);
280        assert!(plan.actions.is_empty());
281    }
282}