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::DashMap;
14use pipedash_plugin_api::{
15 Plugin as PluginTrait,
16 PluginRegistry,
17 RateLimit,
18 CONTROLLER_HEALTH_FEATURE,
19};
20use tokio::sync::{
21 Mutex,
22 RwLock,
23};
24use tokio::time::{
25 timeout,
26 Instant,
27};
28
29use super::health_service::{
30 HealthHandle,
31 HealthInput,
32 ProviderHealthInput,
33};
34use super::sign_in_service::SignInService;
35use crate::application::fetch_scheduler::{
36 FetchScheduler,
37 ForcedClaim,
38 Generation,
39};
40use crate::application::refresh_manager::RefreshDispatcher;
41use crate::domain::{
42 DomainError,
43 DomainResult,
44 FetchStatus,
45 HealthLevel,
46 Provider,
47 ProviderConfig,
48 ProviderFreshness,
49 ProviderHealth,
50 ProviderImportResult,
51 ProviderRecovery,
52 ProviderSummary,
53 SignInCredential,
54 SignInRef,
55 SignInStart,
56 SignInStatus,
57 SIGN_IN_CONFIG_KEY,
58};
59use crate::event::{
60 CoreEvent,
61 EventBus,
62};
63use crate::infrastructure::config::sync::{
64 ProviderSyncService,
65 SyncResult,
66};
67use crate::infrastructure::config::token_ref::TokenReference;
68use crate::infrastructure::config::{
69 ConfigLoader,
70 ConfigState,
71 GeneralSettingsUpdate,
72 PipedashConfig,
73 ProviderFileConfig,
74};
75use crate::infrastructure::database::{
76 ProviderFetchStatus,
77 Repository,
78 FETCH_STATUS_ERROR,
79 FETCH_STATUS_NEVER,
80 FETCH_STATUS_SUCCESS,
81};
82use crate::infrastructure::oauth;
83use crate::infrastructure::providers::PluginAdapter;
84use crate::infrastructure::run_logs::LogStore;
85use crate::infrastructure::{
86 HttpClientManager,
87 TokenStore,
88};
89use crate::plugins;
90mod discovery;
91
92const QUOTA_PAUSE_PERCENT: u64 = 10;
93const ACQUIRE_ATTEMPTS: usize = 3;
94
95pub(crate) type ProviderFactory =
96 Arc<dyn Fn(&ProviderConfig) -> DomainResult<Arc<dyn Provider>> + Send + Sync>;
97
98#[derive(Clone)]
99pub struct ProviderLease {
100 pub id: i64,
101 pub generation: Generation,
102 pub provider: Arc<dyn Provider>,
103}
104
105enum TakenSignIn {
106 None,
107 Flow(String),
108 Kept,
109}
110
111struct ProviderSlot {
112 generation: Generation,
113 provider: Arc<dyn Provider>,
114}
115
116impl ProviderSlot {
117 fn lease(&self, id: i64) -> ProviderLease {
118 ProviderLease {
119 id,
120 generation: self.generation,
121 provider: Arc::clone(&self.provider),
122 }
123 }
124}
125
126struct ParameterFetch<'a> {
127 fetches: &'a DashMap<String, Arc<Mutex<()>>>,
128 key: &'a str,
129 lock: Arc<Mutex<()>>,
130}
131
132impl<'a> ParameterFetch<'a> {
133 fn new(fetches: &'a DashMap<String, Arc<Mutex<()>>>, key: &'a str) -> Self {
134 let lock = Arc::clone(fetches.entry(key.to_string()).or_default().value());
135
136 Self { fetches, key, lock }
137 }
138}
139
140impl Drop for ParameterFetch<'_> {
141 fn drop(&mut self) {
142 self.fetches
143 .remove_if(self.key, |_, lock| Arc::ptr_eq(lock, &self.lock));
144 }
145}
146
147pub struct ProviderService {
148 repository: Arc<Repository>,
149 config_state: Arc<ConfigState>,
150 http_client_manager: Arc<HttpClientManager>,
151 providers: RwLock<HashMap<i64, ProviderSlot>>,
152 commit_locks: DashMap<i64, Arc<Mutex<()>>>,
153 renewal_locks: DashMap<i64, Arc<Mutex<()>>>,
154 sign_ins: Arc<SignInService>,
155 plugin_registry: Arc<PluginRegistry>,
156 parameter_fetches: DashMap<String, Arc<Mutex<()>>>,
157 discovery: discovery::DiscoveryCache,
158 event_bus: Arc<dyn EventBus>,
159 scheduler: Arc<FetchScheduler>,
160 health: HealthHandle,
161 dispatcher: RefreshDispatcher,
162 provider_factory: ProviderFactory,
163 config_apply: Mutex<()>,
164 log_store: Arc<LogStore>,
165}
166
167impl ProviderService {
168 #[allow(clippy::too_many_arguments)]
169 pub fn new(
170 repository: Arc<Repository>, http_client_manager: Arc<HttpClientManager>,
171 event_bus: Arc<dyn EventBus>, scheduler: Arc<FetchScheduler>, health: HealthHandle,
172 dispatcher: RefreshDispatcher, config_state: Arc<ConfigState>, log_store: Arc<LogStore>,
173 ) -> Self {
174 let plugin_registry = Arc::new(plugins::create_plugin_registry());
175 let provider_factory: ProviderFactory = {
176 let registry = Arc::clone(&plugin_registry);
177 let http = Arc::clone(&http_client_manager);
178 Arc::new(move |config| plugin_provider(®istry, &http, config))
179 };
180
181 Self {
182 repository,
183 config_state,
184 http_client_manager,
185 providers: RwLock::new(HashMap::new()),
186 commit_locks: DashMap::new(),
187 renewal_locks: DashMap::new(),
188 sign_ins: Arc::new(SignInService::default()),
189 plugin_registry,
190 parameter_fetches: DashMap::new(),
191 discovery: discovery::DiscoveryCache::default(),
192 event_bus,
193 scheduler,
194 health,
195 dispatcher,
196 provider_factory,
197 config_apply: Mutex::new(()),
198 log_store,
199 }
200 }
201
202 #[cfg(test)]
203 pub(crate) fn with_provider_factory(mut self, factory: ProviderFactory) -> Self {
204 self.provider_factory = factory;
205 self
206 }
207
208 pub fn repository(&self) -> &Arc<Repository> {
209 &self.repository
210 }
211
212 pub(crate) fn scheduler(&self) -> &Arc<FetchScheduler> {
213 &self.scheduler
214 }
215
216 pub(crate) fn health(&self) -> &HealthHandle {
217 &self.health
218 }
219
220 pub(crate) fn dispatcher(&self) -> &RefreshDispatcher {
221 &self.dispatcher
222 }
223
224 pub fn list_available_plugins(&self) -> Vec<pipedash_plugin_api::PluginMetadata> {
225 let mut metadata_list = Vec::new();
226 for provider_type in self.plugin_registry.provider_types() {
227 if let Some(plugin) = self.plugin_registry.get(&provider_type) {
228 metadata_list.push(plugin.metadata().clone());
229 }
230 }
231 metadata_list
232 }
233
234 pub fn retains_run_history(&self, provider_type: &str) -> bool {
235 self.plugin_registry
236 .get(provider_type)
237 .is_some_and(|plugin| plugin.metadata().capabilities.retains_run_history)
238 }
239
240 pub async fn init_plugin(
241 &self, provider_type: &str, mut config: HashMap<String, String>, provider_id: i64,
242 ) -> DomainResult<Box<dyn PluginTrait>> {
243 self.resolve_sign_in(&mut config).await?;
244 init_plugin(
245 &self.http_client_manager,
246 provider_type,
247 config,
248 provider_id,
249 )
250 }
251
252 pub async fn field_options(
253 &self, provider_type: &str, field_key: &str, mut config: HashMap<String, String>,
254 ) -> DomainResult<Vec<String>> {
255 self.resolve_sign_in(&mut config).await?;
256 let (mut plugin, client) = build_plugin(&self.http_client_manager, provider_type, &config)?;
257 let init_error = plugin.initialize(0, config.clone(), Some(client)).err();
258
259 match plugin.get_field_options(field_key, &config).await {
260 Ok(options) => Ok(options),
261 Err(e) => Err(match init_error {
262 Some(init) => {
263 DomainError::InvalidConfig(format!("Failed to initialize plugin: {init}"))
264 }
265 None => PluginAdapter::map_error(e),
266 }),
267 }
268 }
269
270 pub(crate) fn commit_lock(&self, id: i64) -> Arc<Mutex<()>> {
271 Arc::clone(self.commit_locks.entry(id).or_default().value())
272 }
273
274 fn renewal_lock(&self, id: i64) -> Arc<Mutex<()>> {
275 Arc::clone(self.renewal_locks.entry(id).or_default().value())
276 }
277
278 async fn resolve_sign_in(&self, config: &mut HashMap<String, String>) -> DomainResult<()> {
279 let Some(reference) = config.remove(SIGN_IN_CONFIG_KEY) else {
280 return Ok(());
281 };
282 let token = match SignInRef::parse(&reference) {
283 SignInRef::Flow(flow) => self.sign_ins.credential(&flow).await?.token,
284 SignInRef::Provider(id) => {
285 SignInCredential::bearer(&self.renewed_config(id).await?.token)
286 }
287 };
288 config.insert("token".to_string(), token);
289 Ok(())
290 }
291
292 async fn take_sign_in(
293 &self, config: &mut ProviderConfig, owner: Option<i64>,
294 ) -> DomainResult<TakenSignIn> {
295 let Some(reference) = config.config.remove(SIGN_IN_CONFIG_KEY) else {
296 return Ok(TakenSignIn::None);
297 };
298 match SignInRef::parse(&reference) {
299 SignInRef::Flow(flow) => {
300 config.token = self.sign_ins.credential(&flow).await?.encode()?;
301 Ok(TakenSignIn::Flow(flow))
302 }
303 SignInRef::Provider(id) if Some(id) == owner => {
304 config.token = self.renewed_config(id).await?.token;
305 Ok(TakenSignIn::Kept)
306 }
307 SignInRef::Provider(_) => Err(DomainError::InvalidConfig(
308 "This sign-in belongs to another provider".into(),
309 )),
310 }
311 }
312
313 async fn renewed_config(&self, id: i64) -> DomainResult<ProviderConfig> {
314 let renewal = self.renewal_lock(id);
315 let _renewal = renewal.lock().await;
316 let config = self.repository.get_provider(id).await?;
317 let Some(current) = SignInCredential::decode(&config.token)
318 .filter(|credential| credential.renew_at().is_some_and(|at| at <= Utc::now()))
319 else {
320 return Ok(config);
321 };
322
323 let (_, http) = build_plugin(
324 &self.http_client_manager,
325 &config.provider_type,
326 &config.config,
327 )?;
328 let token = oauth::refresh(&http, ¤t).await?.encode()?;
329
330 let commit = self.commit_lock(id);
331 let _commit = commit.lock().await;
332 let mut latest = self.repository.get_provider(id).await?;
333 if latest.token != config.token {
334 return Ok(latest);
335 }
336 self.repository.store_token(id, &token).await?;
337 tracing::info!(provider_id = id, "Renewed the provider sign-in");
338 latest.token = token;
339 Ok(latest)
340 }
341
342 async fn renew(&self, stale: ProviderLease) -> DomainResult<ProviderLease> {
343 let config = self.renewed_config(stale.id).await?;
344 let provider = (self.provider_factory)(&config)?;
345 let mut providers = self.providers.write().await;
346 let slot = providers
347 .get_mut(&stale.id)
348 .ok_or_else(|| DomainError::ProviderNotFound(stale.id.to_string()))?;
349 if slot.generation == stale.generation {
350 slot.provider = provider;
351 }
352 Ok(slot.lease(stale.id))
353 }
354
355 pub async fn provider_for_editing(&self, id: i64) -> DomainResult<ProviderConfig> {
356 let mut config = self.repository.get_provider(id).await?;
357 if SignInCredential::decode(&config.token).is_some() {
358 config.token.clear();
359 config.config.insert(
360 SIGN_IN_CONFIG_KEY.to_string(),
361 SignInRef::Provider(id).to_string(),
362 );
363 }
364 Ok(config)
365 }
366
367 pub async fn stored_plugin(&self, id: i64) -> DomainResult<Box<dyn PluginTrait>> {
368 let config = self.renewed_config(id).await?;
369 init_plugin(
370 &self.http_client_manager,
371 &config.provider_type,
372 config.plugin_config(),
373 id,
374 )
375 }
376
377 pub async fn start_sign_in(
378 &self, provider_type: &str, config: HashMap<String, String>,
379 ) -> DomainResult<SignInStart> {
380 let (plugin, http) = build_plugin(&self.http_client_manager, provider_type, &config)?;
381 let client = plugin
382 .sign_in_client(&config, &http)
383 .await
384 .map_err(PluginAdapter::map_error)?;
385 self.sign_ins.start(client, http).await
386 }
387
388 pub async fn sign_in_status(&self, reference: &str) -> DomainResult<SignInStatus> {
389 match SignInRef::parse(reference) {
390 SignInRef::Flow(flow) => Ok(self.sign_ins.status(&flow)),
391 SignInRef::Provider(id) => match self.renewed_config(id).await {
392 Ok(config) => Ok(SignInCredential::decode(&config.token).map_or(
393 SignInStatus::Expired,
394 |credential| SignInStatus::Completed {
395 account: credential.account,
396 },
397 )),
398 Err(DomainError::ProviderAuthenticationFailed(message)) => {
399 Ok(SignInStatus::Failed { message })
400 }
401 Err(e) => Err(e),
402 },
403 }
404 }
405
406 pub async fn complete_sign_in(
407 &self, flow: &str, redirect_url: &str,
408 ) -> DomainResult<SignInStatus> {
409 self.sign_ins.complete(flow, redirect_url).await
410 }
411
412 pub async fn cancel_sign_in(&self, flow: &str) {
413 self.sign_ins.cancel(flow).await;
414 }
415
416 pub async fn wait_for_sign_in(&self, flow: &str) -> SignInStatus {
417 self.sign_ins.wait(flow).await
418 }
419
420 async fn purge_run_logs(&self, id: i64) {
421 if let Err(e) = self.log_store.purge_provider(id).await {
422 tracing::warn!(provider_id = id, error = %e, "Failed to purge cached run logs");
423 }
424 }
425
426 async fn current(&self, id: i64) -> Option<ProviderLease> {
427 self.providers
428 .read()
429 .await
430 .get(&id)
431 .map(|slot| slot.lease(id))
432 }
433
434 pub(crate) async fn generation_of(&self, id: i64) -> Option<Generation> {
435 self.providers
436 .read()
437 .await
438 .get(&id)
439 .map(|slot| slot.generation)
440 }
441
442 pub(crate) async fn health_inputs(&self) -> Vec<ProviderHealthInput> {
443 let leases: Vec<ProviderLease> = self
444 .providers
445 .read()
446 .await
447 .iter()
448 .map(|(id, slot)| slot.lease(*id))
449 .collect();
450 let mut inputs = Vec::with_capacity(leases.len());
451 for lease in leases {
452 match self.repository.get_provider(lease.id).await {
453 Ok(config) => inputs.push(self.health_input(&config, &lease)),
454 Err(e) => {
455 tracing::debug!(provider_id = lease.id, error = %e, "Provider has no config row");
456 }
457 }
458 }
459
460 inputs
461 }
462
463 async fn install(&self, id: i64, provider: Arc<dyn Provider>, interval: i64) -> ProviderLease {
464 let mut providers = self.providers.write().await;
465 providers
466 .entry(id)
467 .or_insert_with(|| {
468 let generation = Generation::next();
469 self.scheduler
470 .register(id, generation, interval_of(interval));
471 ProviderSlot {
472 generation,
473 provider,
474 }
475 })
476 .lease(id)
477 }
478
479 async fn replace(&self, id: i64, provider: Arc<dyn Provider>, interval: i64) -> ProviderLease {
480 let generation = Generation::next();
481 let mut providers = self.providers.write().await;
482 providers.insert(
483 id,
484 ProviderSlot {
485 generation,
486 provider: Arc::clone(&provider),
487 },
488 );
489 self.scheduler.reset(id, generation, interval_of(interval));
490
491 ProviderLease {
492 id,
493 generation,
494 provider,
495 }
496 }
497
498 pub async fn acquire(&self, id: i64) -> DomainResult<ProviderLease> {
499 if let Some(lease) = self.current(id).await {
500 if lease.provider.renew_at().is_some_and(|at| at <= Utc::now()) {
501 return self.renew(lease).await;
502 }
503 return Ok(lease);
504 }
505
506 for _ in 0..ACQUIRE_ATTEMPTS {
507 let config = self.renewed_config(id).await?;
508 let provider = (self.provider_factory)(&config)?;
509 let lock = self.commit_lock(id);
510 let _guard = lock.lock().await;
511 if let Some(lease) = self.current(id).await {
512 return Ok(lease);
513 }
514 let row = self.repository.get_provider(id).await?;
515 if row.version == config.version {
516 let lease = self.install(id, provider, row.refresh_interval).await;
517 self.health.send(HealthInput::ProvidersChanged {
518 changed: vec![self.health_input(&row, &lease)],
519 });
520 return Ok(lease);
521 }
522 }
523
524 Err(DomainError::ConcurrentModification(format!(
525 "Provider {id} kept changing while it was loaded"
526 )))
527 }
528
529 pub async fn commit<F, Fut, T>(&self, lease: &ProviderLease, f: F) -> Option<T>
530 where
531 F: FnOnce() -> Fut,
532 Fut: Future<Output = T>,
533 {
534 self.commit_at(lease.id, lease.generation, f).await
535 }
536
537 async fn commit_at<F, Fut, T>(&self, id: i64, generation: Generation, f: F) -> Option<T>
538 where
539 F: FnOnce() -> Fut,
540 Fut: Future<Output = T>,
541 {
542 let lock = self.commit_lock(id);
543 let _guard = lock.lock().await;
544 let current = self
545 .providers
546 .read()
547 .await
548 .get(&id)
549 .map(|slot| slot.generation);
550 if current != Some(generation) {
551 return None;
552 }
553
554 Some(f().await)
555 }
556
557 pub async fn publish_summary(&self, id: i64, generation: Generation) {
558 self.commit_at(id, generation, || self.emit_status(id))
559 .await;
560 }
561
562 pub(crate) async fn emit_status(&self, id: i64) {
563 match self.summary_of(id).await {
564 Ok(provider) => {
565 self.event_bus
566 .emit(CoreEvent::ProviderStatusUpdated { provider })
567 .await;
568 }
569 Err(e) => {
570 tracing::warn!(provider_id = id, error = %e, "Failed to build provider summary");
571 }
572 }
573 }
574
575 async fn summary_of(&self, id: i64) -> DomainResult<ProviderSummary> {
576 let config = self.repository.get_provider(id).await?;
577
578 self.build_summary(id, &config).await
579 }
580
581 async fn build_summary(
582 &self, id: i64, config: &ProviderConfig,
583 ) -> DomainResult<ProviderSummary> {
584 let cached_pipelines = self.repository.get_cached_pipelines(Some(id)).await?;
585 let fetch = match self.repository.get_provider_fetch_status(id).await {
586 Ok(fetch) => fetch,
587 Err(e) => {
588 tracing::warn!(provider_id = id, error = %e, "Failed to read provider fetch status");
589 ProviderFetchStatus {
590 status: FETCH_STATUS_NEVER.to_string(),
591 error: None,
592 fetched_at: None,
593 succeeded_at: None,
594 }
595 }
596 };
597 let last_fetch_status = match fetch.status.as_str() {
598 FETCH_STATUS_SUCCESS => FetchStatus::Success,
599 FETCH_STATUS_ERROR => FetchStatus::Error,
600 _ => FetchStatus::Never,
601 };
602 let now = Instant::now();
603 let wall = Utc::now();
604 let view = self.scheduler.view(id, now);
605 let generation = self
606 .providers
607 .read()
608 .await
609 .get(&id)
610 .map(|slot| slot.generation);
611 let snapshot = self.health.snapshot();
612 let state = snapshot
613 .providers
614 .get(&id)
615 .filter(|state| Some(state.generation) == generation);
616 let paused_until = self.scheduler.paused_until(id, wall);
617 let level = state.map_or(HealthLevel::Operational, |state| state.level);
618 let freshness = ProviderFreshness::compute(
619 &last_fetch_status,
620 level,
621 fetch.fetched_at,
622 paused_until,
623 config.refresh_interval,
624 wall,
625 );
626
627 let last_fetch_error_kind = view.as_ref().and_then(|view| view.last_failure);
628 let recovery = ProviderRecovery::for_failure(
629 matches!(last_fetch_status, FetchStatus::Error)
630 .then_some(last_fetch_error_kind)
631 .flatten(),
632 SignInCredential::decode(&config.token).is_some(),
633 );
634
635 Ok(ProviderSummary {
636 id,
637 name: config.display_name().to_string(),
638 provider_type: config.provider_type.clone(),
639 icon: self
640 .plugin_registry
641 .get(&config.provider_type)
642 .and_then(|plugin| plugin.metadata().icon.clone()),
643 pipeline_count: cached_pipelines.len(),
644 last_updated: cached_pipelines.iter().map(|p| p.last_updated).max(),
645 refresh_interval: config.refresh_interval,
646 configured_repositories: configured_repositories(config),
647 last_fetch_status,
648 last_fetch_error: fetch.error,
649 last_fetch_at: fetch.fetched_at,
650 last_success_at: fetch.succeeded_at,
651 paused_until,
652 last_fetch_error_kind,
653 health: ProviderHealth {
654 level,
655 signals: state.map(|state| state.signals.clone()).unwrap_or_default(),
656 next_attempt_at: view
657 .as_ref()
658 .filter(|view| view.failures > 0)
659 .and_then(|_| {
660 let floor = state
661 .filter(|state| state.outage_floor)
662 .map(|_| self.scheduler.policy().outage_floor);
663 self.scheduler.next_due_in(id, now, floor)
664 })
665 .and_then(|wait| chrono::Duration::from_std(wait).ok())
666 .map(|wait| wall + wait),
667 in_flight: view.as_ref().is_some_and(|view| view.in_flight),
668 status_page: state.and_then(|state| state.status_page.clone()),
669 },
670 version: config.version.unwrap_or(1),
671 freshness,
672 recovery,
673 })
674 }
675
676 fn health_input(&self, config: &ProviderConfig, lease: &ProviderLease) -> ProviderHealthInput {
677 ProviderHealthInput {
678 id: lease.id,
679 generation: Some(lease.generation),
680 display_name: config.display_name().to_string(),
681 source: lease.provider.status_source().cloned(),
682 has_instance_checks: controller_health(&self.plugin_registry, &config.provider_type),
683 }
684 }
685
686 fn queue_fetch(&self, lease: &ProviderLease) {
687 if let ForcedClaim::Claimed(claim) =
688 self.scheduler
689 .claim_forced(lease.id, lease.generation, Instant::now())
690 {
691 self.dispatcher.fetch_now(vec![claim]);
692 }
693 }
694
695 pub async fn add_provider(&self, mut config: ProviderConfig) -> DomainResult<i64> {
696 let sign_in = self.take_sign_in(&mut config, None).await?;
697 let plugin = self
698 .init_plugin(&config.provider_type, config.plugin_config(), 0)
699 .await?;
700
701 let (validation_result, permission_status) =
702 tokio::join!(plugin.validate_credentials(), plugin.check_permissions());
703
704 validation_result.map_err(|e| {
705 DomainError::InvalidConfig(format!("Credential validation failed: {e}"))
706 })?;
707
708 let provider_type = plugin.provider_type();
709 if provider_type != config.provider_type {
710 return Err(DomainError::InvalidConfig(format!(
711 "Provider type mismatch: expected '{}', got '{}'",
712 config.provider_type, provider_type
713 )));
714 }
715
716 keep_selection_with_ci(plugin.as_ref(), &mut config).await?;
717
718 let permission_status = permission_status.ok();
719
720 let id = self.repository.add_provider(&config).await?;
721
722 if let Some(status) = &permission_status {
723 if let Err(e) = self.repository.store_provider_permissions(id, status).await {
724 tracing::warn!(provider_id = id, error = %e, "Failed to store permissions");
725 }
726 }
727
728 let mut config_with_id = config.clone();
729 config_with_id.id = Some(id);
730 let provider = (self.provider_factory)(&config_with_id)?;
731
732 let lock = self.commit_lock(id);
733 let _guard = lock.lock().await;
734 let lease = self.install(id, provider, config.refresh_interval).await;
735 self.health.send(HealthInput::ProvidersChanged {
736 changed: vec![self.health_input(&config_with_id, &lease)],
737 });
738 let provider = self.summary_of(id).await?;
739 self.event_bus
740 .emit(CoreEvent::ProviderAdded {
741 provider,
742 timestamp: Utc::now().timestamp_millis(),
743 })
744 .await;
745 if let TakenSignIn::Flow(flow) = sign_in {
746 self.sign_ins.forget(&flow);
747 }
748
749 Ok(id)
750 }
751
752 pub async fn export_providers_toml(&self) -> DomainResult<String> {
753 let providers = self.repository.config_backend().list_providers().await?;
754
755 ProviderSyncService::export_toml(&providers)
756 }
757
758 pub async fn import_providers_toml(&self, content: &str) -> DomainResult<ProviderImportResult> {
759 let file = ConfigLoader::parse_raw(content).map_err(|e| {
760 DomainError::InvalidConfig(format!("Couldn't read the providers file: {e}"))
761 })?;
762 if file.providers.is_empty() {
763 return Err(DomainError::InvalidConfig(
764 "The file has no [providers] entries".to_string(),
765 ));
766 }
767 if let Some((key, provider)) = file
768 .providers
769 .iter()
770 .find(|(_, provider)| !self.plugin_registry.is_registered(&provider.provider_type))
771 {
772 return Err(DomainError::InvalidProviderType(format!(
773 "Provider '{key}' has an unknown type '{}'",
774 provider.provider_type
775 )));
776 }
777
778 let existing: HashSet<String> = self
779 .repository
780 .config_backend()
781 .list_providers()
782 .await?
783 .into_iter()
784 .map(|provider| provider.name)
785 .collect();
786 let mut result = ProviderImportResult::default();
787 let mut failure = None;
788
789 for (key, provider) in &file.providers {
790 let name = provider.display_name(key).to_string();
791 if existing.contains(key) {
792 result.skipped.push(name);
793 continue;
794 }
795
796 let config = ProviderSyncService::provider_config(key, provider, String::new());
797 match self.repository.add_provider(&config).await {
798 Ok(_) => result.added.push(name),
799 Err(e) => {
800 let added_elsewhere = matches!(e, DomainError::InvalidConfig(_))
801 && self
802 .repository
803 .config_backend()
804 .list_providers()
805 .await
806 .is_ok_and(|providers| providers.iter().any(|p| p.name == *key));
807 if !added_elsewhere {
808 failure = Some(e);
809 break;
810 }
811 result.skipped.push(name);
812 }
813 }
814 }
815
816 if !result.added.is_empty() {
817 self.event_bus.emit(CoreEvent::ProvidersChanged).await;
818 }
819
820 failure.map_or(Ok(result), Err)
821 }
822
823 pub async fn apply_config_file(&self, config_state: &ConfigState) -> DomainResult<SyncResult> {
824 let _apply = self.config_apply.lock().await;
825 let PipedashConfig {
826 providers: file,
827 views,
828 ..
829 } = config_state.load_file()?;
830 let current = config_state.get().await;
831 let changed: Vec<(String, ProviderFileConfig)> = file
832 .iter()
833 .filter(|(key, provider)| current.providers.get(*key) != Some(*provider))
834 .map(|(key, provider)| (key.clone(), provider.clone()))
835 .collect();
836
837 for (key, provider) in &changed {
838 if !self.plugin_registry.is_registered(&provider.provider_type) {
839 return Err(DomainError::InvalidProviderType(format!(
840 "Provider '{key}' has an unknown type '{}'",
841 provider.provider_type
842 )));
843 }
844 (self.provider_factory)(&ProviderSyncService::provider_config(
845 key,
846 provider,
847 provider.token.clone(),
848 ))?;
849 }
850
851 let mut result = SyncResult::default();
852 if !changed.is_empty() {
853 let existing = self.repository.config_backend().list_providers().await?;
854 let token_store = config_state.token_store();
855 for (key, provider) in &changed {
856 let id = existing.iter().find(|p| p.name == *key).and_then(|p| p.id);
857 self.apply_config_provider(key, provider, id, token_store.as_ref())
858 .await?;
859 config_state.remember_provider(key, provider).await;
860 match id {
861 Some(_) => result.updated.push(key.clone()),
862 None => result.added.push(key.clone()),
863 }
864 }
865
866 tracing::info!(
867 added = result.added.len(),
868 updated = result.updated.len(),
869 "Applied provider changes from config file"
870 );
871 self.event_bus.emit(CoreEvent::ProvidersChanged).await;
872 }
873 if current.views != views {
874 config_state.remember_views(views).await;
875 self.event_bus.emit(CoreEvent::PipelineViewsChanged).await;
876 }
877 config_state.remember_providers(file).await;
878
879 Ok(result)
880 }
881
882 pub async fn update_general_settings(
883 &self, config_state: &ConfigState, update: GeneralSettingsUpdate,
884 ) -> DomainResult<()> {
885 let proxy_url = update.proxy_url.clone();
886 config_state.update_general_settings(update).await?;
887 if self.http_client_manager.set_proxy(&proxy_url)? {
888 self.rebuild_loaded_providers().await;
889 }
890
891 Ok(())
892 }
893
894 async fn rebuild_loaded_providers(&self) {
895 let ids: Vec<i64> = self.providers.read().await.keys().copied().collect();
896 let mut changed = Vec::new();
897
898 for id in ids {
899 let lock = self.commit_lock(id);
900 let _guard = lock.lock().await;
901 if !self.providers.read().await.contains_key(&id) {
902 continue;
903 }
904
905 let rebuilt = match self.repository.get_provider(id).await {
906 Ok(config) => (self.provider_factory)(&config).map(|provider| (config, provider)),
907 Err(e) => Err(e),
908 };
909 match rebuilt {
910 Ok((config, provider)) => {
911 let lease = self.replace(id, provider, config.refresh_interval).await;
912 changed.push(self.health_input(&config, &lease));
913 }
914 Err(e) => {
915 tracing::warn!(provider_id = id, error = %e, "Failed to rebuild provider");
916 }
917 }
918 }
919
920 if !changed.is_empty() {
921 self.health.send(HealthInput::ProvidersChanged { changed });
922 }
923 }
924
925 async fn apply_config_provider(
926 &self, key: &str, provider: &ProviderFileConfig, existing: Option<i64>,
927 token_store: &dyn TokenStore,
928 ) -> DomainResult<()> {
929 let sync = ProviderSyncService::new();
930 let backend = self.repository.config_backend().as_ref();
931 let id = match existing {
932 Some(id) => id,
933 None => sync.create_from_toml(key, provider, backend).await?,
934 };
935
936 let lock = self.commit_lock(id);
937 let guard = lock.lock().await;
938 if existing.is_some() {
939 let row = backend
940 .get_provider(id)
941 .await?
942 .ok_or_else(|| DomainError::ProviderNotFound(id.to_string()))?;
943 sync.update_from_toml(key, provider, &row, backend).await?;
944 }
945 ProviderSyncService::store_resolved_token(
946 id,
947 &provider.token,
948 existing.is_none(),
949 token_store,
950 )
951 .await?;
952
953 let config = self.repository.get_provider(id).await?;
954 let instance = (self.provider_factory)(&config)?;
955 if let Err(e) = self.repository.reset_provider_fetch_status(id).await {
956 tracing::warn!(provider_id = id, error = %e, "Failed to reset fetch status");
957 }
958 let lease = self.replace(id, instance, config.refresh_interval).await;
959 self.purge_run_logs(id).await;
960 self.health.send(HealthInput::ProvidersChanged {
961 changed: vec![self.health_input(&config, &lease)],
962 });
963 drop(guard);
964
965 self.queue_fetch(&lease);
966
967 Ok(())
968 }
969
970 pub async fn get_provider_config(&self, id: i64) -> DomainResult<ProviderConfig> {
971 self.repository.get_provider(id).await
972 }
973
974 pub async fn list_providers(&self) -> DomainResult<Vec<ProviderSummary>> {
975 let configs = self.repository.list_providers().await?;
976 let mut summaries = Vec::with_capacity(configs.len());
977
978 for config in configs {
979 if let Some(id) = config.id {
980 summaries.push(self.build_summary(id, &config).await?);
981 }
982 }
983
984 Ok(summaries)
985 }
986
987 pub async fn update_provider(&self, id: i64, mut config: ProviderConfig) -> DomainResult<()> {
988 let saved_name = self.repository.get_provider(id).await?.name;
989 if self
990 .config_state
991 .get()
992 .await
993 .providers
994 .contains_key(&saved_name)
995 {
996 let display_name = std::mem::replace(&mut config.name, saved_name);
997 config
998 .config
999 .insert("display_name".to_string(), display_name);
1000 }
1001 let sign_in = self.take_sign_in(&mut config, Some(id)).await?;
1002 let plugin = self
1003 .init_plugin(&config.provider_type, config.plugin_config(), id)
1004 .await?;
1005
1006 plugin.validate_credentials().await.map_err(|e| {
1007 DomainError::InvalidConfig(format!("Credential validation failed: {e}"))
1008 })?;
1009
1010 keep_selection_with_ci(plugin.as_ref(), &mut config).await?;
1011
1012 let permission_status = plugin.check_permissions().await.ok();
1013
1014 let lock = self.commit_lock(id);
1015 let guard = lock.lock().await;
1016
1017 let current = self.repository.get_provider(id).await?;
1018 if matches!(sign_in, TakenSignIn::Kept) {
1019 config.token.clone_from(¤t.token);
1020 }
1021 let mut config_with_id = config.clone();
1022 config_with_id.id = Some(id);
1023 let new_provider = (self.provider_factory)(&config_with_id)?;
1024 let success = self
1025 .repository
1026 .update_provider_with_version(id, &config, current.version.unwrap_or(1))
1027 .await?;
1028
1029 if !success {
1030 return Err(DomainError::ConcurrentModification(format!(
1031 "Provider {} was modified by another client",
1032 id
1033 )));
1034 }
1035
1036 if let Err(e) = self.repository.reset_provider_fetch_status(id).await {
1037 tracing::warn!(provider_id = id, error = %e, "Failed to reset fetch status");
1038 }
1039 let lease = self
1040 .replace(id, new_provider, config.refresh_interval)
1041 .await;
1042 self.purge_run_logs(id).await;
1043 self.health.send(HealthInput::ProvidersChanged {
1044 changed: vec![self.health_input(&config_with_id, &lease)],
1045 });
1046 match self.summary_of(id).await {
1047 Ok(provider) => {
1048 self.event_bus
1049 .emit(CoreEvent::ProviderUpdated {
1050 provider,
1051 timestamp: Utc::now().timestamp_millis(),
1052 })
1053 .await;
1054 }
1055 Err(e) => {
1056 tracing::warn!(provider_id = id, error = %e, "Failed to build updated provider summary");
1057 self.event_bus.emit(CoreEvent::ProvidersChanged).await;
1058 }
1059 }
1060 drop(guard);
1061
1062 if let Some(status) = &permission_status {
1063 if let Err(e) = self.repository.store_provider_permissions(id, status).await {
1064 tracing::warn!(provider_id = id, error = %e, "Failed to store permissions");
1065 }
1066 }
1067
1068 self.queue_fetch(&lease);
1069 if let TakenSignIn::Flow(flow) = sign_in {
1070 self.sign_ins.forget(&flow);
1071 }
1072
1073 Ok(())
1074 }
1075
1076 pub async fn update_provider_refresh_interval(
1077 &self, id: i64, refresh_interval: i64,
1078 ) -> DomainResult<()> {
1079 let lock = self.commit_lock(id);
1080 let _guard = lock.lock().await;
1081
1082 let mut config = self.repository.get_provider(id).await?;
1083 config.refresh_interval = refresh_interval;
1084 self.repository.update_provider(id, &config).await?;
1085
1086 let generation = self
1087 .providers
1088 .read()
1089 .await
1090 .get(&id)
1091 .map(|slot| slot.generation);
1092 if let Some(generation) = generation {
1093 self.scheduler
1094 .set_interval(id, generation, interval_of(refresh_interval));
1095 }
1096
1097 Ok(())
1098 }
1099
1100 pub async fn remove_provider(&self, id: i64) -> DomainResult<()> {
1101 let _apply = self.config_apply.lock().await;
1102 let lock = self.commit_lock(id);
1103 let _guard = lock.lock().await;
1104
1105 let config = self.repository.get_provider(id).await?;
1106 let provider = self.build_summary(id, &config).await?;
1107
1108 self.repository
1109 .remove_provider(id, &self.config_state)
1110 .await?;
1111 self.providers.write().await.remove(&id);
1112 self.purge_run_logs(id).await;
1113 self.scheduler.remove(id);
1114 self.health.send(HealthInput::ProvidersChanged {
1115 changed: vec![ProviderHealthInput {
1116 id,
1117 generation: None,
1118 display_name: config.display_name().to_string(),
1119 source: None,
1120 has_instance_checks: false,
1121 }],
1122 });
1123 self.event_bus
1124 .emit(CoreEvent::ProviderRemoved {
1125 provider,
1126 timestamp: Utc::now().timestamp_millis(),
1127 })
1128 .await;
1129
1130 Ok(())
1131 }
1132
1133 pub async fn get_provider(&self, id: i64) -> DomainResult<Arc<dyn Provider>> {
1134 Ok(self.acquire(id).await?.provider)
1135 }
1136
1137 pub async fn check_quota(&self, lease: &ProviderLease) -> Option<DateTime<Utc>> {
1138 let rate_limit =
1139 match timeout(Duration::from_secs(10), lease.provider.fetch_rate_limit()).await {
1140 Ok(Ok(rate_limit)) => rate_limit,
1141 Ok(Err(e)) => {
1142 tracing::debug!(provider_id = lease.id, error = %e, "Rate limit check failed");
1143 None
1144 }
1145 Err(_) => {
1146 tracing::debug!(provider_id = lease.id, "Rate limit check timed out");
1147 None
1148 }
1149 };
1150 let paused_until = rate_limit.and_then(|rate_limit| quota_pause(&rate_limit, Utc::now()));
1151
1152 self.commit(lease, || async {
1153 let changed = self.scheduler.set_quota_pause(
1154 lease.id,
1155 lease.generation,
1156 paused_until,
1157 Instant::now(),
1158 Utc::now(),
1159 );
1160 if let (true, Some(until)) = (changed, paused_until) {
1161 tracing::info!(provider_id = lease.id, %until, "API quota almost used up, pausing provider");
1162 }
1163 if changed {
1164 self.emit_status(lease.id).await;
1165 }
1166
1167 paused_until
1168 })
1169 .await
1170 .flatten()
1171 }
1172
1173 pub async fn get_provider_instance(&self, id: i64) -> DomainResult<Arc<dyn Provider>> {
1174 self.get_provider(id).await
1175 }
1176
1177 pub async fn load_missing_providers(&self) {
1178 let ids = match self.repository.list_provider_ids().await {
1179 Ok(ids) => ids,
1180 Err(e) => {
1181 tracing::warn!(error = %e, "Failed to list providers");
1182 return;
1183 }
1184 };
1185 let loaded: Vec<i64> = self.providers.read().await.keys().copied().collect();
1186 for id in ids.into_iter().filter(|id| !loaded.contains(id)) {
1187 if let Err(e) = self.acquire(id).await {
1188 tracing::debug!(provider_id = id, error = %e, "Provider not loaded yet");
1189 }
1190 }
1191 }
1192
1193 pub async fn load_all_providers(&self) -> DomainResult<()> {
1194 let configs = self.repository.list_providers().await?;
1195 let mut changed = Vec::new();
1196
1197 for config in configs {
1198 let Some(id) = config.id else {
1199 continue;
1200 };
1201 if config.token.is_empty() {
1202 tracing::debug!(
1203 provider_id = id,
1204 provider_type = %config.provider_type,
1205 "Skipping provider load - token not available yet (vault initializing in background)"
1206 );
1207 continue;
1208 }
1209
1210 match (self.provider_factory)(&config) {
1211 Ok(provider) => {
1212 let lock = self.commit_lock(id);
1213 let _guard = lock.lock().await;
1214 let lease = self.replace(id, provider, config.refresh_interval).await;
1215 changed.push(self.health_input(&config, &lease));
1216 }
1217 Err(e) => {
1218 tracing::warn!(provider_id = id, error = %e, "Failed to load provider");
1219 }
1220 }
1221 }
1222
1223 if !changed.is_empty() {
1224 self.health.send(HealthInput::ProvidersChanged { changed });
1225 }
1226
1227 Ok(())
1228 }
1229
1230 pub async fn get_workflow_parameters(
1231 &self, provider_id: i64, workflow_id: &str,
1232 ) -> DomainResult<Vec<pipedash_plugin_api::WorkflowParameter>> {
1233 if let Some(cached) = self.cached_parameters(workflow_id).await {
1234 return Ok(cached);
1235 }
1236
1237 let lease = self.acquire(provider_id).await?;
1238 let fetch = ParameterFetch::new(&self.parameter_fetches, workflow_id);
1239 let _guard = fetch.lock.lock().await;
1240
1241 if let Some(cached) = self.cached_parameters(workflow_id).await {
1242 return Ok(cached);
1243 }
1244
1245 let parameters = lease.provider.get_workflow_parameters(workflow_id).await?;
1246
1247 let cached = self
1248 .commit(&lease, || {
1249 self.repository
1250 .cache_workflow_parameters(workflow_id, ¶meters)
1251 })
1252 .await;
1253 if let Some(Err(e)) = cached {
1254 tracing::warn!(provider_id, workflow_id, error = %e, "Failed to cache workflow parameters");
1255 }
1256
1257 Ok(parameters)
1258 }
1259
1260 async fn cached_parameters(
1261 &self, workflow_id: &str,
1262 ) -> Option<Vec<pipedash_plugin_api::WorkflowParameter>> {
1263 match self
1264 .repository
1265 .get_cached_workflow_parameters(workflow_id)
1266 .await
1267 {
1268 Ok(cached) => cached,
1269 Err(e) => {
1270 tracing::warn!(workflow_id, error = %e, "Failed to read cached workflow parameters");
1271 None
1272 }
1273 }
1274 }
1275
1276 pub async fn get_provider_permissions(
1277 &self, provider_id: i64,
1278 ) -> DomainResult<Option<pipedash_plugin_api::PermissionStatus>> {
1279 self.repository.get_provider_permissions(provider_id).await
1280 }
1281
1282 pub async fn recheck_provider_permissions(
1283 &self, provider_id: i64,
1284 ) -> DomainResult<pipedash_plugin_api::PermissionStatus> {
1285 let plugin = self.stored_plugin(provider_id).await?;
1286
1287 let status = plugin
1288 .check_permissions()
1289 .await
1290 .map_err(|e| DomainError::InvalidConfig(format!("Failed to check permissions: {e}")))?;
1291
1292 self.repository
1293 .store_provider_permissions(provider_id, &status)
1294 .await?;
1295
1296 Ok(status)
1297 }
1298}
1299
1300fn interval_of(seconds: i64) -> Duration {
1301 Duration::from_secs(u64::try_from(seconds).unwrap_or(0))
1302}
1303
1304fn build_plugin(
1305 http: &HttpClientManager, provider_type: &str, config: &HashMap<String, String>,
1306) -> DomainResult<(Box<dyn PluginTrait>, Arc<reqwest::Client>)> {
1307 let plugin = plugins::create_plugin(provider_type).ok_or_else(|| {
1308 DomainError::InvalidProviderType(format!("Unknown provider type: {}", provider_type))
1309 })?;
1310 let client = http.client_for(&plugin.client_options(config))?;
1311
1312 Ok((plugin, client))
1313}
1314
1315async fn keep_selection_with_ci(
1316 plugin: &dyn PluginTrait, config: &mut ProviderConfig,
1317) -> DomainResult<()> {
1318 let Some(selected) = config.config.get("selected_items") else {
1319 return Ok(());
1320 };
1321 let ids: Vec<String> = selected
1322 .split(',')
1323 .map(str::trim)
1324 .filter(|id| !id.is_empty())
1325 .map(str::to_owned)
1326 .collect();
1327 if ids.is_empty() {
1328 return Ok(());
1329 }
1330 let Ok(kept) = plugin.ids_with_ci(ids.clone()).await else {
1331 return Ok(());
1332 };
1333 if kept.is_empty() {
1334 return Err(DomainError::InvalidConfig(
1335 "None of the selected repositories has a CI file or a pipeline run yet".to_string(),
1336 ));
1337 }
1338 if kept.len() < ids.len() {
1339 tracing::info!(
1340 selected = ids.len(),
1341 kept = kept.len(),
1342 "Skipped selected repositories without a CI file or a pipeline run"
1343 );
1344 config
1345 .config
1346 .insert("selected_items".to_string(), kept.join(","));
1347 }
1348 Ok(())
1349}
1350
1351fn init_plugin(
1352 http: &HttpClientManager, provider_type: &str, config: HashMap<String, String>,
1353 provider_id: i64,
1354) -> DomainResult<Box<dyn PluginTrait>> {
1355 let (mut plugin, client) = build_plugin(http, provider_type, &config)?;
1356 plugin
1357 .initialize(provider_id, config, Some(client))
1358 .map_err(|e| DomainError::InvalidConfig(format!("Failed to initialize plugin: {e}")))?;
1359
1360 Ok(plugin)
1361}
1362
1363pub(crate) fn controller_health(registry: &PluginRegistry, provider_type: &str) -> bool {
1364 registry.get(provider_type).is_some_and(|plugin| {
1365 plugin
1366 .metadata()
1367 .features
1368 .iter()
1369 .any(|feature| feature.id == CONTROLLER_HEALTH_FEATURE)
1370 })
1371}
1372
1373pub(crate) fn plugin_provider(
1374 registry: &PluginRegistry, http: &HttpClientManager, config: &ProviderConfig,
1375) -> DomainResult<Arc<dyn Provider>> {
1376 if !registry.is_registered(&config.provider_type) {
1377 return Err(DomainError::InvalidProviderType(format!(
1378 "Plugin not found for provider type: {}",
1379 config.provider_type
1380 )));
1381 }
1382
1383 let provider_id = config.id.unwrap_or(0);
1384 let (token, renew_at) = match SignInCredential::decode(&config.token) {
1385 Some(credential) => {
1386 let renew_at = credential.renew_at();
1387 (credential.token, renew_at)
1388 }
1389 None => (resolve_token_reference(provider_id, &config.token), None),
1390 };
1391 let mut plugin_config = config.config.clone();
1392 plugin_config.insert("token".to_string(), token);
1393
1394 let plugin = init_plugin(http, &config.provider_type, plugin_config, provider_id)?;
1395
1396 Ok(Arc::new(PluginAdapter::new(
1397 plugin,
1398 config.provider_type.clone(),
1399 provider_id,
1400 renew_at,
1401 )))
1402}
1403
1404fn resolve_token_reference(provider_id: i64, token: &str) -> String {
1405 if token.is_empty() {
1406 return String::new();
1407 }
1408 match TokenReference::parse(token) {
1409 Ok(TokenReference::EnvVar(var_name)) => std::env::var(&var_name).unwrap_or_else(|_| {
1410 tracing::warn!(
1411 provider_id = provider_id,
1412 var_name = %var_name,
1413 "Environment variable not found, using empty token"
1414 );
1415 String::new()
1416 }),
1417 Ok(TokenReference::None) => String::new(),
1418 Ok(_) | Err(_) => token.to_string(),
1419 }
1420}
1421
1422fn configured_repositories(config: &ProviderConfig) -> Vec<String> {
1423 config
1424 .config
1425 .get("selected_items")
1426 .map(|items| {
1427 if matches!(
1428 config.provider_type.as_str(),
1429 "argocd" | "fluxcd" | "komodo" | "prow"
1430 ) {
1431 return Vec::new();
1432 }
1433
1434 items
1435 .split(',')
1436 .map(|s| s.trim().to_string())
1437 .filter(|s| !s.is_empty())
1438 .map(|item| {
1439 if config.provider_type == "tekton" && item.contains("__") {
1440 item.replace("__", "/")
1441 } else if config.provider_type == "jenkins" && !item.contains('/') {
1442 format!("(root)/{}", item)
1443 } else {
1444 item
1445 }
1446 })
1447 .collect()
1448 })
1449 .unwrap_or_default()
1450}
1451
1452pub(crate) fn quota_pause(rate_limit: &RateLimit, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
1453 let low = rate_limit.remaining * 100 < rate_limit.limit * QUOTA_PAUSE_PERCENT;
1454
1455 (low && rate_limit.reset_at > now).then_some(rate_limit.reset_at)
1456}
1457
1458#[cfg(test)]
1459mod tests {
1460 use std::io::{
1461 Read,
1462 Write,
1463 };
1464
1465 use pipedash_plugin_api::PluginError;
1466
1467 use super::*;
1468 use crate::application::test_support::{
1469 pipeline,
1470 FeedStub,
1471 Gate,
1472 Harness,
1473 StubProvider,
1474 };
1475 use crate::application::FetchResult;
1476 use crate::domain::PipelineStatus;
1477 use crate::infrastructure::config::ConfigLoader;
1478 use crate::infrastructure::oauth::test_idp::Idp;
1479 use crate::test_env::EnvGuard;
1480
1481 const GITHUB_SECRET: &str = "ghp_exampleSECRET0123456789";
1482 const GITLAB_SECRET: &str = "glpat-SECRET-VALUE";
1483 const JENKINS_DEFAULT_SECRET: &str = "hunter2-default";
1484
1485 fn provider(
1486 name: &str, provider_type: &str, token: &str, refresh_interval: i64,
1487 config: &[(&str, &str)],
1488 ) -> ProviderConfig {
1489 ProviderConfig {
1490 id: None,
1491 name: name.to_string(),
1492 provider_type: provider_type.to_string(),
1493 token: token.to_string(),
1494 config: config
1495 .iter()
1496 .map(|(k, v)| (k.to_string(), v.to_string()))
1497 .collect(),
1498 refresh_interval,
1499 version: None,
1500 }
1501 }
1502
1503 struct CiCheck(Option<&'static [&'static str]>);
1504
1505 #[async_trait::async_trait]
1506 impl PluginTrait for CiCheck {
1507 fn metadata(&self) -> &pipedash_plugin_api::PluginMetadata {
1508 unimplemented!()
1509 }
1510
1511 fn initialize(
1512 &mut self, _: i64, _: HashMap<String, String>, _: Option<Arc<reqwest::Client>>,
1513 ) -> pipedash_plugin_api::PluginResult<()> {
1514 Ok(())
1515 }
1516
1517 async fn validate_credentials(&self) -> pipedash_plugin_api::PluginResult<bool> {
1518 Ok(true)
1519 }
1520
1521 async fn fetch_pipelines(
1522 &self,
1523 ) -> pipedash_plugin_api::PluginResult<Vec<pipedash_plugin_api::Pipeline>> {
1524 Ok(Vec::new())
1525 }
1526
1527 async fn fetch_run_history(
1528 &self, _: &str, _: usize,
1529 ) -> pipedash_plugin_api::PluginResult<Vec<pipedash_plugin_api::PipelineRun>> {
1530 Ok(Vec::new())
1531 }
1532
1533 async fn fetch_run_details(
1534 &self, _: &str, _: i64,
1535 ) -> pipedash_plugin_api::PluginResult<pipedash_plugin_api::PipelineRun> {
1536 Err(PluginError::NotSupported(String::new()))
1537 }
1538
1539 async fn trigger_pipeline(
1540 &self, _: pipedash_plugin_api::TriggerParams,
1541 ) -> pipedash_plugin_api::PluginResult<String> {
1542 Err(PluginError::NotSupported(String::new()))
1543 }
1544
1545 async fn ids_with_ci(
1546 &self, _: Vec<String>,
1547 ) -> pipedash_plugin_api::PluginResult<Vec<String>> {
1548 self.0
1549 .map(|kept| kept.iter().map(|id| id.to_string()).collect())
1550 .ok_or_else(|| PluginError::NetworkError("reset".to_string()))
1551 }
1552 }
1553
1554 #[tokio::test]
1555 async fn a_saved_selection_keeps_only_repositories_with_ci() {
1556 let selected = &[("selected_items", "acme/api, acme/empty,acme/web")];
1557
1558 let mut config = provider("gh", "github", "t", 30, selected);
1559 keep_selection_with_ci(&CiCheck(Some(&["acme/api", "acme/web"])), &mut config)
1560 .await
1561 .unwrap();
1562 assert_eq!(config.config["selected_items"], "acme/api,acme/web");
1563
1564 let mut config = provider("gh", "github", "t", 30, selected);
1565 keep_selection_with_ci(&CiCheck(None), &mut config)
1566 .await
1567 .unwrap();
1568 assert_eq!(
1569 config.config["selected_items"],
1570 "acme/api, acme/empty,acme/web"
1571 );
1572
1573 let mut config = provider("gh", "github", "t", 30, selected);
1574 let error = keep_selection_with_ci(&CiCheck(Some(&[])), &mut config)
1575 .await
1576 .unwrap_err();
1577 assert!(matches!(error, DomainError::InvalidConfig(_)), "{error}");
1578 assert_eq!(
1579 config.config["selected_items"],
1580 "acme/api, acme/empty,acme/web"
1581 );
1582 }
1583
1584 #[tokio::test]
1585 async fn test_export_providers_toml_round_trips_without_tokens() {
1586 let source = Harness::with_real_plugins().await;
1587 let repository = source.repository.clone();
1588 let service = &source.provider_service;
1589
1590 let originals = [
1591 provider(
1592 "github-main",
1593 "github",
1594 GITHUB_SECRET,
1595 45,
1596 &[("selected_items", "acme/api,acme/web")],
1597 ),
1598 provider(
1599 "My Jenkins",
1600 "jenkins",
1601 &format!("${{JENKINS_API_TOKEN:-{JENKINS_DEFAULT_SECRET}}}"),
1602 120,
1603 &[
1604 ("display_name", "Prod Jenkins"),
1605 ("server_url", "http://127.0.0.1:9"),
1606 ("username", "ci"),
1607 ("selected_items", "(root)/deploy"),
1608 ],
1609 ),
1610 provider("gitlab_main", "gitlab", GITLAB_SECRET, 30, &[]),
1611 ];
1612 for original in &originals {
1613 repository.add_provider(original).await.unwrap();
1614 }
1615
1616 let exported = service.export_providers_toml().await.unwrap();
1617
1618 for secret in [GITHUB_SECRET, GITLAB_SECRET, JENKINS_DEFAULT_SECRET] {
1619 assert!(!exported.contains(secret), "export leaked {secret}");
1620 }
1621 assert!(exported.contains("token = \"${PIPEDASH_TOKEN_GITHUB_MAIN:-}\""));
1622 assert!(exported.contains("token = \"${JENKINS_API_TOKEN:-}\""));
1623 assert!(exported.contains("token = \"${PIPEDASH_TOKEN_GITLAB_MAIN:-}\""));
1624
1625 let _env = EnvGuard::set(&[
1626 ("PIPEDASH_TOKEN_GITHUB_MAIN", Some("env-github-token")),
1627 ("JENKINS_API_TOKEN", Some("env-jenkins-token")),
1628 ("PIPEDASH_TOKEN_GITLAB_MAIN", None),
1629 ]);
1630 let parsed = ConfigLoader::parse(&exported).unwrap();
1631
1632 let mut keys: Vec<_> = parsed.providers.keys().map(String::as_str).collect();
1633 keys.sort_unstable();
1634 assert_eq!(keys, ["My Jenkins", "github-main", "gitlab_main"]);
1635 assert_eq!(parsed.providers["github-main"].token, "env-github-token");
1636 assert_eq!(parsed.providers["My Jenkins"].token, "env-jenkins-token");
1637 assert_eq!(parsed.providers["gitlab_main"].token, "");
1638 assert_eq!(
1639 parsed.providers["My Jenkins"].name.as_deref(),
1640 Some("Prod Jenkins")
1641 );
1642
1643 let target_harness = Harness::with_real_plugins().await;
1644 let target = &target_harness.repository;
1645 crate::infrastructure::config::sync::ProviderSyncService::new()
1646 .sync_toml_to_db(
1647 parsed.providers,
1648 target.config_backend().as_ref(),
1649 &crate::infrastructure::MemoryTokenStore::new(),
1650 false,
1651 )
1652 .await
1653 .unwrap();
1654
1655 let imported = target.config_backend().list_providers().await.unwrap();
1656
1657 assert_eq!(imported.len(), originals.len());
1658 for original in &originals {
1659 let imported = imported.iter().find(|p| p.name == original.name).unwrap();
1660 assert_eq!(imported.name, original.name);
1661 assert_eq!(imported.provider_type, original.provider_type);
1662 assert_eq!(imported.refresh_interval, original.refresh_interval);
1663 assert_eq!(imported.config, original.config);
1664 }
1665 }
1666
1667 #[tokio::test]
1668 async fn test_import_providers_toml_adds_missing_providers_without_tokens() {
1669 let source = Harness::with_real_plugins().await;
1670 let jenkins = provider(
1671 "My Jenkins",
1672 "jenkins",
1673 "${JENKINS_API_TOKEN}",
1674 120,
1675 &[
1676 ("display_name", "Prod Jenkins"),
1677 ("server_url", "http://127.0.0.1:9"),
1678 ("username", "ci"),
1679 ("selected_items", "(root)/deploy"),
1680 ],
1681 );
1682 for original in [
1683 provider(
1684 "github-main",
1685 "github",
1686 GITHUB_SECRET,
1687 45,
1688 &[("selected_items", "acme/api")],
1689 ),
1690 jenkins.clone(),
1691 ] {
1692 source.repository.add_provider(&original).await.unwrap();
1693 }
1694 let exported = source
1695 .provider_service
1696 .export_providers_toml()
1697 .await
1698 .unwrap();
1699
1700 let target = Harness::with_real_plugins().await;
1701 let kept = provider(
1702 "github-main",
1703 "github",
1704 "target-token",
1705 300,
1706 &[("selected_items", "acme/web")],
1707 );
1708 let kept_id = target.repository.add_provider(&kept).await.unwrap();
1709
1710 let _env = EnvGuard::set(&[("JENKINS_API_TOKEN", Some("env-jenkins-token"))]);
1711 let result = target
1712 .provider_service
1713 .import_providers_toml(&exported)
1714 .await
1715 .unwrap();
1716
1717 assert_eq!(
1718 result,
1719 ProviderImportResult {
1720 added: vec!["Prod Jenkins".to_string()],
1721 skipped: vec!["github-main".to_string()],
1722 }
1723 );
1724 let unchanged = target.repository.get_provider(kept_id).await.unwrap();
1725 assert_eq!(unchanged.token, kept.token);
1726 assert_eq!(unchanged.refresh_interval, kept.refresh_interval);
1727 assert_eq!(unchanged.config, kept.config);
1728
1729 let imported = target.repository.list_providers().await.unwrap();
1730 let imported = imported.iter().find(|p| p.name == jenkins.name).unwrap();
1731 let row = target
1732 .repository
1733 .config_backend()
1734 .get_provider(imported.id.unwrap())
1735 .await
1736 .unwrap()
1737 .unwrap();
1738 assert_eq!(imported.token, "");
1739 assert_eq!(row.token, "");
1740 assert_eq!(imported.provider_type, jenkins.provider_type);
1741 assert_eq!(imported.refresh_interval, jenkins.refresh_interval);
1742 assert_eq!(imported.config, jenkins.config);
1743 assert_eq!(target.bus.count("providers-changed"), 1);
1744 }
1745
1746 #[tokio::test]
1747 async fn test_import_providers_toml_rejects_unknown_type_before_adding() {
1748 let h = Harness::with_real_plugins().await;
1749 let content = r#"
1750[providers.github-main]
1751type = "github"
1752
1753[providers.mystery]
1754type = "travis"
1755"#;
1756
1757 let err = h
1758 .provider_service
1759 .import_providers_toml(content)
1760 .await
1761 .unwrap_err();
1762
1763 assert!(matches!(err, DomainError::InvalidProviderType(_)), "{err}");
1764 assert!(h.repository.list_providers().await.unwrap().is_empty());
1765 assert_eq!(h.bus.count("providers-changed"), 0);
1766 }
1767
1768 #[test]
1769 fn quota_pause_starts_under_ten_percent_until_reset() {
1770 let now = Utc::now();
1771 let reset_at = now + chrono::Duration::minutes(20);
1772 let pause = |remaining, reset_at| {
1773 quota_pause(
1774 &RateLimit {
1775 limit: 5000,
1776 remaining,
1777 reset_at,
1778 },
1779 now,
1780 )
1781 };
1782
1783 assert_eq!(pause(500, reset_at), None);
1784 assert_eq!(pause(499, reset_at), Some(reset_at));
1785 assert_eq!(pause(0, reset_at), Some(reset_at));
1786 assert_eq!(pause(0, now), None);
1787 assert_eq!(
1788 quota_pause(
1789 &RateLimit {
1790 limit: 0,
1791 remaining: 0,
1792 reset_at,
1793 },
1794 now,
1795 ),
1796 None
1797 );
1798 }
1799
1800 type StubGate = Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>;
1801
1802 async fn stub_github(
1803 remaining: u64, reset: i64, gate: StubGate,
1804 ) -> (String, Arc<Mutex<Vec<String>>>) {
1805 use tokio::io::{
1806 AsyncReadExt,
1807 AsyncWriteExt,
1808 };
1809
1810 let _ = rustls::crypto::ring::default_provider().install_default();
1811 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1812 let addr = listener.local_addr().unwrap();
1813 let requests: Arc<Mutex<Vec<String>>> = Arc::default();
1814 let log = requests.clone();
1815 tokio::spawn(async move {
1816 loop {
1817 let (mut stream, _) = listener.accept().await.unwrap();
1818 let mut request = Vec::new();
1819 let mut buffer = [0; 4096];
1820 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1821 let count = stream.read(&mut buffer).await.unwrap();
1822 if count == 0 {
1823 break;
1824 }
1825 request.extend_from_slice(&buffer[..count]);
1826 }
1827 let request = String::from_utf8(request).unwrap();
1828 let path = request.split_whitespace().nth(1).unwrap_or_default();
1829 let path = path.split('?').next().unwrap().to_string();
1830 let (status, body) = if path == "/api/v3/rate_limit" {
1831 if let Some((arrived, release)) = &gate {
1832 arrived.notify_one();
1833 release.notified().await;
1834 }
1835 let rate = serde_json::json!({
1836 "limit": 5000, "used": 5000 - remaining, "remaining": remaining, "reset": reset
1837 });
1838 let search = serde_json::json!({"limit": 30, "used": 0, "remaining": 30, "reset": reset});
1839 let body = serde_json::json!({
1840 "resources": {"core": rate, "search": search},
1841 "rate": rate
1842 });
1843 ("200 OK", body.to_string())
1844 } else {
1845 ("404 Not Found", r#"{"message":"Not Found"}"#.to_string())
1846 };
1847 log.lock().await.push(path);
1848 let response = format!(
1849 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1850 body.len()
1851 );
1852 let _ = stream.write_all(response.as_bytes()).await;
1853 }
1854 });
1855
1856 (format!("http://{addr}"), requests)
1857 }
1858
1859 #[tokio::test]
1860 async fn refresh_skips_a_github_provider_with_its_quota_almost_used_up() {
1861 let h = Harness::with_real_plugins().await;
1862 h.start().await;
1863 let reset = Utc::now().timestamp() + 1200;
1864 let (base_url, requests) = stub_github(0, reset, None).await;
1865 let id = h
1866 .repository
1867 .add_provider(&provider(
1868 "github",
1869 "github",
1870 "ghp_stub",
1871 30,
1872 &[("base_url", &base_url), ("selected_items", "acme/app")],
1873 ))
1874 .await
1875 .unwrap();
1876
1877 let first = h
1878 .pipeline_service
1879 .fetch_now(&[id], Duration::from_secs(5))
1880 .await;
1881 let again = h
1882 .pipeline_service
1883 .fetch_now(&[id], Duration::from_secs(5))
1884 .await;
1885 let summaries = h.provider_service.list_providers().await.unwrap();
1886
1887 assert_eq!(*requests.lock().await, ["/api/v3/rate_limit"]);
1888 assert_eq!(
1889 summaries[0].paused_until,
1890 DateTime::from_timestamp(reset, 0)
1891 );
1892 assert!(matches!(summaries[0].last_fetch_status, FetchStatus::Never));
1893 assert_eq!(first[0].state, crate::domain::RefreshState::Recent);
1894 assert_eq!(again[0].state, crate::domain::RefreshState::Recent);
1895 assert_eq!(again[0].kind, Some(crate::domain::FailureKind::RateLimited));
1896 assert_eq!(*requests.lock().await, ["/api/v3/rate_limit"]);
1897 }
1898
1899 #[tokio::test]
1900 async fn refresh_fetches_a_github_provider_with_quota_left() {
1901 let h = Harness::with_real_plugins().await;
1902 h.start().await;
1903 let (base_url, requests) = stub_github(4000, Utc::now().timestamp() + 1200, None).await;
1904 let id = h
1905 .repository
1906 .add_provider(&provider(
1907 "github",
1908 "github",
1909 "ghp_stub",
1910 30,
1911 &[("base_url", &base_url), ("selected_items", "acme/app")],
1912 ))
1913 .await
1914 .unwrap();
1915
1916 h.pipeline_service
1917 .fetch_now(&[id], Duration::from_secs(5))
1918 .await;
1919 let summaries = h.provider_service.list_providers().await.unwrap();
1920 let requests = requests.lock().await;
1921
1922 assert_eq!(requests[0], "/api/v3/rate_limit");
1923 assert!(requests
1924 .iter()
1925 .any(|path| path.starts_with("/api/v3/repos/acme/app")));
1926 assert_eq!(summaries[0].paused_until, None);
1927 }
1928
1929 #[tokio::test]
1930 async fn quota_answer_for_replaced_credentials_does_not_pause_the_new_ones() {
1931 let h = Harness::with_real_plugins().await;
1932 let reset = Utc::now().timestamp() + 1200;
1933 let arrived = Arc::new(tokio::sync::Notify::new());
1934 let release = Arc::new(tokio::sync::Notify::new());
1935 let (base_url, _) = stub_github(0, reset, Some((arrived.clone(), release.clone()))).await;
1936 let id = h
1937 .repository
1938 .add_provider(&provider(
1939 "github",
1940 "github",
1941 "ghp_old",
1942 30,
1943 &[("base_url", &base_url), ("selected_items", "acme/app")],
1944 ))
1945 .await
1946 .unwrap();
1947 let service = Arc::clone(&h.provider_service);
1948 let lease = service.acquire(id).await.unwrap();
1949
1950 let check = tokio::spawn({
1951 let service = Arc::clone(&service);
1952 let lease = lease.clone();
1953 async move { service.check_quota(&lease).await }
1954 });
1955 arrived.notified().await;
1956 let replacement =
1957 (service.provider_factory)(&h.repository.get_provider(id).await.unwrap()).unwrap();
1958 service.replace(id, replacement, 30).await;
1959 release.notify_one();
1960
1961 assert_eq!(check.await.unwrap(), None);
1962 assert_eq!(h.scheduler.paused_until(id, Utc::now()), None);
1963 }
1964
1965 fn updated(h: &Harness, id: i64) -> ProviderConfig {
1966 let mut config = h.config("renamed", 45);
1967 config.id = Some(id);
1968 config
1969 }
1970
1971 #[tokio::test]
1972 async fn an_update_during_a_blocked_fetch_supersedes_the_old_attempt() {
1973 let h = Harness::new().await;
1974 h.start().await;
1975 let old_gate = Gate::new();
1976 let (id, old) = h
1977 .add_stub(|id| {
1978 StubProvider::new(id)
1979 .gated(&old_gate)
1980 .returning(vec![pipeline(id, "old", PipelineStatus::Success)])
1981 })
1982 .await;
1983 let new = h.stubs.queue(StubProvider::new(id).returning(vec![pipeline(
1984 id,
1985 "new",
1986 PipelineStatus::Failed,
1987 )]));
1988 let blocked = h.spawn_fetch(id).await;
1989 old_gate.arrived().await;
1990 let before = h.provider_service.acquire(id).await.unwrap().generation;
1991
1992 h.provider_service
1993 .update_provider(id, updated(&h, id))
1994 .await
1995 .unwrap();
1996 let updated_at = h
1997 .bus
1998 .names()
1999 .iter()
2000 .position(|name| *name == "provider-updated")
2001 .unwrap();
2002 h.bus
2003 .wait_for("the fetch queued by the update", |events| {
2004 events[updated_at..]
2005 .iter()
2006 .any(|event| matches!(event, CoreEvent::PipelinesUpdated { .. }))
2007 })
2008 .await;
2009 old_gate.open();
2010 let old_result = blocked.await.unwrap();
2011
2012 assert!(matches!(old_result, FetchResult::Superseded));
2013 assert_eq!(h.cached_names(id).await, ["new"]);
2014 let published: Vec<_> = h.bus.events()[updated_at..]
2015 .iter()
2016 .filter_map(|event| match event {
2017 CoreEvent::PipelinesUpdated { pipelines, .. } => {
2018 Some(pipelines.iter().map(|p| p.name.clone()).collect::<Vec<_>>())
2019 }
2020 _ => None,
2021 })
2022 .collect();
2023 assert_eq!(published, vec![vec!["new".to_string()]]);
2024 let view = h.scheduler.view(id, Instant::now()).unwrap();
2025 assert_eq!(
2026 (view.failures, view.last_failure, view.in_flight),
2027 (0, None, false)
2028 );
2029 assert_ne!(
2030 h.provider_service.acquire(id).await.unwrap().generation,
2031 before
2032 );
2033 assert_eq!((old.fetch_count(), new.fetch_count()), (1, 1));
2034 }
2035
2036 #[tokio::test]
2037 async fn an_update_resets_the_fetch_status_and_the_schedule() {
2038 let h = Harness::new().await;
2039 let (id, _) = h
2040 .add_stub(|id| StubProvider::new(id).failing(DomainError::NetworkError("down".into())))
2041 .await;
2042 h.fetch_directly(id).await;
2043 h.health_inputs();
2044 assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 1);
2045
2046 h.provider_service
2047 .update_provider(id, updated(&h, id))
2048 .await
2049 .unwrap();
2050 let inputs = h.health_inputs();
2051
2052 let event = h
2053 .bus
2054 .events()
2055 .into_iter()
2056 .find_map(|event| match event {
2057 CoreEvent::ProviderUpdated { provider, .. } => Some(provider),
2058 _ => None,
2059 })
2060 .unwrap();
2061 assert!(matches!(event.last_fetch_status, FetchStatus::Never));
2062 assert_eq!(event.last_fetch_error, None);
2063 assert_eq!(event.last_fetch_error_kind, None);
2064 assert_eq!(event.health.next_attempt_at, None);
2065 assert_eq!(event.name, "renamed");
2066 assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 0);
2067 let lease = h.provider_service.acquire(id).await.unwrap();
2068 assert!(matches!(
2069 inputs.as_slice(),
2070 [HealthInput::ProvidersChanged { changed }]
2071 if changed.len() == 1 && changed[0].generation == Some(lease.generation)
2072 ));
2073 }
2074
2075 #[tokio::test]
2076 async fn an_old_failure_finishing_after_an_update_leaves_the_new_schedule_clean() {
2077 let h = Harness::new().await;
2078 h.start().await;
2079 let old_gate = Gate::new();
2080 let new_gate = Gate::new();
2081 let (id, _) = h
2082 .add_stub(|id| {
2083 StubProvider::new(id)
2084 .gated(&old_gate)
2085 .failing(DomainError::NetworkError("down".into()))
2086 })
2087 .await;
2088 h.stubs.queue(StubProvider::new(id).gated(&new_gate));
2089 let blocked = h.spawn_fetch(id).await;
2090 old_gate.arrived().await;
2091
2092 h.provider_service
2093 .update_provider(id, updated(&h, id))
2094 .await
2095 .unwrap();
2096 new_gate.arrived().await;
2097 old_gate.open();
2098 let old_result = blocked.await.unwrap();
2099
2100 assert!(matches!(old_result, FetchResult::Superseded));
2101 let view = h.scheduler.view(id, Instant::now()).unwrap();
2102 assert_eq!(
2103 (view.failures, view.last_failure, view.in_flight),
2104 (0, None, true)
2105 );
2106 let fetch = h.repository.get_provider_fetch_status(id).await.unwrap();
2107 assert_eq!((fetch.status.as_str(), fetch.error), ("never", None));
2108 new_gate.open();
2109 h.bus
2110 .wait_for("the new fetch", |events| {
2111 events
2112 .iter()
2113 .any(|event| matches!(event, CoreEvent::PipelinesUpdated { .. }))
2114 })
2115 .await;
2116 assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 0);
2117 }
2118
2119 #[tokio::test]
2120 async fn a_removal_during_a_blocked_fetch_leaves_nothing_behind() {
2121 let h = Harness::new().await;
2122 let gate = Gate::new();
2123 let (id, _) = h.add_stub(|id| StubProvider::new(id).gated(&gate)).await;
2124 let blocked = h.spawn_fetch(id).await;
2125 gate.arrived().await;
2126 h.health_inputs();
2127
2128 h.provider_service.remove_provider(id).await.unwrap();
2129 let removed_at = h.bus.events().len();
2130 gate.open();
2131 let result = blocked.await.unwrap();
2132 let inputs = h.health_inputs();
2133
2134 assert!(matches!(result, FetchResult::Superseded));
2135 assert!(h.repository.get_provider(id).await.is_err());
2136 assert!(h.repository.get_provider_fetch_status(id).await.is_err());
2137 assert!(h.cached_names(id).await.is_empty());
2138 assert!(h.scheduler.view(id, Instant::now()).is_none());
2139 assert!(h.bus.names()[removed_at..].is_empty());
2140 assert_eq!(h.bus.names()[removed_at - 1], "provider-removed");
2141 assert!(matches!(
2142 inputs.as_slice(),
2143 [HealthInput::ProvidersChanged { changed }]
2144 if changed[0].id == id && changed[0].generation.is_none()
2145 ));
2146 }
2147
2148 #[tokio::test]
2149 async fn concurrent_acquires_share_one_provider() {
2150 let h = Harness::new().await;
2151 let id = h.add_provider("shared").await;
2152
2153 let leases =
2154 futures::future::join_all((0..8).map(|_| h.provider_service.acquire(id))).await;
2155
2156 let first = leases[0].as_ref().unwrap();
2157 for lease in &leases {
2158 let lease = lease.as_ref().unwrap();
2159 assert!(Arc::ptr_eq(&lease.provider, &first.provider));
2160 assert_eq!(lease.generation, first.generation);
2161 }
2162 }
2163
2164 #[tokio::test]
2165 async fn a_lazy_acquire_racing_a_removal_does_not_bring_the_provider_back() {
2166 let h = Harness::new().await;
2167 let id = h.add_provider("racing").await;
2168 let lock = h.provider_service.commit_lock(id);
2169 let held = lock.lock().await;
2170 let removal = tokio::spawn({
2171 let service = Arc::clone(&h.provider_service);
2172 async move { service.remove_provider(id).await }
2173 });
2174 tokio::time::sleep(Duration::from_millis(100)).await;
2175 let acquire = tokio::spawn({
2176 let service = Arc::clone(&h.provider_service);
2177 async move { service.acquire(id).await }
2178 });
2179 tokio::time::sleep(Duration::from_millis(100)).await;
2180
2181 drop(held);
2182 removal.await.unwrap().unwrap();
2183 let acquired = acquire.await.unwrap();
2184
2185 assert!(matches!(acquired, Err(DomainError::ProviderNotFound(_))));
2186 assert!(h.provider_service.current(id).await.is_none());
2187 assert!(h.scheduler.view(id, Instant::now()).is_none());
2188 }
2189
2190 #[tokio::test]
2191 async fn changing_the_refresh_interval_keeps_the_generation_and_reschedules() {
2192 let h = Harness::new().await;
2193 h.start().await;
2194 let (id, _) = h.add_stub(StubProvider::new).await;
2195 h.pipeline_service
2196 .fetch_now(&[id], Duration::from_secs(5))
2197 .await;
2198 let before = h.provider_service.acquire(id).await.unwrap().generation;
2199 assert!(
2200 h.scheduler
2201 .view(id, Instant::now())
2202 .unwrap()
2203 .next_attempt_in
2204 > Duration::from_secs(20)
2205 );
2206
2207 h.provider_service
2208 .update_provider_refresh_interval(id, 5)
2209 .await
2210 .unwrap();
2211
2212 let after = h.provider_service.acquire(id).await.unwrap().generation;
2213 assert_eq!(before, after);
2214 assert!(
2215 h.scheduler
2216 .view(id, Instant::now())
2217 .unwrap()
2218 .next_attempt_in
2219 <= Duration::from_secs(5)
2220 );
2221 assert_eq!(
2222 h.repository
2223 .get_provider(id)
2224 .await
2225 .unwrap()
2226 .refresh_interval,
2227 5
2228 );
2229 }
2230
2231 #[tokio::test]
2232 async fn health_hears_about_every_provider_change() {
2233 let h = Harness::new().await;
2234
2235 let id = h
2236 .provider_service
2237 .add_provider(h.config("argo", 30))
2238 .await
2239 .unwrap();
2240 let added = h.health_inputs();
2241 let added_generation = h.provider_service.acquire(id).await.unwrap().generation;
2242 h.provider_service
2243 .update_provider(id, updated(&h, id))
2244 .await
2245 .unwrap();
2246 let updated_inputs = h.health_inputs();
2247 h.provider_service.load_all_providers().await.unwrap();
2248 let reloaded = h.health_inputs();
2249 let reloaded_generation = h.provider_service.acquire(id).await.unwrap().generation;
2250 h.provider_service.remove_provider(id).await.unwrap();
2251 let removed = h.health_inputs();
2252
2253 let generations = |inputs: &[HealthInput]| match inputs {
2254 [HealthInput::ProvidersChanged { changed }] => changed
2255 .iter()
2256 .map(|input| (input.id, input.generation))
2257 .collect::<Vec<_>>(),
2258 other => panic!("unexpected health inputs: {other:?}"),
2259 };
2260 assert_eq!(generations(&added), [(id, Some(added_generation))]);
2261 let updated_generation = generations(&updated_inputs)[0].1;
2262 assert!(updated_generation.is_some());
2263 assert_ne!(updated_generation, Some(added_generation));
2264 assert_eq!(generations(&reloaded), [(id, Some(reloaded_generation))]);
2265 assert_ne!(Some(reloaded_generation), updated_generation);
2266 assert_eq!(generations(&removed), [(id, None)]);
2267 assert_eq!(h.bus.count("provider-added"), 1);
2268 assert_eq!(h.bus.count("provider-updated"), 1);
2269 assert_eq!(h.bus.count("provider-removed"), 1);
2270 }
2271
2272 #[tokio::test]
2273 async fn workflow_parameters_are_cached_for_ten_minutes() {
2274 let h = Harness::new().await;
2275 let (id, stub) = h.add_stub(StubProvider::new).await;
2276 let workflow = format!("github__{id}__web");
2277
2278 let first = h
2279 .provider_service
2280 .get_workflow_parameters(id, &workflow)
2281 .await
2282 .unwrap();
2283 h.provider_service
2284 .get_workflow_parameters(id, &workflow)
2285 .await
2286 .unwrap();
2287 let cached_fetches = stub
2288 .parameter_fetches
2289 .load(std::sync::atomic::Ordering::SeqCst);
2290 sqlx::query(
2291 "UPDATE workflow_parameters_cache SET cached_at = datetime('now', '-11 minutes')",
2292 )
2293 .execute(&h.pool)
2294 .await
2295 .unwrap();
2296 h.provider_service
2297 .get_workflow_parameters(id, &workflow)
2298 .await
2299 .unwrap();
2300
2301 assert_eq!(first.len(), 1);
2302 assert_eq!(cached_fetches, 1);
2303 assert_eq!(
2304 stub.parameter_fetches
2305 .load(std::sync::atomic::Ordering::SeqCst),
2306 2
2307 );
2308 }
2309
2310 #[tokio::test]
2311 async fn workflow_parameter_failures_keep_their_kind_and_leave_no_lock_behind() {
2312 let h = Harness::new().await;
2313 let (id, stub) = h.add_stub(StubProvider::new).await;
2314 *stub.parameters.lock().unwrap() = Err(DomainError::RateLimited {
2315 message: "slow".to_string(),
2316 retry_after: None,
2317 });
2318
2319 let failed = h
2320 .provider_service
2321 .get_workflow_parameters(id, "github__1__web")
2322 .await;
2323 let missing = h
2324 .provider_service
2325 .get_workflow_parameters(4242, "github__4242__web")
2326 .await;
2327
2328 assert!(matches!(failed, Err(DomainError::RateLimited { .. })));
2329 assert!(matches!(missing, Err(DomainError::ProviderNotFound(_))));
2330 assert!(h.provider_service.parameter_fetches.is_empty());
2331 }
2332
2333 #[tokio::test]
2334 async fn fetching_workflow_parameters_does_not_block_the_provider_map() {
2335 let h = Harness::new().await;
2336 let gate = Gate::new();
2337 let (id, _) = h
2338 .add_stub(|id| StubProvider::new(id).parameter_gated(&gate))
2339 .await;
2340 let other = h.add_provider("other").await;
2341 let fetch = tokio::spawn({
2342 let service = Arc::clone(&h.provider_service);
2343 async move { service.get_workflow_parameters(id, "github__1__web").await }
2344 });
2345 gate.arrived().await;
2346
2347 let acquired =
2348 tokio::time::timeout(Duration::from_secs(2), h.provider_service.acquire(other)).await;
2349 gate.open();
2350
2351 assert!(acquired.is_ok());
2352 fetch.await.unwrap().unwrap();
2353 }
2354
2355 #[tokio::test]
2356 async fn workflow_parameters_fetched_across_a_removal_are_not_cached() {
2357 let h = Harness::new().await;
2358 let gate = Gate::new();
2359 let (id, _) = h
2360 .add_stub(|id| StubProvider::new(id).parameter_gated(&gate))
2361 .await;
2362 let workflow = format!("github__{id}__web");
2363 let fetch = tokio::spawn({
2364 let service = Arc::clone(&h.provider_service);
2365 let workflow = workflow.clone();
2366 async move { service.get_workflow_parameters(id, &workflow).await }
2367 });
2368 gate.arrived().await;
2369
2370 h.provider_service.remove_provider(id).await.unwrap();
2371 gate.open();
2372
2373 assert_eq!(fetch.await.unwrap().unwrap().len(), 1);
2374 assert!(h
2375 .repository
2376 .get_cached_workflow_parameters(&workflow)
2377 .await
2378 .unwrap()
2379 .is_none());
2380 }
2381
2382 #[tokio::test]
2383 async fn field_options_work_when_the_plugin_rejects_the_partial_form() {
2384 let h = Harness::with_real_plugins().await;
2385 let kubeconfig = h._temp_dir.path().join("kubeconfig");
2386 std::fs::write(
2387 &kubeconfig,
2388 "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",
2389 )
2390 .unwrap();
2391 let config = HashMap::from([
2392 ("kubeconfig".to_string(), kubeconfig.display().to_string()),
2393 ("namespaces".to_string(), "Not_A_Namespace".to_string()),
2394 ]);
2395 assert!(h
2396 .provider_service
2397 .init_plugin("fluxcd", config.clone(), 0)
2398 .await
2399 .is_err());
2400
2401 let contexts = h
2402 .provider_service
2403 .field_options("fluxcd", "context", config)
2404 .await
2405 .unwrap();
2406
2407 assert_eq!(contexts, ["ctx-a", "ctx-b"]);
2408 }
2409
2410 #[tokio::test]
2411 async fn failed_field_options_report_why_the_plugin_could_not_initialize() {
2412 let h = Harness::with_real_plugins().await;
2413 let config = HashMap::from([
2414 (
2415 "kubeconfig".to_string(),
2416 h._temp_dir.path().join("missing").display().to_string(),
2417 ),
2418 ("namespaces".to_string(), "Not_A_Namespace".to_string()),
2419 ]);
2420
2421 let rejected = h
2422 .provider_service
2423 .field_options("fluxcd", "namespaces", config)
2424 .await;
2425
2426 assert!(
2427 matches!(rejected, Err(DomainError::InvalidConfig(message)) if message.contains("initialize"))
2428 );
2429 }
2430
2431 fn serve_tls(stream: std::net::TcpStream, config: Arc<rustls::ServerConfig>) {
2432 let Ok(connection) = rustls::ServerConnection::new(config) else {
2433 return;
2434 };
2435 let mut tls = rustls::StreamOwned::new(connection, stream);
2436 let mut request = Vec::new();
2437 let mut buffer = [0; 4096];
2438 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
2439 match tls.read(&mut buffer) {
2440 Ok(0) | Err(_) => return,
2441 Ok(count) => request.extend_from_slice(&buffer[..count]),
2442 }
2443 }
2444 let body = r#"{"items":[]}"#;
2445 let _ = write!(
2446 tls,
2447 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2448 body.len()
2449 );
2450 let _ = tls.flush();
2451 tls.conn.send_close_notify();
2452 let _ = tls.flush();
2453 }
2454
2455 fn self_signed_argocd() -> String {
2456 let _ = rustls::crypto::ring::default_provider().install_default();
2457 let cert = rustls::pki_types::CertificateDer::from(
2458 include_bytes!("../../../tests/fixtures/self_signed_cert.der").to_vec(),
2459 );
2460 let key = rustls::pki_types::PrivateKeyDer::Pkcs8(
2461 include_bytes!("../../../tests/fixtures/self_signed_key.der")
2462 .to_vec()
2463 .into(),
2464 );
2465 let config = Arc::new(
2466 rustls::ServerConfig::builder()
2467 .with_no_client_auth()
2468 .with_single_cert(vec![cert], key)
2469 .unwrap(),
2470 );
2471 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2472 let addr = listener.local_addr().unwrap();
2473 std::thread::spawn(move || {
2474 for stream in listener.incoming().flatten() {
2475 let config = Arc::clone(&config);
2476 std::thread::spawn(move || serve_tls(stream, config));
2477 }
2478 });
2479
2480 format!("https://{addr}")
2481 }
2482
2483 fn argocd_settings(url: &str, insecure: bool) -> HashMap<String, String> {
2484 HashMap::from([
2485 ("server_url".to_string(), url.to_string()),
2486 ("token".to_string(), "token".to_string()),
2487 ("insecure".to_string(), insecure.to_string()),
2488 ])
2489 }
2490
2491 #[tokio::test]
2492 async fn argocd_insecure_tls_is_honored_by_add_validate_and_preview() {
2493 let h = Harness::with_real_plugins().await;
2494 let service = &h.provider_service;
2495 let url = self_signed_argocd();
2496 let page = Some(pipedash_plugin_api::PaginationParams {
2497 page: 1,
2498 page_size: 10,
2499 });
2500 let add = |insecure| {
2501 let mut config = h.config("argo", 30);
2502 config.config = argocd_settings(&url, insecure);
2503 config.config.remove("token");
2504 service.add_provider(config)
2505 };
2506
2507 let id = add(true).await.unwrap();
2508 let insecure = service
2509 .init_plugin("argocd", argocd_settings(&url, true), 0)
2510 .await
2511 .unwrap();
2512 let secure = service
2513 .init_plugin("argocd", argocd_settings(&url, false), 0)
2514 .await
2515 .unwrap();
2516 let rejected = add(false).await.unwrap_err();
2517
2518 assert!(id > 0);
2519 assert!(insecure.validate_credentials().await.unwrap());
2520 assert!(insecure
2521 .fetch_available_pipelines_filtered(None, None, page.clone())
2522 .await
2523 .unwrap()
2524 .items
2525 .is_empty());
2526 assert!(matches!(
2527 secure.validate_credentials().await,
2528 Err(PluginError::Tls(_))
2529 ));
2530 assert!(matches!(
2531 secure
2532 .fetch_available_pipelines_filtered(None, None, page)
2533 .await,
2534 Err(PluginError::Tls(_))
2535 ));
2536 assert!(matches!(rejected, DomainError::InvalidConfig(message) if message.contains("TLS")));
2537 }
2538
2539 fn config_file(h: &Harness, providers: &[(&str, &str, i64)]) {
2540 let mut content = String::new();
2541 for (key, name, refresh_interval) in providers {
2542 content.push_str(&format!(
2543 "[providers.{key}]\nname = \"{name}\"\ntype = \"argocd\"\ntoken = \"token-{key}\"\nrefresh_interval = {refresh_interval}\n\n[providers.{key}.config]\nserver_url = \"{}\"\n\n",
2544 h.argocd_url
2545 ));
2546 }
2547 std::fs::write(h.config_state.config_path(), content).unwrap();
2548 }
2549
2550 async fn provider_id(h: &Harness, key: &str) -> Option<i64> {
2551 h.repository
2552 .config_backend()
2553 .list_providers()
2554 .await
2555 .unwrap()
2556 .into_iter()
2557 .find(|p| p.name == key)
2558 .and_then(|p| p.id)
2559 }
2560
2561 #[tokio::test]
2562 async fn removal_preserves_the_provider_and_toml_when_token_deletion_fails() {
2563 let h = Harness::new().await;
2564 config_file(&h, &[("ci-a", "CI A", 30)]);
2565 h.provider_service
2566 .apply_config_file(&h.config_state)
2567 .await
2568 .unwrap();
2569 let id = provider_id(&h, "ci-a").await.unwrap();
2570 h.token_store.fail_next_delete();
2571
2572 assert!(h.provider_service.remove_provider(id).await.is_err());
2573 assert!(h.repository.get_provider(id).await.is_ok());
2574 assert!(h
2575 .config_state
2576 .load_providers()
2577 .unwrap()
2578 .contains_key("ci-a"));
2579
2580 h.provider_service.remove_provider(id).await.unwrap();
2581 h.config_state.reload().await.unwrap();
2582 h.config_state
2583 .sync_providers_from_toml(h.repository.config_backend().as_ref(), false)
2584 .await
2585 .unwrap();
2586 assert!(h.repository.list_providers().await.unwrap().is_empty());
2587 assert!(h.token_store.get_all_tokens().await.unwrap().is_empty());
2588 }
2589
2590 #[tokio::test]
2591 async fn removal_awaits_token_cleanup_before_deleting_the_provider() {
2592 let h = Harness::new().await;
2593 let id = h.add_provider("await-delete").await;
2594 let gate = Gate::new();
2595 h.token_store.gate_delete(Arc::clone(&gate));
2596 let remove = tokio::spawn({
2597 let service = Arc::clone(&h.provider_service);
2598 async move { service.remove_provider(id).await }
2599 });
2600 gate.arrived().await;
2601
2602 assert!(!remove.is_finished());
2603 assert!(h.repository.get_provider(id).await.is_ok());
2604
2605 gate.open();
2606 remove.await.unwrap().unwrap();
2607 assert!(h.repository.get_provider(id).await.is_err());
2608 assert!(h.token_store.get_all_tokens().await.unwrap().is_empty());
2609 }
2610
2611 #[tokio::test]
2612 async fn failed_config_deletion_keeps_the_provider_available_for_retry() {
2613 let h = Harness::new().await;
2614 config_file(&h, &[("ci-a", "CI A", 30)]);
2615 h.provider_service
2616 .apply_config_file(&h.config_state)
2617 .await
2618 .unwrap();
2619 let id = provider_id(&h, "ci-a").await.unwrap();
2620 let path = h.config_state.config_path();
2621 std::fs::remove_file(path).unwrap();
2622 std::fs::create_dir(path).unwrap();
2623
2624 assert!(h.provider_service.remove_provider(id).await.is_err());
2625 assert!(h.repository.get_provider(id).await.is_ok());
2626
2627 std::fs::remove_dir(path).unwrap();
2628 config_file(&h, &[("ci-a", "CI A", 30)]);
2629 h.provider_service.remove_provider(id).await.unwrap();
2630 assert!(h.repository.get_provider(id).await.is_err());
2631 assert!(h.config_state.load_providers().unwrap().is_empty());
2632 }
2633
2634 #[tokio::test]
2635 async fn renaming_a_toml_provider_keeps_the_file_key_for_removal() {
2636 let h = Harness::new().await;
2637 config_file(&h, &[("ci-a", "CI A", 30)]);
2638 h.provider_service
2639 .apply_config_file(&h.config_state)
2640 .await
2641 .unwrap();
2642 let id = provider_id(&h, "ci-a").await.unwrap();
2643 let mut renamed = h.repository.get_provider(id).await.unwrap();
2644 renamed.name = "Renamed CI".to_string();
2645
2646 h.provider_service
2647 .update_provider(id, renamed)
2648 .await
2649 .unwrap();
2650 let saved = h.repository.get_provider(id).await.unwrap();
2651 assert_eq!(
2652 (saved.name.as_str(), saved.display_name()),
2653 ("ci-a", "Renamed CI")
2654 );
2655
2656 h.provider_service.remove_provider(id).await.unwrap();
2657 h.config_state.reload().await.unwrap();
2658 h.config_state
2659 .sync_providers_from_toml(h.repository.config_backend().as_ref(), false)
2660 .await
2661 .unwrap();
2662 assert!(h.repository.list_providers().await.unwrap().is_empty());
2663 }
2664
2665 #[tokio::test]
2666 async fn test_apply_config_file_adds_and_updates_changed_blocks_only() {
2667 let h = Harness::new().await;
2668 config_file(&h, &[("ci-a", "CI A", 30), ("ci-b", "CI B", 30)]);
2669
2670 let first = h
2671 .provider_service
2672 .apply_config_file(&h.config_state)
2673 .await
2674 .unwrap();
2675
2676 assert_eq!(first.added, ["ci-a", "ci-b"]);
2677 let a = provider_id(&h, "ci-a").await.unwrap();
2678 let b = provider_id(&h, "ci-b").await.unwrap();
2679 let loaded = h.provider_service.generation_of(a).await.unwrap();
2680 assert!(h.provider_service.generation_of(b).await.is_some());
2681 assert!(h
2682 .bus
2683 .events()
2684 .iter()
2685 .any(|e| matches!(e, CoreEvent::ProvidersChanged)));
2686
2687 h.provider_service
2688 .update_provider_refresh_interval(b, 120)
2689 .await
2690 .unwrap();
2691 config_file(
2692 &h,
2693 &[
2694 ("ci-a", "CI Renamed", 30),
2695 ("ci-b", "CI B", 30),
2696 ("ci-c", "CI C", 60),
2697 ],
2698 );
2699
2700 let second = h
2701 .provider_service
2702 .apply_config_file(&h.config_state)
2703 .await
2704 .unwrap();
2705
2706 assert_eq!(second.added, ["ci-c"]);
2707 assert_eq!(second.updated, ["ci-a"]);
2708 let c = provider_id(&h, "ci-c").await.unwrap();
2709 assert_eq!(
2710 h.repository.get_provider(c).await.unwrap().token,
2711 "token-ci-c"
2712 );
2713 assert!(h.provider_service.generation_of(c).await.is_some());
2714 assert_eq!(
2715 h.repository.get_provider(a).await.unwrap().display_name(),
2716 "CI Renamed"
2717 );
2718 assert_ne!(h.provider_service.generation_of(a).await, Some(loaded));
2719 assert_eq!(
2720 h.repository.get_provider(b).await.unwrap().refresh_interval,
2721 120
2722 );
2723
2724 let unchanged = h
2725 .provider_service
2726 .apply_config_file(&h.config_state)
2727 .await
2728 .unwrap();
2729 assert!(unchanged.added.is_empty() && unchanged.updated.is_empty());
2730 }
2731
2732 #[tokio::test]
2733 async fn test_apply_config_file_rejects_an_invalid_file_and_keeps_providers() {
2734 let h = Harness::new().await;
2735 config_file(&h, &[("ci-a", "CI A", 30)]);
2736 h.provider_service
2737 .apply_config_file(&h.config_state)
2738 .await
2739 .unwrap();
2740 let a = provider_id(&h, "ci-a").await.unwrap();
2741
2742 let write = |content: &str| std::fs::write(h.config_state.config_path(), content).unwrap();
2743
2744 write("[providers.ci-a]\nname = \"CI Broken\"\ntype = \"\"\n");
2745 let empty = h.provider_service.apply_config_file(&h.config_state).await;
2746 write("[providers.ci-a]\nname = \"CI Broken\"\ntype = \"circleci\"\n");
2747 let unknown = h.provider_service.apply_config_file(&h.config_state).await;
2748
2749 assert!(matches!(empty, Err(DomainError::InvalidConfig(_))));
2750 assert!(matches!(unknown, Err(DomainError::InvalidProviderType(_))));
2751
2752 assert_eq!(
2753 h.repository.get_provider(a).await.unwrap().display_name(),
2754 "CI A"
2755 );
2756 assert_eq!(
2757 h.config_state.get().await.providers["ci-a"].provider_type,
2758 "argocd"
2759 );
2760 }
2761
2762 async fn execute(h: &Harness, sql: &'static str) {
2763 sqlx::query(sql).execute(&h.pool).await.unwrap();
2764 }
2765
2766 #[tokio::test]
2767 async fn test_apply_config_file_retry_replaces_providers_saved_by_a_failed_apply() {
2768 let h = Harness::new().await;
2769 config_file(&h, &[("ci-a", "CI A", 30), ("ci-b", "CI B", 30)]);
2770 h.provider_service
2771 .apply_config_file(&h.config_state)
2772 .await
2773 .unwrap();
2774 let a = provider_id(&h, "ci-a").await.unwrap();
2775 let b = provider_id(&h, "ci-b").await.unwrap();
2776 let loaded_a = h.provider_service.generation_of(a).await.unwrap();
2777 let loaded_b = h.provider_service.generation_of(b).await.unwrap();
2778
2779 execute(
2780 &h,
2781 "CREATE TRIGGER fail_ci_b BEFORE UPDATE ON providers WHEN NEW.name = 'ci-b' \
2782 BEGIN SELECT RAISE(ABORT, 'write failed'); END",
2783 )
2784 .await;
2785 config_file(&h, &[("ci-a", "CI A2", 30), ("ci-b", "CI B2", 30)]);
2786 assert!(h
2787 .provider_service
2788 .apply_config_file(&h.config_state)
2789 .await
2790 .is_err());
2791 assert_eq!(
2792 h.repository.get_provider(a).await.unwrap().display_name(),
2793 "CI A2"
2794 );
2795
2796 execute(&h, "DROP TRIGGER fail_ci_b").await;
2797 h.provider_service
2798 .apply_config_file(&h.config_state)
2799 .await
2800 .unwrap();
2801
2802 assert_eq!(
2803 h.repository.get_provider(b).await.unwrap().display_name(),
2804 "CI B2"
2805 );
2806 assert_ne!(h.provider_service.generation_of(a).await, Some(loaded_a));
2807 assert_ne!(h.provider_service.generation_of(b).await, Some(loaded_b));
2808 assert_eq!(
2809 h.config_state.get().await.providers["ci-b"].name.as_deref(),
2810 Some("CI B2")
2811 );
2812 }
2813
2814 #[tokio::test]
2815 async fn test_apply_config_file_stores_an_empty_token_for_a_new_provider_without_one() {
2816 let h = Harness::new().await;
2817 config_file(&h, &[("ci-a", "CI A", 30)]);
2818 let path = h.config_state.config_path();
2819 let unset = std::fs::read_to_string(path)
2820 .unwrap()
2821 .replace("token-ci-a", "${PIPEDASH_TOKEN_UNSET_IN_TESTS:-}");
2822 std::fs::write(path, unset).unwrap();
2823
2824 h.provider_service
2825 .apply_config_file(&h.config_state)
2826 .await
2827 .unwrap();
2828
2829 let a = provider_id(&h, "ci-a").await.unwrap();
2830 assert_eq!(h.token_store.get_token(a).await.unwrap(), "");
2831 }
2832
2833 #[tokio::test]
2834 async fn test_apply_config_file_retries_a_token_the_store_failed_to_save() {
2835 let h = Harness::new().await;
2836 config_file(&h, &[("ci-a", "CI A", 30)]);
2837 h.provider_service
2838 .apply_config_file(&h.config_state)
2839 .await
2840 .unwrap();
2841 let a = provider_id(&h, "ci-a").await.unwrap();
2842 let path = h.config_state.config_path();
2843 let rotated = std::fs::read_to_string(path)
2844 .unwrap()
2845 .replace("token-ci-a", "rotated");
2846 std::fs::write(path, rotated).unwrap();
2847
2848 h.token_store.fail_next_store();
2849 assert!(h
2850 .provider_service
2851 .apply_config_file(&h.config_state)
2852 .await
2853 .is_err());
2854 assert_eq!(h.stubs.token_of(a).as_deref(), Some("token-ci-a"));
2855
2856 h.provider_service
2857 .apply_config_file(&h.config_state)
2858 .await
2859 .unwrap();
2860
2861 assert_eq!(h.stubs.token_of(a).as_deref(), Some("rotated"));
2862 assert_eq!(
2863 h.config_state.get().await.providers["ci-a"].token,
2864 "rotated"
2865 );
2866 }
2867
2868 #[tokio::test]
2869 async fn test_config_watcher_retries_a_failed_apply_without_a_new_edit() {
2870 let h = Harness::new().await;
2871 let watcher = crate::application::ConfigWatcher::new(
2872 Arc::clone(&h.provider_service),
2873 Arc::clone(&h.config_state),
2874 )
2875 .with_poll(Duration::from_millis(20));
2876 watcher.start().await;
2877
2878 execute(
2879 &h,
2880 "CREATE TRIGGER fail_insert BEFORE INSERT ON providers \
2881 BEGIN SELECT RAISE(ABORT, 'write failed'); END",
2882 )
2883 .await;
2884 config_file(&h, &[("ci-a", "CI A", 30)]);
2885 tokio::time::sleep(Duration::from_millis(200)).await;
2886 assert_eq!(provider_id(&h, "ci-a").await, None);
2887 execute(&h, "DROP TRIGGER fail_insert").await;
2888
2889 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2890 let id = loop {
2891 if let Some(id) = provider_id(&h, "ci-a").await {
2892 break id;
2893 }
2894 assert!(
2895 std::time::Instant::now() < deadline,
2896 "the watcher never retried the file"
2897 );
2898 tokio::time::sleep(Duration::from_millis(20)).await;
2899 };
2900 watcher.stop().await;
2901
2902 assert!(h.provider_service.generation_of(id).await.is_some());
2903 }
2904
2905 fn sign_in(token_endpoint: String, expires_in: chrono::Duration) -> SignInCredential {
2906 SignInCredential {
2907 token: "bearer".into(),
2908 refresh_token: Some("r1".into()),
2909 expires_at: Some(Utc::now() + expires_in),
2910 token_endpoint,
2911 client_id: "cli".into(),
2912 kind: pipedash_plugin_api::SignInToken::AccessToken,
2913 account: Some("dev@example.com".into()),
2914 }
2915 }
2916
2917 async fn stored_sign_in(h: &Harness, id: i64) -> SignInCredential {
2918 SignInCredential::decode(&h.repository.get_provider(id).await.unwrap().token).unwrap()
2919 }
2920
2921 #[tokio::test]
2922 async fn renew_is_scheduled_a_minute_before_the_sign_in_expires() {
2923 let h = Harness::with_real_plugins().await;
2924 let id = h.add_provider("sso").await;
2925 let credential = sign_in(
2926 "http://127.0.0.1:9/token".into(),
2927 chrono::Duration::minutes(30),
2928 );
2929 h.repository
2930 .store_token(id, &credential.encode().unwrap())
2931 .await
2932 .unwrap();
2933
2934 let lease = h.provider_service.acquire(id).await.unwrap();
2935
2936 assert_eq!(
2937 lease.provider.renew_at(),
2938 Some(credential.expires_at.unwrap() - chrono::Duration::seconds(60))
2939 );
2940 }
2941
2942 #[tokio::test]
2943 async fn renew_refreshes_and_stores_an_expired_sign_in() {
2944 let h = Harness::new().await;
2945 let idp = Idp::start().await;
2946 idp.reply(
2947 200,
2948 serde_json::json!({"access_token": "new", "refresh_token": "r2", "expires_in": 3600}),
2949 );
2950 let (id, _) = h.add_stub(StubProvider::new).await;
2951 let expired = sign_in(idp.token_endpoint(), chrono::Duration::minutes(-1));
2952 h.repository
2953 .store_token(id, &expired.encode().unwrap())
2954 .await
2955 .unwrap();
2956
2957 h.provider_service.acquire(id).await.unwrap();
2958
2959 let renewed = stored_sign_in(&h, id).await;
2960 assert_eq!(renewed.token, "new");
2961 assert_eq!(renewed.refresh_token.as_deref(), Some("r2"));
2962 assert_eq!(renewed.account.as_deref(), Some("dev@example.com"));
2963 }
2964
2965 #[tokio::test]
2966 async fn renew_with_a_revoked_sign_in_asks_to_sign_in_again() {
2967 let h = Harness::new().await;
2968 let idp = Idp::start().await;
2969 idp.reply(400, serde_json::json!({"error": "invalid_grant"}));
2970 let (id, _) = h.add_stub(StubProvider::new).await;
2971 let expired = sign_in(idp.token_endpoint(), chrono::Duration::minutes(-1));
2972 h.repository
2973 .store_token(id, &expired.encode().unwrap())
2974 .await
2975 .unwrap();
2976
2977 let error = h.provider_service.acquire(id).await.err().unwrap();
2978
2979 assert!(
2980 matches!(error, DomainError::ProviderAuthenticationFailed(m) if m == crate::domain::SIGN_IN_EXPIRED)
2981 );
2982 }
2983
2984 #[tokio::test]
2985 async fn editing_a_signed_in_provider_keeps_its_session() {
2986 let h = Harness::with_real_plugins().await;
2987 let id = h.add_provider("sso").await;
2988 let credential = sign_in(
2989 "http://127.0.0.1:9/token".into(),
2990 chrono::Duration::minutes(30),
2991 );
2992 h.repository
2993 .store_token(id, &credential.encode().unwrap())
2994 .await
2995 .unwrap();
2996
2997 let editing = h.provider_service.provider_for_editing(id).await.unwrap();
2998 assert_eq!(editing.token, "");
2999 assert_eq!(
3000 editing.config.get(SIGN_IN_CONFIG_KEY),
3001 Some(&format!("provider:{id}"))
3002 );
3003
3004 h.provider_service
3005 .update_provider(id, editing)
3006 .await
3007 .unwrap();
3008
3009 assert_eq!(stored_sign_in(&h, id).await, credential);
3010 let other = h.add_provider("other").await;
3011 let mut stolen = h
3012 .provider_service
3013 .provider_for_editing(other)
3014 .await
3015 .unwrap();
3016 stolen
3017 .config
3018 .insert(SIGN_IN_CONFIG_KEY.into(), format!("provider:{id}"));
3019 assert!(matches!(
3020 h.provider_service.update_provider(other, stolen).await,
3021 Err(DomainError::InvalidConfig(_))
3022 ));
3023 }
3024
3025 #[tokio::test]
3026 async fn editing_during_a_renewal_keeps_the_renewed_session() {
3027 let h = Harness::with_real_plugins().await;
3028 let argocd = FeedStub::start().await;
3029 argocd.respond_json(serde_json::json!({ "items": [] }));
3030 let original = sign_in(
3031 "http://127.0.0.1:9/token".into(),
3032 chrono::Duration::minutes(30),
3033 );
3034 let mut config = h.config("sso", 30);
3035 config
3036 .config
3037 .insert("server_url".into(), argocd.base_url.clone());
3038 config.token = original.encode().unwrap();
3039 let id = h.repository.add_provider(&config).await.unwrap();
3040 let editing = h.provider_service.provider_for_editing(id).await.unwrap();
3041
3042 let checks = Gate::new();
3043 argocd.set_gate(Some(&checks));
3044 let edit = tokio::spawn({
3045 let service = Arc::clone(&h.provider_service);
3046 async move { service.update_provider(id, editing).await }
3047 });
3048 checks.arrived().await;
3049 let renewed = SignInCredential {
3050 token: "renewed".into(),
3051 refresh_token: Some("r2".into()),
3052 expires_at: Some(Utc::now() + chrono::Duration::minutes(45)),
3053 ..original.clone()
3054 };
3055 h.repository
3056 .store_token(id, &renewed.encode().unwrap())
3057 .await
3058 .unwrap();
3059 checks.open();
3060 edit.await.unwrap().unwrap();
3061
3062 assert_eq!(stored_sign_in(&h, id).await, renewed);
3063 assert_eq!(
3064 h.provider_service
3065 .acquire(id)
3066 .await
3067 .unwrap()
3068 .provider
3069 .renew_at(),
3070 renewed.renew_at()
3071 );
3072 }
3073}