Skip to main content

pipedash_core/application/services/
provider_service.rs

1use std::collections::{
2    HashMap,
3    HashSet,
4};
5use std::future::Future;
6use std::sync::Arc;
7use std::time::Duration;
8
9use chrono::{
10    DateTime,
11    Utc,
12};
13use dashmap::{
14    DashMap,
15    DashSet,
16};
17use pipedash_plugin_api::{
18    Plugin as PluginTrait,
19    PluginRegistry,
20    RateLimit,
21    CONTROLLER_HEALTH_FEATURE,
22};
23use tokio::sync::{
24    Mutex,
25    RwLock,
26};
27use tokio::time::{
28    timeout,
29    Instant,
30};
31
32use super::health_service::{
33    HealthHandle,
34    HealthInput,
35    ProviderHealthInput,
36};
37use crate::application::fetch_scheduler::{
38    FetchScheduler,
39    ForcedClaim,
40    Generation,
41};
42use crate::application::refresh_manager::RefreshDispatcher;
43use crate::domain::{
44    DomainError,
45    DomainResult,
46    FetchStatus,
47    HealthLevel,
48    Provider,
49    ProviderConfig,
50    ProviderHealth,
51    ProviderImportResult,
52    ProviderSummary,
53};
54use crate::event::{
55    CoreEvent,
56    EventBus,
57};
58use crate::infrastructure::config::sync::ProviderSyncService;
59use crate::infrastructure::config::token_ref::TokenReference;
60use crate::infrastructure::config::ConfigLoader;
61use crate::infrastructure::database::{
62    Repository,
63    FETCH_STATUS_ERROR,
64    FETCH_STATUS_NEVER,
65    FETCH_STATUS_SUCCESS,
66};
67use crate::infrastructure::providers::PluginAdapter;
68use crate::infrastructure::HttpClientManager;
69use crate::plugins;
70
71const QUOTA_PAUSE_PERCENT: u64 = 10;
72const ACQUIRE_ATTEMPTS: usize = 3;
73
74pub(crate) type ProviderFactory =
75    Arc<dyn Fn(&ProviderConfig) -> DomainResult<Arc<dyn Provider>> + Send + Sync>;
76
77#[derive(Clone)]
78pub struct ProviderLease {
79    pub id: i64,
80    pub generation: Generation,
81    pub provider: Arc<dyn Provider>,
82}
83
84struct ProviderSlot {
85    generation: Generation,
86    provider: Arc<dyn Provider>,
87}
88
89impl ProviderSlot {
90    fn lease(&self, id: i64) -> ProviderLease {
91        ProviderLease {
92            id,
93            generation: self.generation,
94            provider: Arc::clone(&self.provider),
95        }
96    }
97}
98
99struct ParameterFetch<'a> {
100    fetches: &'a DashMap<String, Arc<Mutex<()>>>,
101    key: &'a str,
102    lock: Arc<Mutex<()>>,
103}
104
105impl<'a> ParameterFetch<'a> {
106    fn new(fetches: &'a DashMap<String, Arc<Mutex<()>>>, key: &'a str) -> Self {
107        let lock = Arc::clone(fetches.entry(key.to_string()).or_default().value());
108
109        Self { fetches, key, lock }
110    }
111}
112
113impl Drop for ParameterFetch<'_> {
114    fn drop(&mut self) {
115        self.fetches
116            .remove_if(self.key, |_, lock| Arc::ptr_eq(lock, &self.lock));
117    }
118}
119
120pub struct ProviderService {
121    repository: Arc<Repository>,
122    http_client_manager: Arc<HttpClientManager>,
123    providers: RwLock<HashMap<i64, ProviderSlot>>,
124    commit_locks: DashMap<i64, Arc<Mutex<()>>>,
125    plugin_registry: Arc<PluginRegistry>,
126    parameter_fetches: DashMap<String, Arc<Mutex<()>>>,
127    event_bus: Arc<dyn EventBus>,
128    scheduler: Arc<FetchScheduler>,
129    health: HealthHandle,
130    dispatcher: RefreshDispatcher,
131    provider_factory: ProviderFactory,
132    published_in_flight: DashSet<i64>,
133}
134
135impl ProviderService {
136    pub fn new(
137        repository: Arc<Repository>, http_client_manager: Arc<HttpClientManager>,
138        event_bus: Arc<dyn EventBus>, scheduler: Arc<FetchScheduler>, health: HealthHandle,
139        dispatcher: RefreshDispatcher,
140    ) -> Self {
141        let plugin_registry = Arc::new(plugins::create_plugin_registry());
142        let provider_factory: ProviderFactory = {
143            let registry = Arc::clone(&plugin_registry);
144            let http = Arc::clone(&http_client_manager);
145            Arc::new(move |config| plugin_provider(&registry, &http, config))
146        };
147
148        Self {
149            repository,
150            http_client_manager,
151            providers: RwLock::new(HashMap::new()),
152            commit_locks: DashMap::new(),
153            plugin_registry,
154            parameter_fetches: DashMap::new(),
155            event_bus,
156            scheduler,
157            health,
158            dispatcher,
159            provider_factory,
160            published_in_flight: DashSet::new(),
161        }
162    }
163
164    #[cfg(test)]
165    pub(crate) fn with_provider_factory(mut self, factory: ProviderFactory) -> Self {
166        self.provider_factory = factory;
167        self
168    }
169
170    pub fn repository(&self) -> &Arc<Repository> {
171        &self.repository
172    }
173
174    pub(crate) fn scheduler(&self) -> &Arc<FetchScheduler> {
175        &self.scheduler
176    }
177
178    pub(crate) fn health(&self) -> &HealthHandle {
179        &self.health
180    }
181
182    pub(crate) fn dispatcher(&self) -> &RefreshDispatcher {
183        &self.dispatcher
184    }
185
186    pub fn list_available_plugins(&self) -> Vec<pipedash_plugin_api::PluginMetadata> {
187        let mut metadata_list = Vec::new();
188        for provider_type in self.plugin_registry.provider_types() {
189            if let Some(plugin) = self.plugin_registry.get(&provider_type) {
190                metadata_list.push(plugin.metadata().clone());
191            }
192        }
193        metadata_list
194    }
195
196    pub fn retains_run_history(&self, provider_type: &str) -> bool {
197        self.plugin_registry
198            .get(provider_type)
199            .is_some_and(|plugin| plugin.metadata().capabilities.retains_run_history)
200    }
201
202    pub fn init_plugin(
203        &self, provider_type: &str, config: HashMap<String, String>, provider_id: i64,
204    ) -> DomainResult<Box<dyn PluginTrait>> {
205        init_plugin(
206            &self.http_client_manager,
207            provider_type,
208            config,
209            provider_id,
210        )
211    }
212
213    pub async fn field_options(
214        &self, provider_type: &str, field_key: &str, config: HashMap<String, String>,
215    ) -> DomainResult<Vec<String>> {
216        let (mut plugin, client) = build_plugin(&self.http_client_manager, provider_type, &config)?;
217        let init_error = plugin.initialize(0, config.clone(), Some(client)).err();
218
219        match plugin.get_field_options(field_key, &config).await {
220            Ok(options) => Ok(options),
221            Err(e) => Err(match init_error {
222                Some(init) => {
223                    DomainError::InvalidConfig(format!("Failed to initialize plugin: {init}"))
224                }
225                None => PluginAdapter::map_error(e),
226            }),
227        }
228    }
229
230    pub(crate) fn commit_lock(&self, id: i64) -> Arc<Mutex<()>> {
231        Arc::clone(self.commit_locks.entry(id).or_default().value())
232    }
233
234    async fn current(&self, id: i64) -> Option<ProviderLease> {
235        self.providers
236            .read()
237            .await
238            .get(&id)
239            .map(|slot| slot.lease(id))
240    }
241
242    pub(crate) async fn generation_of(&self, id: i64) -> Option<Generation> {
243        self.providers
244            .read()
245            .await
246            .get(&id)
247            .map(|slot| slot.generation)
248    }
249
250    pub(crate) async fn health_inputs(&self) -> Vec<ProviderHealthInput> {
251        let leases: Vec<ProviderLease> = self
252            .providers
253            .read()
254            .await
255            .iter()
256            .map(|(id, slot)| slot.lease(*id))
257            .collect();
258        let mut inputs = Vec::with_capacity(leases.len());
259        for lease in leases {
260            match self.repository.get_provider(lease.id).await {
261                Ok(config) => inputs.push(self.health_input(&config, &lease)),
262                Err(e) => {
263                    tracing::debug!(provider_id = lease.id, error = %e, "Provider has no config row");
264                }
265            }
266        }
267
268        inputs
269    }
270
271    async fn install(&self, id: i64, provider: Arc<dyn Provider>, interval: i64) -> ProviderLease {
272        let mut providers = self.providers.write().await;
273        providers
274            .entry(id)
275            .or_insert_with(|| {
276                let generation = Generation::next();
277                self.scheduler
278                    .register(id, generation, interval_of(interval));
279                ProviderSlot {
280                    generation,
281                    provider,
282                }
283            })
284            .lease(id)
285    }
286
287    async fn replace(&self, id: i64, provider: Arc<dyn Provider>, interval: i64) -> ProviderLease {
288        let generation = Generation::next();
289        let mut providers = self.providers.write().await;
290        providers.insert(
291            id,
292            ProviderSlot {
293                generation,
294                provider: Arc::clone(&provider),
295            },
296        );
297        self.scheduler.reset(id, generation, interval_of(interval));
298
299        ProviderLease {
300            id,
301            generation,
302            provider,
303        }
304    }
305
306    pub async fn acquire(&self, id: i64) -> DomainResult<ProviderLease> {
307        if let Some(lease) = self.current(id).await {
308            return Ok(lease);
309        }
310
311        for _ in 0..ACQUIRE_ATTEMPTS {
312            let config = self.repository.get_provider(id).await?;
313            let provider = (self.provider_factory)(&config)?;
314            let lock = self.commit_lock(id);
315            let _guard = lock.lock().await;
316            if let Some(lease) = self.current(id).await {
317                return Ok(lease);
318            }
319            let row = self.repository.get_provider(id).await?;
320            if row.version == config.version {
321                let lease = self.install(id, provider, row.refresh_interval).await;
322                self.health.send(HealthInput::ProvidersChanged {
323                    changed: vec![self.health_input(&row, &lease)],
324                });
325                return Ok(lease);
326            }
327        }
328
329        Err(DomainError::ConcurrentModification(format!(
330            "Provider {id} kept changing while it was loaded"
331        )))
332    }
333
334    pub async fn commit<F, Fut, T>(&self, lease: &ProviderLease, f: F) -> Option<T>
335    where
336        F: FnOnce() -> Fut,
337        Fut: Future<Output = T>,
338    {
339        self.commit_at(lease.id, lease.generation, f).await
340    }
341
342    async fn commit_at<F, Fut, T>(&self, id: i64, generation: Generation, f: F) -> Option<T>
343    where
344        F: FnOnce() -> Fut,
345        Fut: Future<Output = T>,
346    {
347        let lock = self.commit_lock(id);
348        let _guard = lock.lock().await;
349        let current = self
350            .providers
351            .read()
352            .await
353            .get(&id)
354            .map(|slot| slot.generation);
355        if current != Some(generation) {
356            return None;
357        }
358
359        Some(f().await)
360    }
361
362    pub async fn publish_summary(&self, id: i64, generation: Generation) {
363        self.commit_at(id, generation, || self.emit_status(id))
364            .await;
365    }
366
367    pub(crate) async fn emit_status(&self, id: i64) {
368        match self.summary_of(id).await {
369            Ok(provider) => {
370                if provider.health.in_flight {
371                    self.published_in_flight.insert(id);
372                } else {
373                    self.published_in_flight.remove(&id);
374                }
375                self.event_bus
376                    .emit(CoreEvent::ProviderStatusUpdated { provider })
377                    .await;
378            }
379            Err(e) => {
380                tracing::warn!(provider_id = id, error = %e, "Failed to build provider summary");
381            }
382        }
383    }
384
385    pub(crate) fn published_in_flight(&self, id: i64) -> bool {
386        self.published_in_flight.contains(&id)
387    }
388
389    async fn summary_of(&self, id: i64) -> DomainResult<ProviderSummary> {
390        let config = self.repository.get_provider(id).await?;
391
392        self.build_summary(id, &config).await
393    }
394
395    async fn build_summary(
396        &self, id: i64, config: &ProviderConfig,
397    ) -> DomainResult<ProviderSummary> {
398        let cached_pipelines = self.repository.get_cached_pipelines(Some(id)).await?;
399        let (status, last_fetch_error, last_fetch_at) = match self
400            .repository
401            .get_provider_fetch_status(id)
402            .await
403        {
404            Ok(status) => status,
405            Err(e) => {
406                tracing::warn!(provider_id = id, error = %e, "Failed to read provider fetch status");
407                (FETCH_STATUS_NEVER.to_string(), None, None)
408            }
409        };
410        let last_fetch_status = match status.as_str() {
411            FETCH_STATUS_SUCCESS => FetchStatus::Success,
412            FETCH_STATUS_ERROR => FetchStatus::Error,
413            _ => FetchStatus::Never,
414        };
415        let now = Instant::now();
416        let wall = Utc::now();
417        let view = self.scheduler.view(id, now);
418        let generation = self
419            .providers
420            .read()
421            .await
422            .get(&id)
423            .map(|slot| slot.generation);
424        let snapshot = self.health.snapshot();
425        let state = snapshot
426            .providers
427            .get(&id)
428            .filter(|state| Some(state.generation) == generation);
429
430        Ok(ProviderSummary {
431            id,
432            name: config.display_name().to_string(),
433            provider_type: config.provider_type.clone(),
434            icon: self
435                .plugin_registry
436                .get(&config.provider_type)
437                .and_then(|plugin| plugin.metadata().icon.clone()),
438            pipeline_count: cached_pipelines.len(),
439            last_updated: cached_pipelines.iter().map(|p| p.last_updated).max(),
440            refresh_interval: config.refresh_interval,
441            configured_repositories: configured_repositories(config),
442            last_fetch_status,
443            last_fetch_error,
444            last_fetch_at: last_fetch_at.and_then(|at| {
445                DateTime::parse_from_rfc3339(&at)
446                    .ok()
447                    .map(|at| at.with_timezone(&Utc))
448            }),
449            paused_until: self.scheduler.paused_until(id, wall),
450            last_fetch_error_kind: view.as_ref().and_then(|view| view.last_failure),
451            health: ProviderHealth {
452                level: state.map_or(HealthLevel::Operational, |state| state.level),
453                signals: state.map(|state| state.signals.clone()).unwrap_or_default(),
454                next_attempt_at: view
455                    .as_ref()
456                    .filter(|view| view.failures > 0)
457                    .and_then(|_| {
458                        let floor = state
459                            .filter(|state| state.outage_floor)
460                            .map(|_| self.scheduler.policy().outage_floor);
461                        self.scheduler.next_due_in(id, now, floor)
462                    })
463                    .and_then(|wait| chrono::Duration::from_std(wait).ok())
464                    .map(|wait| wall + wait),
465                in_flight: view.as_ref().is_some_and(|view| view.in_flight),
466                status_page: state.and_then(|state| state.status_page.clone()),
467            },
468            version: config.version.unwrap_or(1),
469        })
470    }
471
472    fn health_input(&self, config: &ProviderConfig, lease: &ProviderLease) -> ProviderHealthInput {
473        ProviderHealthInput {
474            id: lease.id,
475            generation: Some(lease.generation),
476            display_name: config.display_name().to_string(),
477            source: lease.provider.status_source().cloned(),
478            has_instance_checks: self.plugin_registry.get(&config.provider_type).is_some_and(
479                |plugin| {
480                    plugin
481                        .metadata()
482                        .features
483                        .iter()
484                        .any(|feature| feature.id == CONTROLLER_HEALTH_FEATURE)
485                },
486            ),
487        }
488    }
489
490    fn queue_fetch(&self, lease: &ProviderLease) {
491        if let ForcedClaim::Claimed(claim) =
492            self.scheduler
493                .claim_forced(lease.id, lease.generation, Instant::now())
494        {
495            self.dispatcher.fetch_now(vec![claim]);
496        }
497    }
498
499    pub async fn add_provider(&self, config: ProviderConfig) -> DomainResult<i64> {
500        let mut plugin_config = config.config.clone();
501        plugin_config.insert("token".to_string(), config.token.clone());
502
503        let plugin = self.init_plugin(&config.provider_type, plugin_config, 0)?;
504
505        let (validation_result, permission_status) =
506            tokio::join!(plugin.validate_credentials(), plugin.check_permissions());
507
508        validation_result.map_err(|e| {
509            DomainError::InvalidConfig(format!("Credential validation failed: {e}"))
510        })?;
511
512        let provider_type = plugin.provider_type();
513        if provider_type != config.provider_type {
514            return Err(DomainError::InvalidConfig(format!(
515                "Provider type mismatch: expected '{}', got '{}'",
516                config.provider_type, provider_type
517            )));
518        }
519
520        let permission_status = permission_status.ok();
521
522        let id = self.repository.add_provider(&config).await?;
523
524        if let Some(status) = &permission_status {
525            if let Err(e) = self.repository.store_provider_permissions(id, status).await {
526                tracing::warn!(provider_id = id, error = %e, "Failed to store permissions");
527            }
528        }
529
530        let mut config_with_id = config.clone();
531        config_with_id.id = Some(id);
532        let provider = (self.provider_factory)(&config_with_id)?;
533
534        let lock = self.commit_lock(id);
535        let _guard = lock.lock().await;
536        let lease = self.install(id, provider, config.refresh_interval).await;
537        self.health.send(HealthInput::ProvidersChanged {
538            changed: vec![self.health_input(&config_with_id, &lease)],
539        });
540        let provider = self.summary_of(id).await?;
541        self.event_bus
542            .emit(CoreEvent::ProviderAdded {
543                provider,
544                timestamp: Utc::now().timestamp_millis(),
545            })
546            .await;
547
548        Ok(id)
549    }
550
551    pub async fn export_providers_toml(&self) -> DomainResult<String> {
552        let providers = self.repository.config_backend().list_providers().await?;
553
554        ProviderSyncService::export_toml(&providers)
555    }
556
557    pub async fn import_providers_toml(&self, content: &str) -> DomainResult<ProviderImportResult> {
558        let file = ConfigLoader::parse_raw(content).map_err(|e| {
559            DomainError::InvalidConfig(format!("Couldn't read the providers file: {e}"))
560        })?;
561        if file.providers.is_empty() {
562            return Err(DomainError::InvalidConfig(
563                "The file has no [providers] entries".to_string(),
564            ));
565        }
566        if let Some((key, provider)) = file
567            .providers
568            .iter()
569            .find(|(_, provider)| !self.plugin_registry.is_registered(&provider.provider_type))
570        {
571            return Err(DomainError::InvalidProviderType(format!(
572                "Provider '{key}' has an unknown type '{}'",
573                provider.provider_type
574            )));
575        }
576
577        let existing: HashSet<String> = self
578            .repository
579            .config_backend()
580            .list_providers()
581            .await?
582            .into_iter()
583            .map(|provider| provider.name)
584            .collect();
585        let mut result = ProviderImportResult::default();
586        let mut failure = None;
587
588        for (key, provider) in &file.providers {
589            let name = provider.display_name(key).to_string();
590            if existing.contains(key) {
591                result.skipped.push(name);
592                continue;
593            }
594
595            let config = ProviderSyncService::provider_config(key, provider, String::new());
596            match self.repository.add_provider(&config).await {
597                Ok(_) => result.added.push(name),
598                Err(e) => {
599                    let added_elsewhere = matches!(e, DomainError::InvalidConfig(_))
600                        && self
601                            .repository
602                            .config_backend()
603                            .list_providers()
604                            .await
605                            .is_ok_and(|providers| providers.iter().any(|p| p.name == *key));
606                    if !added_elsewhere {
607                        failure = Some(e);
608                        break;
609                    }
610                    result.skipped.push(name);
611                }
612            }
613        }
614
615        if !result.added.is_empty() {
616            self.event_bus.emit(CoreEvent::ProvidersChanged).await;
617        }
618
619        failure.map_or(Ok(result), Err)
620    }
621
622    pub async fn get_provider_config(&self, id: i64) -> DomainResult<ProviderConfig> {
623        self.repository.get_provider(id).await
624    }
625
626    pub async fn list_providers(&self) -> DomainResult<Vec<ProviderSummary>> {
627        let configs = self.repository.list_providers().await?;
628        let mut summaries = Vec::with_capacity(configs.len());
629
630        for config in configs {
631            if let Some(id) = config.id {
632                summaries.push(self.build_summary(id, &config).await?);
633            }
634        }
635
636        Ok(summaries)
637    }
638
639    pub async fn update_provider(&self, id: i64, config: ProviderConfig) -> DomainResult<()> {
640        let mut plugin_config = config.config.clone();
641        plugin_config.insert("token".to_string(), config.token.clone());
642
643        let plugin = self.init_plugin(&config.provider_type, plugin_config, id)?;
644
645        plugin.validate_credentials().await.map_err(|e| {
646            DomainError::InvalidConfig(format!("Credential validation failed: {e}"))
647        })?;
648
649        let permission_status = plugin.check_permissions().await.ok();
650
651        let mut config_with_id = config.clone();
652        config_with_id.id = Some(id);
653        let new_provider = (self.provider_factory)(&config_with_id)?;
654
655        let lock = self.commit_lock(id);
656        let guard = lock.lock().await;
657
658        let current = self.repository.get_provider(id).await?;
659        let success = self
660            .repository
661            .update_provider_with_version(id, &config, current.version.unwrap_or(1))
662            .await?;
663
664        if !success {
665            return Err(DomainError::ConcurrentModification(format!(
666                "Provider {} was modified by another client",
667                id
668            )));
669        }
670
671        if let Err(e) = self.repository.reset_provider_fetch_status(id).await {
672            tracing::warn!(provider_id = id, error = %e, "Failed to reset fetch status");
673        }
674        let lease = self
675            .replace(id, new_provider, config.refresh_interval)
676            .await;
677        self.health.send(HealthInput::ProvidersChanged {
678            changed: vec![self.health_input(&config_with_id, &lease)],
679        });
680        match self.summary_of(id).await {
681            Ok(provider) => {
682                self.event_bus
683                    .emit(CoreEvent::ProviderUpdated {
684                        provider,
685                        timestamp: Utc::now().timestamp_millis(),
686                    })
687                    .await;
688            }
689            Err(e) => {
690                tracing::warn!(provider_id = id, error = %e, "Failed to build updated provider summary");
691                self.event_bus.emit(CoreEvent::ProvidersChanged).await;
692            }
693        }
694        drop(guard);
695
696        if let Some(status) = &permission_status {
697            if let Err(e) = self.repository.store_provider_permissions(id, status).await {
698                tracing::warn!(provider_id = id, error = %e, "Failed to store permissions");
699            }
700        }
701
702        self.queue_fetch(&lease);
703
704        Ok(())
705    }
706
707    pub async fn update_provider_refresh_interval(
708        &self, id: i64, refresh_interval: i64,
709    ) -> DomainResult<()> {
710        let lock = self.commit_lock(id);
711        let _guard = lock.lock().await;
712
713        let mut config = self.repository.get_provider(id).await?;
714        config.refresh_interval = refresh_interval;
715        self.repository.update_provider(id, &config).await?;
716
717        let generation = self
718            .providers
719            .read()
720            .await
721            .get(&id)
722            .map(|slot| slot.generation);
723        if let Some(generation) = generation {
724            self.scheduler
725                .set_interval(id, generation, interval_of(refresh_interval));
726        }
727
728        Ok(())
729    }
730
731    pub async fn remove_provider(&self, id: i64) -> DomainResult<()> {
732        let lock = self.commit_lock(id);
733        let _guard = lock.lock().await;
734
735        let config = self.repository.get_provider(id).await?;
736        let provider = self.build_summary(id, &config).await?;
737
738        self.repository.remove_provider(id).await?;
739        self.providers.write().await.remove(&id);
740        self.scheduler.remove(id);
741        self.published_in_flight.remove(&id);
742        self.health.send(HealthInput::ProvidersChanged {
743            changed: vec![ProviderHealthInput {
744                id,
745                generation: None,
746                display_name: config.display_name().to_string(),
747                source: None,
748                has_instance_checks: false,
749            }],
750        });
751        self.event_bus
752            .emit(CoreEvent::ProviderRemoved {
753                provider,
754                timestamp: Utc::now().timestamp_millis(),
755            })
756            .await;
757
758        Ok(())
759    }
760
761    pub async fn get_provider(&self, id: i64) -> DomainResult<Arc<dyn Provider>> {
762        Ok(self.acquire(id).await?.provider)
763    }
764
765    pub async fn check_quota(&self, lease: &ProviderLease) -> Option<DateTime<Utc>> {
766        let rate_limit =
767            match timeout(Duration::from_secs(10), lease.provider.fetch_rate_limit()).await {
768                Ok(Ok(rate_limit)) => rate_limit,
769                Ok(Err(e)) => {
770                    tracing::debug!(provider_id = lease.id, error = %e, "Rate limit check failed");
771                    None
772                }
773                Err(_) => {
774                    tracing::debug!(provider_id = lease.id, "Rate limit check timed out");
775                    None
776                }
777            };
778        let paused_until = rate_limit.and_then(|rate_limit| quota_pause(&rate_limit, Utc::now()));
779
780        self.commit(lease, || async {
781            let changed = self.scheduler.set_quota_pause(
782                lease.id,
783                lease.generation,
784                paused_until,
785                Instant::now(),
786                Utc::now(),
787            );
788            if let (true, Some(until)) = (changed, paused_until) {
789                tracing::info!(provider_id = lease.id, %until, "API quota almost used up, pausing provider");
790            }
791            if changed {
792                self.emit_status(lease.id).await;
793            }
794
795            paused_until
796        })
797        .await
798        .flatten()
799    }
800
801    pub async fn get_provider_instance(&self, id: i64) -> DomainResult<Arc<dyn Provider>> {
802        self.get_provider(id).await
803    }
804
805    pub async fn load_missing_providers(&self) {
806        let ids = match self.repository.list_provider_ids().await {
807            Ok(ids) => ids,
808            Err(e) => {
809                tracing::warn!(error = %e, "Failed to list providers");
810                return;
811            }
812        };
813        let loaded: Vec<i64> = self.providers.read().await.keys().copied().collect();
814        for id in ids.into_iter().filter(|id| !loaded.contains(id)) {
815            if let Err(e) = self.acquire(id).await {
816                tracing::debug!(provider_id = id, error = %e, "Provider not loaded yet");
817            }
818        }
819    }
820
821    pub async fn load_all_providers(&self) -> DomainResult<()> {
822        let configs = self.repository.list_providers().await?;
823        let mut changed = Vec::new();
824
825        for config in configs {
826            let Some(id) = config.id else {
827                continue;
828            };
829            if config.token.is_empty() {
830                tracing::debug!(
831                    provider_id = id,
832                    provider_type = %config.provider_type,
833                    "Skipping provider load - token not available yet (vault initializing in background)"
834                );
835                continue;
836            }
837
838            match (self.provider_factory)(&config) {
839                Ok(provider) => {
840                    let lock = self.commit_lock(id);
841                    let _guard = lock.lock().await;
842                    let lease = self.replace(id, provider, config.refresh_interval).await;
843                    changed.push(self.health_input(&config, &lease));
844                }
845                Err(e) => {
846                    tracing::warn!(provider_id = id, error = %e, "Failed to load provider");
847                }
848            }
849        }
850
851        if !changed.is_empty() {
852            self.health.send(HealthInput::ProvidersChanged { changed });
853        }
854
855        Ok(())
856    }
857
858    pub async fn get_workflow_parameters(
859        &self, provider_id: i64, workflow_id: &str,
860    ) -> DomainResult<Vec<pipedash_plugin_api::WorkflowParameter>> {
861        if let Some(cached) = self.cached_parameters(workflow_id).await {
862            return Ok(cached);
863        }
864
865        let lease = self.acquire(provider_id).await?;
866        let fetch = ParameterFetch::new(&self.parameter_fetches, workflow_id);
867        let _guard = fetch.lock.lock().await;
868
869        if let Some(cached) = self.cached_parameters(workflow_id).await {
870            return Ok(cached);
871        }
872
873        let parameters = lease.provider.get_workflow_parameters(workflow_id).await?;
874
875        let cached = self
876            .commit(&lease, || {
877                self.repository
878                    .cache_workflow_parameters(workflow_id, &parameters)
879            })
880            .await;
881        if let Some(Err(e)) = cached {
882            tracing::warn!(provider_id, workflow_id, error = %e, "Failed to cache workflow parameters");
883        }
884
885        Ok(parameters)
886    }
887
888    async fn cached_parameters(
889        &self, workflow_id: &str,
890    ) -> Option<Vec<pipedash_plugin_api::WorkflowParameter>> {
891        match self
892            .repository
893            .get_cached_workflow_parameters(workflow_id)
894            .await
895        {
896            Ok(cached) => cached,
897            Err(e) => {
898                tracing::warn!(workflow_id, error = %e, "Failed to read cached workflow parameters");
899                None
900            }
901        }
902    }
903
904    pub async fn get_provider_permissions(
905        &self, provider_id: i64,
906    ) -> DomainResult<Option<pipedash_plugin_api::PermissionStatus>> {
907        self.repository.get_provider_permissions(provider_id).await
908    }
909
910    pub async fn recheck_provider_permissions(
911        &self, provider_id: i64,
912    ) -> DomainResult<pipedash_plugin_api::PermissionStatus> {
913        let config = self.repository.get_provider(provider_id).await?;
914        let mut plugin_config = config.config.clone();
915        plugin_config.insert("token".to_string(), config.token.clone());
916
917        let plugin = self.init_plugin(&config.provider_type, plugin_config, provider_id)?;
918
919        let status = plugin
920            .check_permissions()
921            .await
922            .map_err(|e| DomainError::InvalidConfig(format!("Failed to check permissions: {e}")))?;
923
924        self.repository
925            .store_provider_permissions(provider_id, &status)
926            .await?;
927
928        Ok(status)
929    }
930}
931
932fn interval_of(seconds: i64) -> Duration {
933    Duration::from_secs(u64::try_from(seconds).unwrap_or(0))
934}
935
936fn build_plugin(
937    http: &HttpClientManager, provider_type: &str, config: &HashMap<String, String>,
938) -> DomainResult<(Box<dyn PluginTrait>, Arc<reqwest::Client>)> {
939    let plugin = plugins::create_plugin(provider_type).ok_or_else(|| {
940        DomainError::InvalidProviderType(format!("Unknown provider type: {}", provider_type))
941    })?;
942    let client = http.client_for(&plugin.client_options(config))?;
943
944    Ok((plugin, client))
945}
946
947fn init_plugin(
948    http: &HttpClientManager, provider_type: &str, config: HashMap<String, String>,
949    provider_id: i64,
950) -> DomainResult<Box<dyn PluginTrait>> {
951    let (mut plugin, client) = build_plugin(http, provider_type, &config)?;
952    plugin
953        .initialize(provider_id, config, Some(client))
954        .map_err(|e| DomainError::InvalidConfig(format!("Failed to initialize plugin: {e}")))?;
955
956    Ok(plugin)
957}
958
959fn plugin_provider(
960    registry: &PluginRegistry, http: &HttpClientManager, config: &ProviderConfig,
961) -> DomainResult<Arc<dyn Provider>> {
962    if !registry.is_registered(&config.provider_type) {
963        return Err(DomainError::InvalidProviderType(format!(
964            "Plugin not found for provider type: {}",
965            config.provider_type
966        )));
967    }
968
969    let provider_id = config.id.unwrap_or(0);
970    let mut plugin_config = config.config.clone();
971
972    let resolved_token = if config.token.is_empty() {
973        String::new()
974    } else {
975        match TokenReference::parse(&config.token) {
976            Ok(TokenReference::EnvVar(var_name)) => std::env::var(&var_name).unwrap_or_else(|_| {
977                tracing::warn!(
978                    provider_id = provider_id,
979                    var_name = %var_name,
980                    "Environment variable not found, using empty token"
981                );
982                String::new()
983            }),
984            Ok(TokenReference::None) => String::new(),
985            Ok(_) => config.token.clone(),
986            Err(_) => config.token.clone(),
987        }
988    };
989    plugin_config.insert("token".to_string(), resolved_token);
990
991    let plugin = init_plugin(http, &config.provider_type, plugin_config, provider_id)?;
992
993    Ok(Arc::new(PluginAdapter::new(
994        plugin,
995        config.provider_type.clone(),
996        provider_id,
997    )))
998}
999
1000fn configured_repositories(config: &ProviderConfig) -> Vec<String> {
1001    config
1002        .config
1003        .get("selected_items")
1004        .map(|items| {
1005            if matches!(config.provider_type.as_str(), "argocd" | "fluxcd") {
1006                return Vec::new();
1007            }
1008
1009            items
1010                .split(',')
1011                .map(|s| s.trim().to_string())
1012                .filter(|s| !s.is_empty())
1013                .map(|item| {
1014                    if config.provider_type == "tekton" && item.contains("__") {
1015                        item.replace("__", "/")
1016                    } else if config.provider_type == "jenkins" && !item.contains('/') {
1017                        format!("(root)/{}", item)
1018                    } else {
1019                        item
1020                    }
1021                })
1022                .collect()
1023        })
1024        .unwrap_or_default()
1025}
1026
1027fn quota_pause(rate_limit: &RateLimit, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
1028    let low = rate_limit.remaining * 100 < rate_limit.limit * QUOTA_PAUSE_PERCENT;
1029
1030    (low && rate_limit.reset_at > now).then_some(rate_limit.reset_at)
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use std::io::{
1036        Read,
1037        Write,
1038    };
1039
1040    use pipedash_plugin_api::PluginError;
1041
1042    use super::*;
1043    use crate::application::test_support::{
1044        pipeline,
1045        Gate,
1046        Harness,
1047        StubProvider,
1048    };
1049    use crate::application::FetchResult;
1050    use crate::domain::PipelineStatus;
1051    use crate::infrastructure::config::ConfigLoader;
1052    use crate::test_env::EnvGuard;
1053
1054    const GITHUB_SECRET: &str = "ghp_exampleSECRET0123456789";
1055    const GITLAB_SECRET: &str = "glpat-SECRET-VALUE";
1056    const JENKINS_DEFAULT_SECRET: &str = "hunter2-default";
1057
1058    fn provider(
1059        name: &str, provider_type: &str, token: &str, refresh_interval: i64,
1060        config: &[(&str, &str)],
1061    ) -> ProviderConfig {
1062        ProviderConfig {
1063            id: None,
1064            name: name.to_string(),
1065            provider_type: provider_type.to_string(),
1066            token: token.to_string(),
1067            config: config
1068                .iter()
1069                .map(|(k, v)| (k.to_string(), v.to_string()))
1070                .collect(),
1071            refresh_interval,
1072            version: None,
1073        }
1074    }
1075
1076    #[tokio::test]
1077    async fn test_export_providers_toml_round_trips_without_tokens() {
1078        let source = Harness::with_real_plugins().await;
1079        let repository = source.repository.clone();
1080        let service = &source.provider_service;
1081
1082        let originals = [
1083            provider(
1084                "github-main",
1085                "github",
1086                GITHUB_SECRET,
1087                45,
1088                &[("selected_items", "acme/api,acme/web")],
1089            ),
1090            provider(
1091                "My Jenkins",
1092                "jenkins",
1093                &format!("${{JENKINS_API_TOKEN:-{JENKINS_DEFAULT_SECRET}}}"),
1094                120,
1095                &[
1096                    ("display_name", "Prod Jenkins"),
1097                    ("server_url", "http://127.0.0.1:9"),
1098                    ("username", "ci"),
1099                    ("selected_items", "(root)/deploy"),
1100                ],
1101            ),
1102            provider("gitlab_main", "gitlab", GITLAB_SECRET, 30, &[]),
1103        ];
1104        for original in &originals {
1105            repository.add_provider(original).await.unwrap();
1106        }
1107
1108        let exported = service.export_providers_toml().await.unwrap();
1109
1110        for secret in [GITHUB_SECRET, GITLAB_SECRET, JENKINS_DEFAULT_SECRET] {
1111            assert!(!exported.contains(secret), "export leaked {secret}");
1112        }
1113        assert!(exported.contains("token = \"${PIPEDASH_TOKEN_GITHUB_MAIN:-}\""));
1114        assert!(exported.contains("token = \"${JENKINS_API_TOKEN:-}\""));
1115        assert!(exported.contains("token = \"${PIPEDASH_TOKEN_GITLAB_MAIN:-}\""));
1116
1117        let _env = EnvGuard::set(&[
1118            ("PIPEDASH_TOKEN_GITHUB_MAIN", Some("env-github-token")),
1119            ("JENKINS_API_TOKEN", Some("env-jenkins-token")),
1120            ("PIPEDASH_TOKEN_GITLAB_MAIN", None),
1121        ]);
1122        let parsed = ConfigLoader::parse(&exported).unwrap();
1123
1124        let mut keys: Vec<_> = parsed.providers.keys().map(String::as_str).collect();
1125        keys.sort_unstable();
1126        assert_eq!(keys, ["My Jenkins", "github-main", "gitlab_main"]);
1127        assert_eq!(parsed.providers["github-main"].token, "env-github-token");
1128        assert_eq!(parsed.providers["My Jenkins"].token, "env-jenkins-token");
1129        assert_eq!(parsed.providers["gitlab_main"].token, "");
1130        assert_eq!(
1131            parsed.providers["My Jenkins"].name.as_deref(),
1132            Some("Prod Jenkins")
1133        );
1134
1135        let target_harness = Harness::with_real_plugins().await;
1136        let target = &target_harness.repository;
1137        crate::infrastructure::config::sync::ProviderSyncService::new()
1138            .sync_toml_to_db(
1139                parsed.providers,
1140                target.config_backend().as_ref(),
1141                &crate::infrastructure::MemoryTokenStore::new(),
1142                false,
1143            )
1144            .await
1145            .unwrap();
1146
1147        let imported = target.config_backend().list_providers().await.unwrap();
1148
1149        assert_eq!(imported.len(), originals.len());
1150        for original in &originals {
1151            let imported = imported.iter().find(|p| p.name == original.name).unwrap();
1152            assert_eq!(imported.name, original.name);
1153            assert_eq!(imported.provider_type, original.provider_type);
1154            assert_eq!(imported.refresh_interval, original.refresh_interval);
1155            assert_eq!(imported.config, original.config);
1156        }
1157    }
1158
1159    #[tokio::test]
1160    async fn test_import_providers_toml_adds_missing_providers_without_tokens() {
1161        let source = Harness::with_real_plugins().await;
1162        let jenkins = provider(
1163            "My Jenkins",
1164            "jenkins",
1165            "${JENKINS_API_TOKEN}",
1166            120,
1167            &[
1168                ("display_name", "Prod Jenkins"),
1169                ("server_url", "http://127.0.0.1:9"),
1170                ("username", "ci"),
1171                ("selected_items", "(root)/deploy"),
1172            ],
1173        );
1174        for original in [
1175            provider(
1176                "github-main",
1177                "github",
1178                GITHUB_SECRET,
1179                45,
1180                &[("selected_items", "acme/api")],
1181            ),
1182            jenkins.clone(),
1183        ] {
1184            source.repository.add_provider(&original).await.unwrap();
1185        }
1186        let exported = source
1187            .provider_service
1188            .export_providers_toml()
1189            .await
1190            .unwrap();
1191
1192        let target = Harness::with_real_plugins().await;
1193        let kept = provider(
1194            "github-main",
1195            "github",
1196            "target-token",
1197            300,
1198            &[("selected_items", "acme/web")],
1199        );
1200        let kept_id = target.repository.add_provider(&kept).await.unwrap();
1201
1202        let _env = EnvGuard::set(&[("JENKINS_API_TOKEN", Some("env-jenkins-token"))]);
1203        let result = target
1204            .provider_service
1205            .import_providers_toml(&exported)
1206            .await
1207            .unwrap();
1208
1209        assert_eq!(
1210            result,
1211            ProviderImportResult {
1212                added: vec!["Prod Jenkins".to_string()],
1213                skipped: vec!["github-main".to_string()],
1214            }
1215        );
1216        let unchanged = target.repository.get_provider(kept_id).await.unwrap();
1217        assert_eq!(unchanged.token, kept.token);
1218        assert_eq!(unchanged.refresh_interval, kept.refresh_interval);
1219        assert_eq!(unchanged.config, kept.config);
1220
1221        let imported = target.repository.list_providers().await.unwrap();
1222        let imported = imported.iter().find(|p| p.name == jenkins.name).unwrap();
1223        let row = target
1224            .repository
1225            .config_backend()
1226            .get_provider(imported.id.unwrap())
1227            .await
1228            .unwrap()
1229            .unwrap();
1230        assert_eq!(imported.token, "");
1231        assert_eq!(row.token, "");
1232        assert_eq!(imported.provider_type, jenkins.provider_type);
1233        assert_eq!(imported.refresh_interval, jenkins.refresh_interval);
1234        assert_eq!(imported.config, jenkins.config);
1235        assert_eq!(target.bus.count("providers-changed"), 1);
1236    }
1237
1238    #[tokio::test]
1239    async fn test_import_providers_toml_rejects_unknown_type_before_adding() {
1240        let h = Harness::with_real_plugins().await;
1241        let content = r#"
1242[providers.github-main]
1243type = "github"
1244
1245[providers.mystery]
1246type = "travis"
1247"#;
1248
1249        let err = h
1250            .provider_service
1251            .import_providers_toml(content)
1252            .await
1253            .unwrap_err();
1254
1255        assert!(matches!(err, DomainError::InvalidProviderType(_)), "{err}");
1256        assert!(h.repository.list_providers().await.unwrap().is_empty());
1257        assert_eq!(h.bus.count("providers-changed"), 0);
1258    }
1259
1260    #[test]
1261    fn quota_pause_starts_under_ten_percent_until_reset() {
1262        let now = Utc::now();
1263        let reset_at = now + chrono::Duration::minutes(20);
1264        let pause = |remaining, reset_at| {
1265            quota_pause(
1266                &RateLimit {
1267                    limit: 5000,
1268                    remaining,
1269                    reset_at,
1270                },
1271                now,
1272            )
1273        };
1274
1275        assert_eq!(pause(500, reset_at), None);
1276        assert_eq!(pause(499, reset_at), Some(reset_at));
1277        assert_eq!(pause(0, reset_at), Some(reset_at));
1278        assert_eq!(pause(0, now), None);
1279        assert_eq!(
1280            quota_pause(
1281                &RateLimit {
1282                    limit: 0,
1283                    remaining: 0,
1284                    reset_at,
1285                },
1286                now,
1287            ),
1288            None
1289        );
1290    }
1291
1292    type StubGate = Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>;
1293
1294    async fn stub_github(
1295        remaining: u64, reset: i64, gate: StubGate,
1296    ) -> (String, Arc<Mutex<Vec<String>>>) {
1297        use tokio::io::{
1298            AsyncReadExt,
1299            AsyncWriteExt,
1300        };
1301
1302        let _ = rustls::crypto::ring::default_provider().install_default();
1303        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1304        let addr = listener.local_addr().unwrap();
1305        let requests: Arc<Mutex<Vec<String>>> = Arc::default();
1306        let log = requests.clone();
1307        tokio::spawn(async move {
1308            loop {
1309                let (mut stream, _) = listener.accept().await.unwrap();
1310                let mut request = Vec::new();
1311                let mut buffer = [0; 4096];
1312                while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1313                    let count = stream.read(&mut buffer).await.unwrap();
1314                    if count == 0 {
1315                        break;
1316                    }
1317                    request.extend_from_slice(&buffer[..count]);
1318                }
1319                let request = String::from_utf8(request).unwrap();
1320                let path = request.split_whitespace().nth(1).unwrap_or_default();
1321                let path = path.split('?').next().unwrap().to_string();
1322                let (status, body) = if path == "/api/v3/rate_limit" {
1323                    if let Some((arrived, release)) = &gate {
1324                        arrived.notify_one();
1325                        release.notified().await;
1326                    }
1327                    let rate = serde_json::json!({
1328                        "limit": 5000, "used": 5000 - remaining, "remaining": remaining, "reset": reset
1329                    });
1330                    let search = serde_json::json!({"limit": 30, "used": 0, "remaining": 30, "reset": reset});
1331                    let body = serde_json::json!({
1332                        "resources": {"core": rate, "search": search},
1333                        "rate": rate
1334                    });
1335                    ("200 OK", body.to_string())
1336                } else {
1337                    ("404 Not Found", r#"{"message":"Not Found"}"#.to_string())
1338                };
1339                log.lock().await.push(path);
1340                let response = format!(
1341                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1342                    body.len()
1343                );
1344                let _ = stream.write_all(response.as_bytes()).await;
1345            }
1346        });
1347
1348        (format!("http://{addr}"), requests)
1349    }
1350
1351    #[tokio::test]
1352    async fn refresh_skips_a_github_provider_with_its_quota_almost_used_up() {
1353        let h = Harness::with_real_plugins().await;
1354        h.start().await;
1355        let reset = Utc::now().timestamp() + 1200;
1356        let (base_url, requests) = stub_github(0, reset, None).await;
1357        let id = h
1358            .repository
1359            .add_provider(&provider(
1360                "github",
1361                "github",
1362                "ghp_stub",
1363                30,
1364                &[("base_url", &base_url), ("selected_items", "acme/app")],
1365            ))
1366            .await
1367            .unwrap();
1368
1369        let first = h
1370            .pipeline_service
1371            .fetch_now(&[id], Duration::from_secs(5))
1372            .await;
1373        let again = h
1374            .pipeline_service
1375            .fetch_now(&[id], Duration::from_secs(5))
1376            .await;
1377        let summaries = h.provider_service.list_providers().await.unwrap();
1378
1379        assert_eq!(*requests.lock().await, ["/api/v3/rate_limit"]);
1380        assert_eq!(
1381            summaries[0].paused_until,
1382            DateTime::from_timestamp(reset, 0)
1383        );
1384        assert!(matches!(summaries[0].last_fetch_status, FetchStatus::Never));
1385        assert_eq!(first[0].state, crate::domain::RefreshState::Recent);
1386        assert_eq!(again[0].state, crate::domain::RefreshState::Recent);
1387        assert_eq!(again[0].kind, Some(crate::domain::FailureKind::RateLimited));
1388        assert_eq!(*requests.lock().await, ["/api/v3/rate_limit"]);
1389    }
1390
1391    #[tokio::test]
1392    async fn refresh_fetches_a_github_provider_with_quota_left() {
1393        let h = Harness::with_real_plugins().await;
1394        h.start().await;
1395        let (base_url, requests) = stub_github(4000, Utc::now().timestamp() + 1200, None).await;
1396        let id = h
1397            .repository
1398            .add_provider(&provider(
1399                "github",
1400                "github",
1401                "ghp_stub",
1402                30,
1403                &[("base_url", &base_url), ("selected_items", "acme/app")],
1404            ))
1405            .await
1406            .unwrap();
1407
1408        h.pipeline_service
1409            .fetch_now(&[id], Duration::from_secs(5))
1410            .await;
1411        let summaries = h.provider_service.list_providers().await.unwrap();
1412        let requests = requests.lock().await;
1413
1414        assert_eq!(requests[0], "/api/v3/rate_limit");
1415        assert!(requests
1416            .iter()
1417            .any(|path| path.starts_with("/api/v3/repos/acme/app")));
1418        assert_eq!(summaries[0].paused_until, None);
1419    }
1420
1421    #[tokio::test]
1422    async fn quota_answer_for_replaced_credentials_does_not_pause_the_new_ones() {
1423        let h = Harness::with_real_plugins().await;
1424        let reset = Utc::now().timestamp() + 1200;
1425        let arrived = Arc::new(tokio::sync::Notify::new());
1426        let release = Arc::new(tokio::sync::Notify::new());
1427        let (base_url, _) = stub_github(0, reset, Some((arrived.clone(), release.clone()))).await;
1428        let id = h
1429            .repository
1430            .add_provider(&provider(
1431                "github",
1432                "github",
1433                "ghp_old",
1434                30,
1435                &[("base_url", &base_url), ("selected_items", "acme/app")],
1436            ))
1437            .await
1438            .unwrap();
1439        let service = Arc::clone(&h.provider_service);
1440        let lease = service.acquire(id).await.unwrap();
1441
1442        let check = tokio::spawn({
1443            let service = Arc::clone(&service);
1444            let lease = lease.clone();
1445            async move { service.check_quota(&lease).await }
1446        });
1447        arrived.notified().await;
1448        let replacement =
1449            (service.provider_factory)(&h.repository.get_provider(id).await.unwrap()).unwrap();
1450        service.replace(id, replacement, 30).await;
1451        release.notify_one();
1452
1453        assert_eq!(check.await.unwrap(), None);
1454        assert_eq!(h.scheduler.paused_until(id, Utc::now()), None);
1455    }
1456
1457    fn updated(h: &Harness, id: i64) -> ProviderConfig {
1458        let mut config = h.config("renamed", 45);
1459        config.id = Some(id);
1460        config
1461    }
1462
1463    #[tokio::test]
1464    async fn an_update_during_a_blocked_fetch_supersedes_the_old_attempt() {
1465        let h = Harness::new().await;
1466        h.start().await;
1467        let old_gate = Gate::new();
1468        let (id, old) = h
1469            .add_stub(|id| {
1470                StubProvider::new(id)
1471                    .gated(&old_gate)
1472                    .returning(vec![pipeline(id, "old", PipelineStatus::Success)])
1473            })
1474            .await;
1475        let new = h.stubs.queue(StubProvider::new(id).returning(vec![pipeline(
1476            id,
1477            "new",
1478            PipelineStatus::Failed,
1479        )]));
1480        let blocked = h.spawn_fetch(id).await;
1481        old_gate.arrived().await;
1482        let before = h.provider_service.acquire(id).await.unwrap().generation;
1483
1484        h.provider_service
1485            .update_provider(id, updated(&h, id))
1486            .await
1487            .unwrap();
1488        let updated_at = h
1489            .bus
1490            .names()
1491            .iter()
1492            .position(|name| *name == "provider-updated")
1493            .unwrap();
1494        h.bus
1495            .wait_for("the fetch queued by the update", |events| {
1496                events[updated_at..]
1497                    .iter()
1498                    .any(|event| matches!(event, CoreEvent::PipelinesUpdated { .. }))
1499            })
1500            .await;
1501        old_gate.open();
1502        let old_result = blocked.await.unwrap();
1503
1504        assert!(matches!(old_result, FetchResult::Superseded));
1505        assert_eq!(h.cached_names(id).await, ["new"]);
1506        let published: Vec<_> = h.bus.events()[updated_at..]
1507            .iter()
1508            .filter_map(|event| match event {
1509                CoreEvent::PipelinesUpdated { pipelines, .. } => {
1510                    Some(pipelines.iter().map(|p| p.name.clone()).collect::<Vec<_>>())
1511                }
1512                _ => None,
1513            })
1514            .collect();
1515        assert_eq!(published, vec![vec!["new".to_string()]]);
1516        let view = h.scheduler.view(id, Instant::now()).unwrap();
1517        assert_eq!(
1518            (view.failures, view.last_failure, view.in_flight),
1519            (0, None, false)
1520        );
1521        assert_ne!(
1522            h.provider_service.acquire(id).await.unwrap().generation,
1523            before
1524        );
1525        assert_eq!((old.fetch_count(), new.fetch_count()), (1, 1));
1526    }
1527
1528    #[tokio::test]
1529    async fn an_update_resets_the_fetch_status_and_the_schedule() {
1530        let h = Harness::new().await;
1531        let (id, _) = h
1532            .add_stub(|id| StubProvider::new(id).failing(DomainError::NetworkError("down".into())))
1533            .await;
1534        h.fetch_directly(id).await;
1535        h.health_inputs();
1536        assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 1);
1537
1538        h.provider_service
1539            .update_provider(id, updated(&h, id))
1540            .await
1541            .unwrap();
1542        let inputs = h.health_inputs();
1543
1544        let event = h
1545            .bus
1546            .events()
1547            .into_iter()
1548            .find_map(|event| match event {
1549                CoreEvent::ProviderUpdated { provider, .. } => Some(provider),
1550                _ => None,
1551            })
1552            .unwrap();
1553        assert!(matches!(event.last_fetch_status, FetchStatus::Never));
1554        assert_eq!(event.last_fetch_error, None);
1555        assert_eq!(event.last_fetch_error_kind, None);
1556        assert_eq!(event.health.next_attempt_at, None);
1557        assert_eq!(event.name, "renamed");
1558        assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 0);
1559        let lease = h.provider_service.acquire(id).await.unwrap();
1560        assert!(matches!(
1561            inputs.as_slice(),
1562            [HealthInput::ProvidersChanged { changed }]
1563                if changed.len() == 1 && changed[0].generation == Some(lease.generation)
1564        ));
1565    }
1566
1567    #[tokio::test]
1568    async fn an_old_failure_finishing_after_an_update_leaves_the_new_schedule_clean() {
1569        let h = Harness::new().await;
1570        h.start().await;
1571        let old_gate = Gate::new();
1572        let new_gate = Gate::new();
1573        let (id, _) = h
1574            .add_stub(|id| {
1575                StubProvider::new(id)
1576                    .gated(&old_gate)
1577                    .failing(DomainError::NetworkError("down".into()))
1578            })
1579            .await;
1580        h.stubs.queue(StubProvider::new(id).gated(&new_gate));
1581        let blocked = h.spawn_fetch(id).await;
1582        old_gate.arrived().await;
1583
1584        h.provider_service
1585            .update_provider(id, updated(&h, id))
1586            .await
1587            .unwrap();
1588        new_gate.arrived().await;
1589        old_gate.open();
1590        let old_result = blocked.await.unwrap();
1591
1592        assert!(matches!(old_result, FetchResult::Superseded));
1593        let view = h.scheduler.view(id, Instant::now()).unwrap();
1594        assert_eq!(
1595            (view.failures, view.last_failure, view.in_flight),
1596            (0, None, true)
1597        );
1598        let (status, error, _) = h.repository.get_provider_fetch_status(id).await.unwrap();
1599        assert_eq!((status.as_str(), error), ("never", None));
1600        new_gate.open();
1601        h.bus
1602            .wait_for("the new fetch", |events| {
1603                events
1604                    .iter()
1605                    .any(|event| matches!(event, CoreEvent::PipelinesUpdated { .. }))
1606            })
1607            .await;
1608        assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 0);
1609    }
1610
1611    #[tokio::test]
1612    async fn a_removal_during_a_blocked_fetch_leaves_nothing_behind() {
1613        let h = Harness::new().await;
1614        let gate = Gate::new();
1615        let (id, _) = h.add_stub(|id| StubProvider::new(id).gated(&gate)).await;
1616        let blocked = h.spawn_fetch(id).await;
1617        gate.arrived().await;
1618        h.health_inputs();
1619
1620        h.provider_service.remove_provider(id).await.unwrap();
1621        let removed_at = h.bus.events().len();
1622        gate.open();
1623        let result = blocked.await.unwrap();
1624        let inputs = h.health_inputs();
1625
1626        assert!(matches!(result, FetchResult::Superseded));
1627        assert!(h.repository.get_provider(id).await.is_err());
1628        assert!(h.repository.get_provider_fetch_status(id).await.is_err());
1629        assert!(h.cached_names(id).await.is_empty());
1630        assert!(h.scheduler.view(id, Instant::now()).is_none());
1631        assert!(h.bus.names()[removed_at..].is_empty());
1632        assert_eq!(h.bus.names()[removed_at - 1], "provider-removed");
1633        assert!(matches!(
1634            inputs.as_slice(),
1635            [HealthInput::ProvidersChanged { changed }]
1636                if changed[0].id == id && changed[0].generation.is_none()
1637        ));
1638    }
1639
1640    #[tokio::test]
1641    async fn concurrent_acquires_share_one_provider() {
1642        let h = Harness::new().await;
1643        let id = h.add_provider("shared").await;
1644
1645        let leases =
1646            futures::future::join_all((0..8).map(|_| h.provider_service.acquire(id))).await;
1647
1648        let first = leases[0].as_ref().unwrap();
1649        for lease in &leases {
1650            let lease = lease.as_ref().unwrap();
1651            assert!(Arc::ptr_eq(&lease.provider, &first.provider));
1652            assert_eq!(lease.generation, first.generation);
1653        }
1654    }
1655
1656    #[tokio::test]
1657    async fn a_lazy_acquire_racing_a_removal_does_not_bring_the_provider_back() {
1658        let h = Harness::new().await;
1659        let id = h.add_provider("racing").await;
1660        let lock = h.provider_service.commit_lock(id);
1661        let held = lock.lock().await;
1662        let removal = tokio::spawn({
1663            let service = Arc::clone(&h.provider_service);
1664            async move { service.remove_provider(id).await }
1665        });
1666        tokio::time::sleep(Duration::from_millis(100)).await;
1667        let acquire = tokio::spawn({
1668            let service = Arc::clone(&h.provider_service);
1669            async move { service.acquire(id).await }
1670        });
1671        tokio::time::sleep(Duration::from_millis(100)).await;
1672
1673        drop(held);
1674        removal.await.unwrap().unwrap();
1675        let acquired = acquire.await.unwrap();
1676
1677        assert!(matches!(acquired, Err(DomainError::ProviderNotFound(_))));
1678        assert!(h.provider_service.current(id).await.is_none());
1679        assert!(h.scheduler.view(id, Instant::now()).is_none());
1680    }
1681
1682    #[tokio::test]
1683    async fn changing_the_refresh_interval_keeps_the_generation_and_reschedules() {
1684        let h = Harness::new().await;
1685        h.start().await;
1686        let (id, _) = h.add_stub(StubProvider::new).await;
1687        h.pipeline_service
1688            .fetch_now(&[id], Duration::from_secs(5))
1689            .await;
1690        let before = h.provider_service.acquire(id).await.unwrap().generation;
1691        assert!(
1692            h.scheduler
1693                .view(id, Instant::now())
1694                .unwrap()
1695                .next_attempt_in
1696                > Duration::from_secs(20)
1697        );
1698
1699        h.provider_service
1700            .update_provider_refresh_interval(id, 5)
1701            .await
1702            .unwrap();
1703
1704        let after = h.provider_service.acquire(id).await.unwrap().generation;
1705        assert_eq!(before, after);
1706        assert!(
1707            h.scheduler
1708                .view(id, Instant::now())
1709                .unwrap()
1710                .next_attempt_in
1711                <= Duration::from_secs(5)
1712        );
1713        assert_eq!(
1714            h.repository
1715                .get_provider(id)
1716                .await
1717                .unwrap()
1718                .refresh_interval,
1719            5
1720        );
1721    }
1722
1723    #[tokio::test]
1724    async fn health_hears_about_every_provider_change() {
1725        let h = Harness::new().await;
1726
1727        let id = h
1728            .provider_service
1729            .add_provider(h.config("argo", 30))
1730            .await
1731            .unwrap();
1732        let added = h.health_inputs();
1733        let added_generation = h.provider_service.acquire(id).await.unwrap().generation;
1734        h.provider_service
1735            .update_provider(id, updated(&h, id))
1736            .await
1737            .unwrap();
1738        let updated_inputs = h.health_inputs();
1739        h.provider_service.load_all_providers().await.unwrap();
1740        let reloaded = h.health_inputs();
1741        let reloaded_generation = h.provider_service.acquire(id).await.unwrap().generation;
1742        h.provider_service.remove_provider(id).await.unwrap();
1743        let removed = h.health_inputs();
1744
1745        let generations = |inputs: &[HealthInput]| match inputs {
1746            [HealthInput::ProvidersChanged { changed }] => changed
1747                .iter()
1748                .map(|input| (input.id, input.generation))
1749                .collect::<Vec<_>>(),
1750            other => panic!("unexpected health inputs: {other:?}"),
1751        };
1752        assert_eq!(generations(&added), [(id, Some(added_generation))]);
1753        let updated_generation = generations(&updated_inputs)[0].1;
1754        assert!(updated_generation.is_some());
1755        assert_ne!(updated_generation, Some(added_generation));
1756        assert_eq!(generations(&reloaded), [(id, Some(reloaded_generation))]);
1757        assert_ne!(Some(reloaded_generation), updated_generation);
1758        assert_eq!(generations(&removed), [(id, None)]);
1759        assert_eq!(h.bus.count("provider-added"), 1);
1760        assert_eq!(h.bus.count("provider-updated"), 1);
1761        assert_eq!(h.bus.count("provider-removed"), 1);
1762    }
1763
1764    #[tokio::test]
1765    async fn workflow_parameters_are_cached_for_ten_minutes() {
1766        let h = Harness::new().await;
1767        let (id, stub) = h.add_stub(StubProvider::new).await;
1768        let workflow = format!("github__{id}__web");
1769
1770        let first = h
1771            .provider_service
1772            .get_workflow_parameters(id, &workflow)
1773            .await
1774            .unwrap();
1775        h.provider_service
1776            .get_workflow_parameters(id, &workflow)
1777            .await
1778            .unwrap();
1779        let cached_fetches = stub
1780            .parameter_fetches
1781            .load(std::sync::atomic::Ordering::SeqCst);
1782        sqlx::query(
1783            "UPDATE workflow_parameters_cache SET cached_at = datetime('now', '-11 minutes')",
1784        )
1785        .execute(&h.pool)
1786        .await
1787        .unwrap();
1788        h.provider_service
1789            .get_workflow_parameters(id, &workflow)
1790            .await
1791            .unwrap();
1792
1793        assert_eq!(first.len(), 1);
1794        assert_eq!(cached_fetches, 1);
1795        assert_eq!(
1796            stub.parameter_fetches
1797                .load(std::sync::atomic::Ordering::SeqCst),
1798            2
1799        );
1800    }
1801
1802    #[tokio::test]
1803    async fn workflow_parameter_failures_keep_their_kind_and_leave_no_lock_behind() {
1804        let h = Harness::new().await;
1805        let (id, stub) = h.add_stub(StubProvider::new).await;
1806        *stub.parameters.lock().unwrap() = Err(DomainError::RateLimited {
1807            message: "slow".to_string(),
1808            retry_after: None,
1809        });
1810
1811        let failed = h
1812            .provider_service
1813            .get_workflow_parameters(id, "github__1__web")
1814            .await;
1815        let missing = h
1816            .provider_service
1817            .get_workflow_parameters(4242, "github__4242__web")
1818            .await;
1819
1820        assert!(matches!(failed, Err(DomainError::RateLimited { .. })));
1821        assert!(matches!(missing, Err(DomainError::ProviderNotFound(_))));
1822        assert!(h.provider_service.parameter_fetches.is_empty());
1823    }
1824
1825    #[tokio::test]
1826    async fn fetching_workflow_parameters_does_not_block_the_provider_map() {
1827        let h = Harness::new().await;
1828        let gate = Gate::new();
1829        let (id, _) = h
1830            .add_stub(|id| StubProvider::new(id).parameter_gated(&gate))
1831            .await;
1832        let other = h.add_provider("other").await;
1833        let fetch = tokio::spawn({
1834            let service = Arc::clone(&h.provider_service);
1835            async move { service.get_workflow_parameters(id, "github__1__web").await }
1836        });
1837        gate.arrived().await;
1838
1839        let acquired =
1840            tokio::time::timeout(Duration::from_secs(2), h.provider_service.acquire(other)).await;
1841        gate.open();
1842
1843        assert!(acquired.is_ok());
1844        fetch.await.unwrap().unwrap();
1845    }
1846
1847    #[tokio::test]
1848    async fn workflow_parameters_fetched_across_a_removal_are_not_cached() {
1849        let h = Harness::new().await;
1850        let gate = Gate::new();
1851        let (id, _) = h
1852            .add_stub(|id| StubProvider::new(id).parameter_gated(&gate))
1853            .await;
1854        let workflow = format!("github__{id}__web");
1855        let fetch = tokio::spawn({
1856            let service = Arc::clone(&h.provider_service);
1857            let workflow = workflow.clone();
1858            async move { service.get_workflow_parameters(id, &workflow).await }
1859        });
1860        gate.arrived().await;
1861
1862        h.provider_service.remove_provider(id).await.unwrap();
1863        gate.open();
1864
1865        assert_eq!(fetch.await.unwrap().unwrap().len(), 1);
1866        assert!(h
1867            .repository
1868            .get_cached_workflow_parameters(&workflow)
1869            .await
1870            .unwrap()
1871            .is_none());
1872    }
1873
1874    #[tokio::test]
1875    async fn field_options_work_when_the_plugin_rejects_the_partial_form() {
1876        let h = Harness::with_real_plugins().await;
1877        let kubeconfig = h._temp_dir.path().join("kubeconfig");
1878        std::fs::write(
1879            &kubeconfig,
1880            "apiVersion: v1\nkind: Config\nclusters:\n- name: c\n  cluster: {server: 'https://127.0.0.1:6443'}\nusers:\n- name: u\n  user: {token: t}\ncontexts:\n- name: ctx-b\n  context: {cluster: c, user: u}\n- name: ctx-a\n  context: {cluster: c, user: u}\ncurrent-context: ctx-a\n",
1881        )
1882        .unwrap();
1883        let config = HashMap::from([
1884            ("kubeconfig".to_string(), kubeconfig.display().to_string()),
1885            ("namespaces".to_string(), "Not_A_Namespace".to_string()),
1886        ]);
1887        assert!(h
1888            .provider_service
1889            .init_plugin("fluxcd", config.clone(), 0)
1890            .is_err());
1891
1892        let contexts = h
1893            .provider_service
1894            .field_options("fluxcd", "context", config)
1895            .await
1896            .unwrap();
1897
1898        assert_eq!(contexts, ["ctx-a", "ctx-b"]);
1899    }
1900
1901    #[tokio::test]
1902    async fn field_options_that_need_a_client_use_the_initialized_plugin() {
1903        let h = Harness::with_real_plugins().await;
1904        let ready = HashMap::from([
1905            ("server_url".to_string(), h.argocd_url.clone()),
1906            ("token".to_string(), "token".to_string()),
1907        ]);
1908        let incomplete = HashMap::from([("token".to_string(), "token".to_string())]);
1909
1910        let organizations = h
1911            .provider_service
1912            .field_options("argocd", "organizations", ready)
1913            .await;
1914        let rejected = h
1915            .provider_service
1916            .field_options("argocd", "organizations", incomplete)
1917            .await;
1918
1919        assert_eq!(organizations.unwrap(), Vec::<String>::new());
1920        assert!(
1921            matches!(rejected, Err(DomainError::InvalidConfig(message)) if message.contains("initialize"))
1922        );
1923    }
1924
1925    fn serve_tls(stream: std::net::TcpStream, config: Arc<rustls::ServerConfig>) {
1926        let Ok(connection) = rustls::ServerConnection::new(config) else {
1927            return;
1928        };
1929        let mut tls = rustls::StreamOwned::new(connection, stream);
1930        let mut request = Vec::new();
1931        let mut buffer = [0; 4096];
1932        while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1933            match tls.read(&mut buffer) {
1934                Ok(0) | Err(_) => return,
1935                Ok(count) => request.extend_from_slice(&buffer[..count]),
1936            }
1937        }
1938        let body = r#"{"items":[]}"#;
1939        let _ = write!(
1940            tls,
1941            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1942            body.len()
1943        );
1944        let _ = tls.flush();
1945        tls.conn.send_close_notify();
1946        let _ = tls.flush();
1947    }
1948
1949    fn self_signed_argocd() -> String {
1950        let _ = rustls::crypto::ring::default_provider().install_default();
1951        let cert = rustls::pki_types::CertificateDer::from(
1952            include_bytes!("../../../tests/fixtures/self_signed_cert.der").to_vec(),
1953        );
1954        let key = rustls::pki_types::PrivateKeyDer::Pkcs8(
1955            include_bytes!("../../../tests/fixtures/self_signed_key.der")
1956                .to_vec()
1957                .into(),
1958        );
1959        let config = Arc::new(
1960            rustls::ServerConfig::builder()
1961                .with_no_client_auth()
1962                .with_single_cert(vec![cert], key)
1963                .unwrap(),
1964        );
1965        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1966        let addr = listener.local_addr().unwrap();
1967        std::thread::spawn(move || {
1968            for stream in listener.incoming().flatten() {
1969                let config = Arc::clone(&config);
1970                std::thread::spawn(move || serve_tls(stream, config));
1971            }
1972        });
1973
1974        format!("https://{addr}")
1975    }
1976
1977    fn argocd_settings(url: &str, insecure: bool) -> HashMap<String, String> {
1978        HashMap::from([
1979            ("server_url".to_string(), url.to_string()),
1980            ("token".to_string(), "token".to_string()),
1981            ("insecure".to_string(), insecure.to_string()),
1982        ])
1983    }
1984
1985    #[tokio::test]
1986    async fn argocd_insecure_tls_is_honored_by_add_validate_and_preview() {
1987        let h = Harness::with_real_plugins().await;
1988        let service = &h.provider_service;
1989        let url = self_signed_argocd();
1990        let page = Some(pipedash_plugin_api::PaginationParams {
1991            page: 1,
1992            page_size: 10,
1993        });
1994        let add = |insecure| {
1995            let mut config = h.config("argo", 30);
1996            config.config = argocd_settings(&url, insecure);
1997            config.config.remove("token");
1998            service.add_provider(config)
1999        };
2000
2001        let id = add(true).await.unwrap();
2002        let insecure = service
2003            .init_plugin("argocd", argocd_settings(&url, true), 0)
2004            .unwrap();
2005        let secure = service
2006            .init_plugin("argocd", argocd_settings(&url, false), 0)
2007            .unwrap();
2008        let rejected = add(false).await.unwrap_err();
2009
2010        assert!(id > 0);
2011        assert!(insecure.validate_credentials().await.unwrap());
2012        assert!(insecure
2013            .fetch_available_pipelines_filtered(None, None, page.clone())
2014            .await
2015            .unwrap()
2016            .items
2017            .is_empty());
2018        assert!(matches!(
2019            secure.validate_credentials().await,
2020            Err(PluginError::Tls(_))
2021        ));
2022        assert!(matches!(
2023            secure
2024                .fetch_available_pipelines_filtered(None, None, page)
2025                .await,
2026            Err(PluginError::Tls(_))
2027        ));
2028        assert!(matches!(rejected, DomainError::InvalidConfig(message) if message.contains("TLS")));
2029    }
2030}