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(®istry, &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, ¶meters)
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}