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;
90
91const QUOTA_PAUSE_PERCENT: u64 = 10;
92const ACQUIRE_ATTEMPTS: usize = 3;
93
94pub(crate) type ProviderFactory =
95 Arc<dyn Fn(&ProviderConfig) -> DomainResult<Arc<dyn Provider>> + Send + Sync>;
96
97#[derive(Clone)]
98pub struct ProviderLease {
99 pub id: i64,
100 pub generation: Generation,
101 pub provider: Arc<dyn Provider>,
102}
103
104enum TakenSignIn {
105 None,
106 Flow(String),
107 Kept,
108}
109
110struct ProviderSlot {
111 generation: Generation,
112 provider: Arc<dyn Provider>,
113}
114
115impl ProviderSlot {
116 fn lease(&self, id: i64) -> ProviderLease {
117 ProviderLease {
118 id,
119 generation: self.generation,
120 provider: Arc::clone(&self.provider),
121 }
122 }
123}
124
125struct ParameterFetch<'a> {
126 fetches: &'a DashMap<String, Arc<Mutex<()>>>,
127 key: &'a str,
128 lock: Arc<Mutex<()>>,
129}
130
131impl<'a> ParameterFetch<'a> {
132 fn new(fetches: &'a DashMap<String, Arc<Mutex<()>>>, key: &'a str) -> Self {
133 let lock = Arc::clone(fetches.entry(key.to_string()).or_default().value());
134
135 Self { fetches, key, lock }
136 }
137}
138
139impl Drop for ParameterFetch<'_> {
140 fn drop(&mut self) {
141 self.fetches
142 .remove_if(self.key, |_, lock| Arc::ptr_eq(lock, &self.lock));
143 }
144}
145
146pub struct ProviderService {
147 repository: Arc<Repository>,
148 config_state: Arc<ConfigState>,
149 http_client_manager: Arc<HttpClientManager>,
150 providers: RwLock<HashMap<i64, ProviderSlot>>,
151 commit_locks: DashMap<i64, Arc<Mutex<()>>>,
152 renewal_locks: DashMap<i64, Arc<Mutex<()>>>,
153 sign_ins: Arc<SignInService>,
154 plugin_registry: Arc<PluginRegistry>,
155 parameter_fetches: DashMap<String, Arc<Mutex<()>>>,
156 event_bus: Arc<dyn EventBus>,
157 scheduler: Arc<FetchScheduler>,
158 health: HealthHandle,
159 dispatcher: RefreshDispatcher,
160 provider_factory: ProviderFactory,
161 config_apply: Mutex<()>,
162 log_store: Arc<LogStore>,
163}
164
165impl ProviderService {
166 #[allow(clippy::too_many_arguments)]
167 pub fn new(
168 repository: Arc<Repository>, http_client_manager: Arc<HttpClientManager>,
169 event_bus: Arc<dyn EventBus>, scheduler: Arc<FetchScheduler>, health: HealthHandle,
170 dispatcher: RefreshDispatcher, config_state: Arc<ConfigState>, log_store: Arc<LogStore>,
171 ) -> Self {
172 let plugin_registry = Arc::new(plugins::create_plugin_registry());
173 let provider_factory: ProviderFactory = {
174 let registry = Arc::clone(&plugin_registry);
175 let http = Arc::clone(&http_client_manager);
176 Arc::new(move |config| plugin_provider(®istry, &http, config))
177 };
178
179 Self {
180 repository,
181 config_state,
182 http_client_manager,
183 providers: RwLock::new(HashMap::new()),
184 commit_locks: DashMap::new(),
185 renewal_locks: DashMap::new(),
186 sign_ins: Arc::new(SignInService::default()),
187 plugin_registry,
188 parameter_fetches: DashMap::new(),
189 event_bus,
190 scheduler,
191 health,
192 dispatcher,
193 provider_factory,
194 config_apply: Mutex::new(()),
195 log_store,
196 }
197 }
198
199 #[cfg(test)]
200 pub(crate) fn with_provider_factory(mut self, factory: ProviderFactory) -> Self {
201 self.provider_factory = factory;
202 self
203 }
204
205 pub fn repository(&self) -> &Arc<Repository> {
206 &self.repository
207 }
208
209 pub(crate) fn scheduler(&self) -> &Arc<FetchScheduler> {
210 &self.scheduler
211 }
212
213 pub(crate) fn health(&self) -> &HealthHandle {
214 &self.health
215 }
216
217 pub(crate) fn dispatcher(&self) -> &RefreshDispatcher {
218 &self.dispatcher
219 }
220
221 pub fn list_available_plugins(&self) -> Vec<pipedash_plugin_api::PluginMetadata> {
222 let mut metadata_list = Vec::new();
223 for provider_type in self.plugin_registry.provider_types() {
224 if let Some(plugin) = self.plugin_registry.get(&provider_type) {
225 metadata_list.push(plugin.metadata().clone());
226 }
227 }
228 metadata_list
229 }
230
231 pub fn retains_run_history(&self, provider_type: &str) -> bool {
232 self.plugin_registry
233 .get(provider_type)
234 .is_some_and(|plugin| plugin.metadata().capabilities.retains_run_history)
235 }
236
237 pub async fn init_plugin(
238 &self, provider_type: &str, mut config: HashMap<String, String>, provider_id: i64,
239 ) -> DomainResult<Box<dyn PluginTrait>> {
240 self.resolve_sign_in(&mut config).await?;
241 init_plugin(
242 &self.http_client_manager,
243 provider_type,
244 config,
245 provider_id,
246 )
247 }
248
249 pub async fn field_options(
250 &self, provider_type: &str, field_key: &str, mut config: HashMap<String, String>,
251 ) -> DomainResult<Vec<String>> {
252 self.resolve_sign_in(&mut config).await?;
253 let (mut plugin, client) = build_plugin(&self.http_client_manager, provider_type, &config)?;
254 let init_error = plugin.initialize(0, config.clone(), Some(client)).err();
255
256 match plugin.get_field_options(field_key, &config).await {
257 Ok(options) => Ok(options),
258 Err(e) => Err(match init_error {
259 Some(init) => {
260 DomainError::InvalidConfig(format!("Failed to initialize plugin: {init}"))
261 }
262 None => PluginAdapter::map_error(e),
263 }),
264 }
265 }
266
267 pub(crate) fn commit_lock(&self, id: i64) -> Arc<Mutex<()>> {
268 Arc::clone(self.commit_locks.entry(id).or_default().value())
269 }
270
271 fn renewal_lock(&self, id: i64) -> Arc<Mutex<()>> {
272 Arc::clone(self.renewal_locks.entry(id).or_default().value())
273 }
274
275 async fn resolve_sign_in(&self, config: &mut HashMap<String, String>) -> DomainResult<()> {
276 let Some(reference) = config.remove(SIGN_IN_CONFIG_KEY) else {
277 return Ok(());
278 };
279 let token = match SignInRef::parse(&reference) {
280 SignInRef::Flow(flow) => self.sign_ins.credential(&flow).await?.token,
281 SignInRef::Provider(id) => {
282 SignInCredential::bearer(&self.renewed_config(id).await?.token)
283 }
284 };
285 config.insert("token".to_string(), token);
286 Ok(())
287 }
288
289 async fn take_sign_in(
290 &self, config: &mut ProviderConfig, owner: Option<i64>,
291 ) -> DomainResult<TakenSignIn> {
292 let Some(reference) = config.config.remove(SIGN_IN_CONFIG_KEY) else {
293 return Ok(TakenSignIn::None);
294 };
295 match SignInRef::parse(&reference) {
296 SignInRef::Flow(flow) => {
297 config.token = self.sign_ins.credential(&flow).await?.encode()?;
298 Ok(TakenSignIn::Flow(flow))
299 }
300 SignInRef::Provider(id) if Some(id) == owner => {
301 config.token = self.renewed_config(id).await?.token;
302 Ok(TakenSignIn::Kept)
303 }
304 SignInRef::Provider(_) => Err(DomainError::InvalidConfig(
305 "This sign-in belongs to another provider".into(),
306 )),
307 }
308 }
309
310 async fn renewed_config(&self, id: i64) -> DomainResult<ProviderConfig> {
311 let renewal = self.renewal_lock(id);
312 let _renewal = renewal.lock().await;
313 let config = self.repository.get_provider(id).await?;
314 let Some(current) = SignInCredential::decode(&config.token)
315 .filter(|credential| credential.renew_at().is_some_and(|at| at <= Utc::now()))
316 else {
317 return Ok(config);
318 };
319
320 let (_, http) = build_plugin(
321 &self.http_client_manager,
322 &config.provider_type,
323 &config.config,
324 )?;
325 let token = oauth::refresh(&http, ¤t).await?.encode()?;
326
327 let commit = self.commit_lock(id);
328 let _commit = commit.lock().await;
329 let mut latest = self.repository.get_provider(id).await?;
330 if latest.token != config.token {
331 return Ok(latest);
332 }
333 self.repository.store_token(id, &token).await?;
334 tracing::info!(provider_id = id, "Renewed the provider sign-in");
335 latest.token = token;
336 Ok(latest)
337 }
338
339 async fn renew(&self, stale: ProviderLease) -> DomainResult<ProviderLease> {
340 let config = self.renewed_config(stale.id).await?;
341 let provider = (self.provider_factory)(&config)?;
342 let mut providers = self.providers.write().await;
343 let slot = providers
344 .get_mut(&stale.id)
345 .ok_or_else(|| DomainError::ProviderNotFound(stale.id.to_string()))?;
346 if slot.generation == stale.generation {
347 slot.provider = provider;
348 }
349 Ok(slot.lease(stale.id))
350 }
351
352 pub async fn provider_for_editing(&self, id: i64) -> DomainResult<ProviderConfig> {
353 let mut config = self.repository.get_provider(id).await?;
354 if SignInCredential::decode(&config.token).is_some() {
355 config.token.clear();
356 config.config.insert(
357 SIGN_IN_CONFIG_KEY.to_string(),
358 SignInRef::Provider(id).to_string(),
359 );
360 }
361 Ok(config)
362 }
363
364 pub async fn stored_plugin(&self, id: i64) -> DomainResult<Box<dyn PluginTrait>> {
365 let config = self.renewed_config(id).await?;
366 init_plugin(
367 &self.http_client_manager,
368 &config.provider_type,
369 config.plugin_config(),
370 id,
371 )
372 }
373
374 pub async fn start_sign_in(
375 &self, provider_type: &str, config: HashMap<String, String>,
376 ) -> DomainResult<SignInStart> {
377 let (plugin, http) = build_plugin(&self.http_client_manager, provider_type, &config)?;
378 let client = plugin
379 .sign_in_client(&config, &http)
380 .await
381 .map_err(PluginAdapter::map_error)?;
382 self.sign_ins.start(client, http).await
383 }
384
385 pub async fn sign_in_status(&self, reference: &str) -> DomainResult<SignInStatus> {
386 match SignInRef::parse(reference) {
387 SignInRef::Flow(flow) => Ok(self.sign_ins.status(&flow)),
388 SignInRef::Provider(id) => match self.renewed_config(id).await {
389 Ok(config) => Ok(SignInCredential::decode(&config.token).map_or(
390 SignInStatus::Expired,
391 |credential| SignInStatus::Completed {
392 account: credential.account,
393 },
394 )),
395 Err(DomainError::ProviderAuthenticationFailed(message)) => {
396 Ok(SignInStatus::Failed { message })
397 }
398 Err(e) => Err(e),
399 },
400 }
401 }
402
403 pub async fn complete_sign_in(
404 &self, flow: &str, redirect_url: &str,
405 ) -> DomainResult<SignInStatus> {
406 self.sign_ins.complete(flow, redirect_url).await
407 }
408
409 pub async fn cancel_sign_in(&self, flow: &str) {
410 self.sign_ins.cancel(flow).await;
411 }
412
413 pub async fn wait_for_sign_in(&self, flow: &str) -> SignInStatus {
414 self.sign_ins.wait(flow).await
415 }
416
417 async fn purge_run_logs(&self, id: i64) {
418 if let Err(e) = self.log_store.purge_provider(id).await {
419 tracing::warn!(provider_id = id, error = %e, "Failed to purge cached run logs");
420 }
421 }
422
423 async fn current(&self, id: i64) -> Option<ProviderLease> {
424 self.providers
425 .read()
426 .await
427 .get(&id)
428 .map(|slot| slot.lease(id))
429 }
430
431 pub(crate) async fn generation_of(&self, id: i64) -> Option<Generation> {
432 self.providers
433 .read()
434 .await
435 .get(&id)
436 .map(|slot| slot.generation)
437 }
438
439 pub(crate) async fn health_inputs(&self) -> Vec<ProviderHealthInput> {
440 let leases: Vec<ProviderLease> = self
441 .providers
442 .read()
443 .await
444 .iter()
445 .map(|(id, slot)| slot.lease(*id))
446 .collect();
447 let mut inputs = Vec::with_capacity(leases.len());
448 for lease in leases {
449 match self.repository.get_provider(lease.id).await {
450 Ok(config) => inputs.push(self.health_input(&config, &lease)),
451 Err(e) => {
452 tracing::debug!(provider_id = lease.id, error = %e, "Provider has no config row");
453 }
454 }
455 }
456
457 inputs
458 }
459
460 async fn install(&self, id: i64, provider: Arc<dyn Provider>, interval: i64) -> ProviderLease {
461 let mut providers = self.providers.write().await;
462 providers
463 .entry(id)
464 .or_insert_with(|| {
465 let generation = Generation::next();
466 self.scheduler
467 .register(id, generation, interval_of(interval));
468 ProviderSlot {
469 generation,
470 provider,
471 }
472 })
473 .lease(id)
474 }
475
476 async fn replace(&self, id: i64, provider: Arc<dyn Provider>, interval: i64) -> ProviderLease {
477 let generation = Generation::next();
478 let mut providers = self.providers.write().await;
479 providers.insert(
480 id,
481 ProviderSlot {
482 generation,
483 provider: Arc::clone(&provider),
484 },
485 );
486 self.scheduler.reset(id, generation, interval_of(interval));
487
488 ProviderLease {
489 id,
490 generation,
491 provider,
492 }
493 }
494
495 pub async fn acquire(&self, id: i64) -> DomainResult<ProviderLease> {
496 if let Some(lease) = self.current(id).await {
497 if lease.provider.renew_at().is_some_and(|at| at <= Utc::now()) {
498 return self.renew(lease).await;
499 }
500 return Ok(lease);
501 }
502
503 for _ in 0..ACQUIRE_ATTEMPTS {
504 let config = self.renewed_config(id).await?;
505 let provider = (self.provider_factory)(&config)?;
506 let lock = self.commit_lock(id);
507 let _guard = lock.lock().await;
508 if let Some(lease) = self.current(id).await {
509 return Ok(lease);
510 }
511 let row = self.repository.get_provider(id).await?;
512 if row.version == config.version {
513 let lease = self.install(id, provider, row.refresh_interval).await;
514 self.health.send(HealthInput::ProvidersChanged {
515 changed: vec![self.health_input(&row, &lease)],
516 });
517 return Ok(lease);
518 }
519 }
520
521 Err(DomainError::ConcurrentModification(format!(
522 "Provider {id} kept changing while it was loaded"
523 )))
524 }
525
526 pub async fn commit<F, Fut, T>(&self, lease: &ProviderLease, f: F) -> Option<T>
527 where
528 F: FnOnce() -> Fut,
529 Fut: Future<Output = T>,
530 {
531 self.commit_at(lease.id, lease.generation, f).await
532 }
533
534 async fn commit_at<F, Fut, T>(&self, id: i64, generation: Generation, f: F) -> Option<T>
535 where
536 F: FnOnce() -> Fut,
537 Fut: Future<Output = T>,
538 {
539 let lock = self.commit_lock(id);
540 let _guard = lock.lock().await;
541 let current = self
542 .providers
543 .read()
544 .await
545 .get(&id)
546 .map(|slot| slot.generation);
547 if current != Some(generation) {
548 return None;
549 }
550
551 Some(f().await)
552 }
553
554 pub async fn publish_summary(&self, id: i64, generation: Generation) {
555 self.commit_at(id, generation, || self.emit_status(id))
556 .await;
557 }
558
559 pub(crate) async fn emit_status(&self, id: i64) {
560 match self.summary_of(id).await {
561 Ok(provider) => {
562 self.event_bus
563 .emit(CoreEvent::ProviderStatusUpdated { provider })
564 .await;
565 }
566 Err(e) => {
567 tracing::warn!(provider_id = id, error = %e, "Failed to build provider summary");
568 }
569 }
570 }
571
572 async fn summary_of(&self, id: i64) -> DomainResult<ProviderSummary> {
573 let config = self.repository.get_provider(id).await?;
574
575 self.build_summary(id, &config).await
576 }
577
578 async fn build_summary(
579 &self, id: i64, config: &ProviderConfig,
580 ) -> DomainResult<ProviderSummary> {
581 let cached_pipelines = self.repository.get_cached_pipelines(Some(id)).await?;
582 let fetch = match self.repository.get_provider_fetch_status(id).await {
583 Ok(fetch) => fetch,
584 Err(e) => {
585 tracing::warn!(provider_id = id, error = %e, "Failed to read provider fetch status");
586 ProviderFetchStatus {
587 status: FETCH_STATUS_NEVER.to_string(),
588 error: None,
589 fetched_at: None,
590 succeeded_at: None,
591 }
592 }
593 };
594 let last_fetch_status = match fetch.status.as_str() {
595 FETCH_STATUS_SUCCESS => FetchStatus::Success,
596 FETCH_STATUS_ERROR => FetchStatus::Error,
597 _ => FetchStatus::Never,
598 };
599 let now = Instant::now();
600 let wall = Utc::now();
601 let view = self.scheduler.view(id, now);
602 let generation = self
603 .providers
604 .read()
605 .await
606 .get(&id)
607 .map(|slot| slot.generation);
608 let snapshot = self.health.snapshot();
609 let state = snapshot
610 .providers
611 .get(&id)
612 .filter(|state| Some(state.generation) == generation);
613 let paused_until = self.scheduler.paused_until(id, wall);
614 let level = state.map_or(HealthLevel::Operational, |state| state.level);
615 let freshness = ProviderFreshness::compute(
616 &last_fetch_status,
617 level,
618 fetch.fetched_at,
619 paused_until,
620 config.refresh_interval,
621 wall,
622 );
623
624 let last_fetch_error_kind = view.as_ref().and_then(|view| view.last_failure);
625 let recovery = ProviderRecovery::for_failure(
626 matches!(last_fetch_status, FetchStatus::Error)
627 .then_some(last_fetch_error_kind)
628 .flatten(),
629 SignInCredential::decode(&config.token).is_some(),
630 );
631
632 Ok(ProviderSummary {
633 id,
634 name: config.display_name().to_string(),
635 provider_type: config.provider_type.clone(),
636 icon: self
637 .plugin_registry
638 .get(&config.provider_type)
639 .and_then(|plugin| plugin.metadata().icon.clone()),
640 pipeline_count: cached_pipelines.len(),
641 last_updated: cached_pipelines.iter().map(|p| p.last_updated).max(),
642 refresh_interval: config.refresh_interval,
643 configured_repositories: configured_repositories(config),
644 last_fetch_status,
645 last_fetch_error: fetch.error,
646 last_fetch_at: fetch.fetched_at,
647 last_success_at: fetch.succeeded_at,
648 paused_until,
649 last_fetch_error_kind,
650 health: ProviderHealth {
651 level,
652 signals: state.map(|state| state.signals.clone()).unwrap_or_default(),
653 next_attempt_at: view
654 .as_ref()
655 .filter(|view| view.failures > 0)
656 .and_then(|_| {
657 let floor = state
658 .filter(|state| state.outage_floor)
659 .map(|_| self.scheduler.policy().outage_floor);
660 self.scheduler.next_due_in(id, now, floor)
661 })
662 .and_then(|wait| chrono::Duration::from_std(wait).ok())
663 .map(|wait| wall + wait),
664 in_flight: view.as_ref().is_some_and(|view| view.in_flight),
665 status_page: state.and_then(|state| state.status_page.clone()),
666 },
667 version: config.version.unwrap_or(1),
668 freshness,
669 recovery,
670 })
671 }
672
673 fn health_input(&self, config: &ProviderConfig, lease: &ProviderLease) -> ProviderHealthInput {
674 ProviderHealthInput {
675 id: lease.id,
676 generation: Some(lease.generation),
677 display_name: config.display_name().to_string(),
678 source: lease.provider.status_source().cloned(),
679 has_instance_checks: controller_health(&self.plugin_registry, &config.provider_type),
680 }
681 }
682
683 fn queue_fetch(&self, lease: &ProviderLease) {
684 if let ForcedClaim::Claimed(claim) =
685 self.scheduler
686 .claim_forced(lease.id, lease.generation, Instant::now())
687 {
688 self.dispatcher.fetch_now(vec![claim]);
689 }
690 }
691
692 pub async fn add_provider(&self, mut config: ProviderConfig) -> DomainResult<i64> {
693 let sign_in = self.take_sign_in(&mut config, None).await?;
694 let plugin = self
695 .init_plugin(&config.provider_type, config.plugin_config(), 0)
696 .await?;
697
698 let (validation_result, permission_status) =
699 tokio::join!(plugin.validate_credentials(), plugin.check_permissions());
700
701 validation_result.map_err(|e| {
702 DomainError::InvalidConfig(format!("Credential validation failed: {e}"))
703 })?;
704
705 let provider_type = plugin.provider_type();
706 if provider_type != config.provider_type {
707 return Err(DomainError::InvalidConfig(format!(
708 "Provider type mismatch: expected '{}', got '{}'",
709 config.provider_type, provider_type
710 )));
711 }
712
713 keep_selection_with_ci(plugin.as_ref(), &mut config).await?;
714
715 let permission_status = permission_status.ok();
716
717 let id = self.repository.add_provider(&config).await?;
718
719 if let Some(status) = &permission_status {
720 if let Err(e) = self.repository.store_provider_permissions(id, status).await {
721 tracing::warn!(provider_id = id, error = %e, "Failed to store permissions");
722 }
723 }
724
725 let mut config_with_id = config.clone();
726 config_with_id.id = Some(id);
727 let provider = (self.provider_factory)(&config_with_id)?;
728
729 let lock = self.commit_lock(id);
730 let _guard = lock.lock().await;
731 let lease = self.install(id, provider, config.refresh_interval).await;
732 self.health.send(HealthInput::ProvidersChanged {
733 changed: vec![self.health_input(&config_with_id, &lease)],
734 });
735 let provider = self.summary_of(id).await?;
736 self.event_bus
737 .emit(CoreEvent::ProviderAdded {
738 provider,
739 timestamp: Utc::now().timestamp_millis(),
740 })
741 .await;
742 if let TakenSignIn::Flow(flow) = sign_in {
743 self.sign_ins.forget(&flow);
744 }
745
746 Ok(id)
747 }
748
749 pub async fn export_providers_toml(&self) -> DomainResult<String> {
750 let providers = self.repository.config_backend().list_providers().await?;
751
752 ProviderSyncService::export_toml(&providers)
753 }
754
755 pub async fn import_providers_toml(&self, content: &str) -> DomainResult<ProviderImportResult> {
756 let file = ConfigLoader::parse_raw(content).map_err(|e| {
757 DomainError::InvalidConfig(format!("Couldn't read the providers file: {e}"))
758 })?;
759 if file.providers.is_empty() {
760 return Err(DomainError::InvalidConfig(
761 "The file has no [providers] entries".to_string(),
762 ));
763 }
764 if let Some((key, provider)) = file
765 .providers
766 .iter()
767 .find(|(_, provider)| !self.plugin_registry.is_registered(&provider.provider_type))
768 {
769 return Err(DomainError::InvalidProviderType(format!(
770 "Provider '{key}' has an unknown type '{}'",
771 provider.provider_type
772 )));
773 }
774
775 let existing: HashSet<String> = self
776 .repository
777 .config_backend()
778 .list_providers()
779 .await?
780 .into_iter()
781 .map(|provider| provider.name)
782 .collect();
783 let mut result = ProviderImportResult::default();
784 let mut failure = None;
785
786 for (key, provider) in &file.providers {
787 let name = provider.display_name(key).to_string();
788 if existing.contains(key) {
789 result.skipped.push(name);
790 continue;
791 }
792
793 let config = ProviderSyncService::provider_config(key, provider, String::new());
794 match self.repository.add_provider(&config).await {
795 Ok(_) => result.added.push(name),
796 Err(e) => {
797 let added_elsewhere = matches!(e, DomainError::InvalidConfig(_))
798 && self
799 .repository
800 .config_backend()
801 .list_providers()
802 .await
803 .is_ok_and(|providers| providers.iter().any(|p| p.name == *key));
804 if !added_elsewhere {
805 failure = Some(e);
806 break;
807 }
808 result.skipped.push(name);
809 }
810 }
811 }
812
813 if !result.added.is_empty() {
814 self.event_bus.emit(CoreEvent::ProvidersChanged).await;
815 }
816
817 failure.map_or(Ok(result), Err)
818 }
819
820 pub async fn apply_config_file(&self, config_state: &ConfigState) -> DomainResult<SyncResult> {
821 let _apply = self.config_apply.lock().await;
822 let PipedashConfig {
823 providers: file,
824 views,
825 ..
826 } = config_state.load_file()?;
827 let current = config_state.get().await;
828 let changed: Vec<(String, ProviderFileConfig)> = file
829 .iter()
830 .filter(|(key, provider)| current.providers.get(*key) != Some(*provider))
831 .map(|(key, provider)| (key.clone(), provider.clone()))
832 .collect();
833
834 for (key, provider) in &changed {
835 if !self.plugin_registry.is_registered(&provider.provider_type) {
836 return Err(DomainError::InvalidProviderType(format!(
837 "Provider '{key}' has an unknown type '{}'",
838 provider.provider_type
839 )));
840 }
841 (self.provider_factory)(&ProviderSyncService::provider_config(
842 key,
843 provider,
844 provider.token.clone(),
845 ))?;
846 }
847
848 let mut result = SyncResult::default();
849 if !changed.is_empty() {
850 let existing = self.repository.config_backend().list_providers().await?;
851 let token_store = config_state.token_store();
852 for (key, provider) in &changed {
853 let id = existing.iter().find(|p| p.name == *key).and_then(|p| p.id);
854 self.apply_config_provider(key, provider, id, token_store.as_ref())
855 .await?;
856 config_state.remember_provider(key, provider).await;
857 match id {
858 Some(_) => result.updated.push(key.clone()),
859 None => result.added.push(key.clone()),
860 }
861 }
862
863 tracing::info!(
864 added = result.added.len(),
865 updated = result.updated.len(),
866 "Applied provider changes from config file"
867 );
868 self.event_bus.emit(CoreEvent::ProvidersChanged).await;
869 }
870 if current.views != views {
871 config_state.remember_views(views).await;
872 self.event_bus.emit(CoreEvent::PipelineViewsChanged).await;
873 }
874 config_state.remember_providers(file).await;
875
876 Ok(result)
877 }
878
879 pub async fn update_general_settings(
880 &self, config_state: &ConfigState, update: GeneralSettingsUpdate,
881 ) -> DomainResult<()> {
882 let proxy_url = update.proxy_url.clone();
883 config_state.update_general_settings(update).await?;
884 if self.http_client_manager.set_proxy(&proxy_url)? {
885 self.rebuild_loaded_providers().await;
886 }
887
888 Ok(())
889 }
890
891 async fn rebuild_loaded_providers(&self) {
892 let ids: Vec<i64> = self.providers.read().await.keys().copied().collect();
893 let mut changed = Vec::new();
894
895 for id in ids {
896 let lock = self.commit_lock(id);
897 let _guard = lock.lock().await;
898 if !self.providers.read().await.contains_key(&id) {
899 continue;
900 }
901
902 let rebuilt = match self.repository.get_provider(id).await {
903 Ok(config) => (self.provider_factory)(&config).map(|provider| (config, provider)),
904 Err(e) => Err(e),
905 };
906 match rebuilt {
907 Ok((config, provider)) => {
908 let lease = self.replace(id, provider, config.refresh_interval).await;
909 changed.push(self.health_input(&config, &lease));
910 }
911 Err(e) => {
912 tracing::warn!(provider_id = id, error = %e, "Failed to rebuild provider");
913 }
914 }
915 }
916
917 if !changed.is_empty() {
918 self.health.send(HealthInput::ProvidersChanged { changed });
919 }
920 }
921
922 async fn apply_config_provider(
923 &self, key: &str, provider: &ProviderFileConfig, existing: Option<i64>,
924 token_store: &dyn TokenStore,
925 ) -> DomainResult<()> {
926 let sync = ProviderSyncService::new();
927 let backend = self.repository.config_backend().as_ref();
928 let id = match existing {
929 Some(id) => id,
930 None => sync.create_from_toml(key, provider, backend).await?,
931 };
932
933 let lock = self.commit_lock(id);
934 let guard = lock.lock().await;
935 if existing.is_some() {
936 let row = backend
937 .get_provider(id)
938 .await?
939 .ok_or_else(|| DomainError::ProviderNotFound(id.to_string()))?;
940 sync.update_from_toml(key, provider, &row, backend).await?;
941 }
942 ProviderSyncService::store_resolved_token(
943 id,
944 &provider.token,
945 existing.is_none(),
946 token_store,
947 )
948 .await?;
949
950 let config = self.repository.get_provider(id).await?;
951 let instance = (self.provider_factory)(&config)?;
952 if let Err(e) = self.repository.reset_provider_fetch_status(id).await {
953 tracing::warn!(provider_id = id, error = %e, "Failed to reset fetch status");
954 }
955 let lease = self.replace(id, instance, config.refresh_interval).await;
956 self.purge_run_logs(id).await;
957 self.health.send(HealthInput::ProvidersChanged {
958 changed: vec![self.health_input(&config, &lease)],
959 });
960 drop(guard);
961
962 self.queue_fetch(&lease);
963
964 Ok(())
965 }
966
967 pub async fn get_provider_config(&self, id: i64) -> DomainResult<ProviderConfig> {
968 self.repository.get_provider(id).await
969 }
970
971 pub async fn list_providers(&self) -> DomainResult<Vec<ProviderSummary>> {
972 let configs = self.repository.list_providers().await?;
973 let mut summaries = Vec::with_capacity(configs.len());
974
975 for config in configs {
976 if let Some(id) = config.id {
977 summaries.push(self.build_summary(id, &config).await?);
978 }
979 }
980
981 Ok(summaries)
982 }
983
984 pub async fn update_provider(&self, id: i64, mut config: ProviderConfig) -> DomainResult<()> {
985 let saved_name = self.repository.get_provider(id).await?.name;
986 if self
987 .config_state
988 .get()
989 .await
990 .providers
991 .contains_key(&saved_name)
992 {
993 let display_name = std::mem::replace(&mut config.name, saved_name);
994 config
995 .config
996 .insert("display_name".to_string(), display_name);
997 }
998 let sign_in = self.take_sign_in(&mut config, Some(id)).await?;
999 let plugin = self
1000 .init_plugin(&config.provider_type, config.plugin_config(), id)
1001 .await?;
1002
1003 plugin.validate_credentials().await.map_err(|e| {
1004 DomainError::InvalidConfig(format!("Credential validation failed: {e}"))
1005 })?;
1006
1007 keep_selection_with_ci(plugin.as_ref(), &mut config).await?;
1008
1009 let permission_status = plugin.check_permissions().await.ok();
1010
1011 let lock = self.commit_lock(id);
1012 let guard = lock.lock().await;
1013
1014 let current = self.repository.get_provider(id).await?;
1015 if matches!(sign_in, TakenSignIn::Kept) {
1016 config.token.clone_from(¤t.token);
1017 }
1018 let mut config_with_id = config.clone();
1019 config_with_id.id = Some(id);
1020 let new_provider = (self.provider_factory)(&config_with_id)?;
1021 let success = self
1022 .repository
1023 .update_provider_with_version(id, &config, current.version.unwrap_or(1))
1024 .await?;
1025
1026 if !success {
1027 return Err(DomainError::ConcurrentModification(format!(
1028 "Provider {} was modified by another client",
1029 id
1030 )));
1031 }
1032
1033 if let Err(e) = self.repository.reset_provider_fetch_status(id).await {
1034 tracing::warn!(provider_id = id, error = %e, "Failed to reset fetch status");
1035 }
1036 let lease = self
1037 .replace(id, new_provider, config.refresh_interval)
1038 .await;
1039 self.purge_run_logs(id).await;
1040 self.health.send(HealthInput::ProvidersChanged {
1041 changed: vec![self.health_input(&config_with_id, &lease)],
1042 });
1043 match self.summary_of(id).await {
1044 Ok(provider) => {
1045 self.event_bus
1046 .emit(CoreEvent::ProviderUpdated {
1047 provider,
1048 timestamp: Utc::now().timestamp_millis(),
1049 })
1050 .await;
1051 }
1052 Err(e) => {
1053 tracing::warn!(provider_id = id, error = %e, "Failed to build updated provider summary");
1054 self.event_bus.emit(CoreEvent::ProvidersChanged).await;
1055 }
1056 }
1057 drop(guard);
1058
1059 if let Some(status) = &permission_status {
1060 if let Err(e) = self.repository.store_provider_permissions(id, status).await {
1061 tracing::warn!(provider_id = id, error = %e, "Failed to store permissions");
1062 }
1063 }
1064
1065 self.queue_fetch(&lease);
1066 if let TakenSignIn::Flow(flow) = sign_in {
1067 self.sign_ins.forget(&flow);
1068 }
1069
1070 Ok(())
1071 }
1072
1073 pub async fn update_provider_refresh_interval(
1074 &self, id: i64, refresh_interval: i64,
1075 ) -> DomainResult<()> {
1076 let lock = self.commit_lock(id);
1077 let _guard = lock.lock().await;
1078
1079 let mut config = self.repository.get_provider(id).await?;
1080 config.refresh_interval = refresh_interval;
1081 self.repository.update_provider(id, &config).await?;
1082
1083 let generation = self
1084 .providers
1085 .read()
1086 .await
1087 .get(&id)
1088 .map(|slot| slot.generation);
1089 if let Some(generation) = generation {
1090 self.scheduler
1091 .set_interval(id, generation, interval_of(refresh_interval));
1092 }
1093
1094 Ok(())
1095 }
1096
1097 pub async fn remove_provider(&self, id: i64) -> DomainResult<()> {
1098 let _apply = self.config_apply.lock().await;
1099 let lock = self.commit_lock(id);
1100 let _guard = lock.lock().await;
1101
1102 let config = self.repository.get_provider(id).await?;
1103 let provider = self.build_summary(id, &config).await?;
1104
1105 self.repository
1106 .remove_provider(id, &self.config_state)
1107 .await?;
1108 self.providers.write().await.remove(&id);
1109 self.purge_run_logs(id).await;
1110 self.scheduler.remove(id);
1111 self.health.send(HealthInput::ProvidersChanged {
1112 changed: vec![ProviderHealthInput {
1113 id,
1114 generation: None,
1115 display_name: config.display_name().to_string(),
1116 source: None,
1117 has_instance_checks: false,
1118 }],
1119 });
1120 self.event_bus
1121 .emit(CoreEvent::ProviderRemoved {
1122 provider,
1123 timestamp: Utc::now().timestamp_millis(),
1124 })
1125 .await;
1126
1127 Ok(())
1128 }
1129
1130 pub async fn get_provider(&self, id: i64) -> DomainResult<Arc<dyn Provider>> {
1131 Ok(self.acquire(id).await?.provider)
1132 }
1133
1134 pub async fn check_quota(&self, lease: &ProviderLease) -> Option<DateTime<Utc>> {
1135 let rate_limit =
1136 match timeout(Duration::from_secs(10), lease.provider.fetch_rate_limit()).await {
1137 Ok(Ok(rate_limit)) => rate_limit,
1138 Ok(Err(e)) => {
1139 tracing::debug!(provider_id = lease.id, error = %e, "Rate limit check failed");
1140 None
1141 }
1142 Err(_) => {
1143 tracing::debug!(provider_id = lease.id, "Rate limit check timed out");
1144 None
1145 }
1146 };
1147 let paused_until = rate_limit.and_then(|rate_limit| quota_pause(&rate_limit, Utc::now()));
1148
1149 self.commit(lease, || async {
1150 let changed = self.scheduler.set_quota_pause(
1151 lease.id,
1152 lease.generation,
1153 paused_until,
1154 Instant::now(),
1155 Utc::now(),
1156 );
1157 if let (true, Some(until)) = (changed, paused_until) {
1158 tracing::info!(provider_id = lease.id, %until, "API quota almost used up, pausing provider");
1159 }
1160 if changed {
1161 self.emit_status(lease.id).await;
1162 }
1163
1164 paused_until
1165 })
1166 .await
1167 .flatten()
1168 }
1169
1170 pub async fn get_provider_instance(&self, id: i64) -> DomainResult<Arc<dyn Provider>> {
1171 self.get_provider(id).await
1172 }
1173
1174 pub async fn load_missing_providers(&self) {
1175 let ids = match self.repository.list_provider_ids().await {
1176 Ok(ids) => ids,
1177 Err(e) => {
1178 tracing::warn!(error = %e, "Failed to list providers");
1179 return;
1180 }
1181 };
1182 let loaded: Vec<i64> = self.providers.read().await.keys().copied().collect();
1183 for id in ids.into_iter().filter(|id| !loaded.contains(id)) {
1184 if let Err(e) = self.acquire(id).await {
1185 tracing::debug!(provider_id = id, error = %e, "Provider not loaded yet");
1186 }
1187 }
1188 }
1189
1190 pub async fn load_all_providers(&self) -> DomainResult<()> {
1191 let configs = self.repository.list_providers().await?;
1192 let mut changed = Vec::new();
1193
1194 for config in configs {
1195 let Some(id) = config.id else {
1196 continue;
1197 };
1198 if config.token.is_empty() {
1199 tracing::debug!(
1200 provider_id = id,
1201 provider_type = %config.provider_type,
1202 "Skipping provider load - token not available yet (vault initializing in background)"
1203 );
1204 continue;
1205 }
1206
1207 match (self.provider_factory)(&config) {
1208 Ok(provider) => {
1209 let lock = self.commit_lock(id);
1210 let _guard = lock.lock().await;
1211 let lease = self.replace(id, provider, config.refresh_interval).await;
1212 changed.push(self.health_input(&config, &lease));
1213 }
1214 Err(e) => {
1215 tracing::warn!(provider_id = id, error = %e, "Failed to load provider");
1216 }
1217 }
1218 }
1219
1220 if !changed.is_empty() {
1221 self.health.send(HealthInput::ProvidersChanged { changed });
1222 }
1223
1224 Ok(())
1225 }
1226
1227 pub async fn get_workflow_parameters(
1228 &self, provider_id: i64, workflow_id: &str,
1229 ) -> DomainResult<Vec<pipedash_plugin_api::WorkflowParameter>> {
1230 if let Some(cached) = self.cached_parameters(workflow_id).await {
1231 return Ok(cached);
1232 }
1233
1234 let lease = self.acquire(provider_id).await?;
1235 let fetch = ParameterFetch::new(&self.parameter_fetches, workflow_id);
1236 let _guard = fetch.lock.lock().await;
1237
1238 if let Some(cached) = self.cached_parameters(workflow_id).await {
1239 return Ok(cached);
1240 }
1241
1242 let parameters = lease.provider.get_workflow_parameters(workflow_id).await?;
1243
1244 let cached = self
1245 .commit(&lease, || {
1246 self.repository
1247 .cache_workflow_parameters(workflow_id, ¶meters)
1248 })
1249 .await;
1250 if let Some(Err(e)) = cached {
1251 tracing::warn!(provider_id, workflow_id, error = %e, "Failed to cache workflow parameters");
1252 }
1253
1254 Ok(parameters)
1255 }
1256
1257 async fn cached_parameters(
1258 &self, workflow_id: &str,
1259 ) -> Option<Vec<pipedash_plugin_api::WorkflowParameter>> {
1260 match self
1261 .repository
1262 .get_cached_workflow_parameters(workflow_id)
1263 .await
1264 {
1265 Ok(cached) => cached,
1266 Err(e) => {
1267 tracing::warn!(workflow_id, error = %e, "Failed to read cached workflow parameters");
1268 None
1269 }
1270 }
1271 }
1272
1273 pub async fn get_provider_permissions(
1274 &self, provider_id: i64,
1275 ) -> DomainResult<Option<pipedash_plugin_api::PermissionStatus>> {
1276 self.repository.get_provider_permissions(provider_id).await
1277 }
1278
1279 pub async fn recheck_provider_permissions(
1280 &self, provider_id: i64,
1281 ) -> DomainResult<pipedash_plugin_api::PermissionStatus> {
1282 let plugin = self.stored_plugin(provider_id).await?;
1283
1284 let status = plugin
1285 .check_permissions()
1286 .await
1287 .map_err(|e| DomainError::InvalidConfig(format!("Failed to check permissions: {e}")))?;
1288
1289 self.repository
1290 .store_provider_permissions(provider_id, &status)
1291 .await?;
1292
1293 Ok(status)
1294 }
1295}
1296
1297fn interval_of(seconds: i64) -> Duration {
1298 Duration::from_secs(u64::try_from(seconds).unwrap_or(0))
1299}
1300
1301fn build_plugin(
1302 http: &HttpClientManager, provider_type: &str, config: &HashMap<String, String>,
1303) -> DomainResult<(Box<dyn PluginTrait>, Arc<reqwest::Client>)> {
1304 let plugin = plugins::create_plugin(provider_type).ok_or_else(|| {
1305 DomainError::InvalidProviderType(format!("Unknown provider type: {}", provider_type))
1306 })?;
1307 let client = http.client_for(&plugin.client_options(config))?;
1308
1309 Ok((plugin, client))
1310}
1311
1312async fn keep_selection_with_ci(
1313 plugin: &dyn PluginTrait, config: &mut ProviderConfig,
1314) -> DomainResult<()> {
1315 let Some(selected) = config.config.get("selected_items") else {
1316 return Ok(());
1317 };
1318 let ids: Vec<String> = selected
1319 .split(',')
1320 .map(str::trim)
1321 .filter(|id| !id.is_empty())
1322 .map(str::to_owned)
1323 .collect();
1324 if ids.is_empty() {
1325 return Ok(());
1326 }
1327 let Ok(kept) = plugin.ids_with_ci(ids.clone()).await else {
1328 return Ok(());
1329 };
1330 if kept.is_empty() {
1331 return Err(DomainError::InvalidConfig(
1332 "None of the selected repositories has a CI file or a pipeline run yet".to_string(),
1333 ));
1334 }
1335 if kept.len() < ids.len() {
1336 tracing::info!(
1337 selected = ids.len(),
1338 kept = kept.len(),
1339 "Skipped selected repositories without a CI file or a pipeline run"
1340 );
1341 config
1342 .config
1343 .insert("selected_items".to_string(), kept.join(","));
1344 }
1345 Ok(())
1346}
1347
1348fn init_plugin(
1349 http: &HttpClientManager, provider_type: &str, config: HashMap<String, String>,
1350 provider_id: i64,
1351) -> DomainResult<Box<dyn PluginTrait>> {
1352 let (mut plugin, client) = build_plugin(http, provider_type, &config)?;
1353 plugin
1354 .initialize(provider_id, config, Some(client))
1355 .map_err(|e| DomainError::InvalidConfig(format!("Failed to initialize plugin: {e}")))?;
1356
1357 Ok(plugin)
1358}
1359
1360pub(crate) fn controller_health(registry: &PluginRegistry, provider_type: &str) -> bool {
1361 registry.get(provider_type).is_some_and(|plugin| {
1362 plugin
1363 .metadata()
1364 .features
1365 .iter()
1366 .any(|feature| feature.id == CONTROLLER_HEALTH_FEATURE)
1367 })
1368}
1369
1370pub(crate) fn plugin_provider(
1371 registry: &PluginRegistry, http: &HttpClientManager, config: &ProviderConfig,
1372) -> DomainResult<Arc<dyn Provider>> {
1373 if !registry.is_registered(&config.provider_type) {
1374 return Err(DomainError::InvalidProviderType(format!(
1375 "Plugin not found for provider type: {}",
1376 config.provider_type
1377 )));
1378 }
1379
1380 let provider_id = config.id.unwrap_or(0);
1381 let (token, renew_at) = match SignInCredential::decode(&config.token) {
1382 Some(credential) => {
1383 let renew_at = credential.renew_at();
1384 (credential.token, renew_at)
1385 }
1386 None => (resolve_token_reference(provider_id, &config.token), None),
1387 };
1388 let mut plugin_config = config.config.clone();
1389 plugin_config.insert("token".to_string(), token);
1390
1391 let plugin = init_plugin(http, &config.provider_type, plugin_config, provider_id)?;
1392
1393 Ok(Arc::new(PluginAdapter::new(
1394 plugin,
1395 config.provider_type.clone(),
1396 provider_id,
1397 renew_at,
1398 )))
1399}
1400
1401fn resolve_token_reference(provider_id: i64, token: &str) -> String {
1402 if token.is_empty() {
1403 return String::new();
1404 }
1405 match TokenReference::parse(token) {
1406 Ok(TokenReference::EnvVar(var_name)) => std::env::var(&var_name).unwrap_or_else(|_| {
1407 tracing::warn!(
1408 provider_id = provider_id,
1409 var_name = %var_name,
1410 "Environment variable not found, using empty token"
1411 );
1412 String::new()
1413 }),
1414 Ok(TokenReference::None) => String::new(),
1415 Ok(_) | Err(_) => token.to_string(),
1416 }
1417}
1418
1419fn configured_repositories(config: &ProviderConfig) -> Vec<String> {
1420 config
1421 .config
1422 .get("selected_items")
1423 .map(|items| {
1424 if matches!(
1425 config.provider_type.as_str(),
1426 "argocd" | "fluxcd" | "komodo"
1427 ) {
1428 return Vec::new();
1429 }
1430
1431 items
1432 .split(',')
1433 .map(|s| s.trim().to_string())
1434 .filter(|s| !s.is_empty())
1435 .map(|item| {
1436 if config.provider_type == "tekton" && item.contains("__") {
1437 item.replace("__", "/")
1438 } else if config.provider_type == "jenkins" && !item.contains('/') {
1439 format!("(root)/{}", item)
1440 } else {
1441 item
1442 }
1443 })
1444 .collect()
1445 })
1446 .unwrap_or_default()
1447}
1448
1449pub(crate) fn quota_pause(rate_limit: &RateLimit, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
1450 let low = rate_limit.remaining * 100 < rate_limit.limit * QUOTA_PAUSE_PERCENT;
1451
1452 (low && rate_limit.reset_at > now).then_some(rate_limit.reset_at)
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457 use std::io::{
1458 Read,
1459 Write,
1460 };
1461
1462 use pipedash_plugin_api::PluginError;
1463
1464 use super::*;
1465 use crate::application::test_support::{
1466 pipeline,
1467 FeedStub,
1468 Gate,
1469 Harness,
1470 StubProvider,
1471 };
1472 use crate::application::FetchResult;
1473 use crate::domain::PipelineStatus;
1474 use crate::infrastructure::config::ConfigLoader;
1475 use crate::infrastructure::oauth::test_idp::Idp;
1476 use crate::test_env::EnvGuard;
1477
1478 const GITHUB_SECRET: &str = "ghp_exampleSECRET0123456789";
1479 const GITLAB_SECRET: &str = "glpat-SECRET-VALUE";
1480 const JENKINS_DEFAULT_SECRET: &str = "hunter2-default";
1481
1482 fn provider(
1483 name: &str, provider_type: &str, token: &str, refresh_interval: i64,
1484 config: &[(&str, &str)],
1485 ) -> ProviderConfig {
1486 ProviderConfig {
1487 id: None,
1488 name: name.to_string(),
1489 provider_type: provider_type.to_string(),
1490 token: token.to_string(),
1491 config: config
1492 .iter()
1493 .map(|(k, v)| (k.to_string(), v.to_string()))
1494 .collect(),
1495 refresh_interval,
1496 version: None,
1497 }
1498 }
1499
1500 struct CiCheck(Option<&'static [&'static str]>);
1501
1502 #[async_trait::async_trait]
1503 impl PluginTrait for CiCheck {
1504 fn metadata(&self) -> &pipedash_plugin_api::PluginMetadata {
1505 unimplemented!()
1506 }
1507
1508 fn initialize(
1509 &mut self, _: i64, _: HashMap<String, String>, _: Option<Arc<reqwest::Client>>,
1510 ) -> pipedash_plugin_api::PluginResult<()> {
1511 Ok(())
1512 }
1513
1514 async fn validate_credentials(&self) -> pipedash_plugin_api::PluginResult<bool> {
1515 Ok(true)
1516 }
1517
1518 async fn fetch_pipelines(
1519 &self,
1520 ) -> pipedash_plugin_api::PluginResult<Vec<pipedash_plugin_api::Pipeline>> {
1521 Ok(Vec::new())
1522 }
1523
1524 async fn fetch_run_history(
1525 &self, _: &str, _: usize,
1526 ) -> pipedash_plugin_api::PluginResult<Vec<pipedash_plugin_api::PipelineRun>> {
1527 Ok(Vec::new())
1528 }
1529
1530 async fn fetch_run_details(
1531 &self, _: &str, _: i64,
1532 ) -> pipedash_plugin_api::PluginResult<pipedash_plugin_api::PipelineRun> {
1533 Err(PluginError::NotSupported(String::new()))
1534 }
1535
1536 async fn trigger_pipeline(
1537 &self, _: pipedash_plugin_api::TriggerParams,
1538 ) -> pipedash_plugin_api::PluginResult<String> {
1539 Err(PluginError::NotSupported(String::new()))
1540 }
1541
1542 async fn ids_with_ci(
1543 &self, _: Vec<String>,
1544 ) -> pipedash_plugin_api::PluginResult<Vec<String>> {
1545 self.0
1546 .map(|kept| kept.iter().map(|id| id.to_string()).collect())
1547 .ok_or_else(|| PluginError::NetworkError("reset".to_string()))
1548 }
1549 }
1550
1551 #[tokio::test]
1552 async fn a_saved_selection_keeps_only_repositories_with_ci() {
1553 let selected = &[("selected_items", "acme/api, acme/empty,acme/web")];
1554
1555 let mut config = provider("gh", "github", "t", 30, selected);
1556 keep_selection_with_ci(&CiCheck(Some(&["acme/api", "acme/web"])), &mut config)
1557 .await
1558 .unwrap();
1559 assert_eq!(config.config["selected_items"], "acme/api,acme/web");
1560
1561 let mut config = provider("gh", "github", "t", 30, selected);
1562 keep_selection_with_ci(&CiCheck(None), &mut config)
1563 .await
1564 .unwrap();
1565 assert_eq!(
1566 config.config["selected_items"],
1567 "acme/api, acme/empty,acme/web"
1568 );
1569
1570 let mut config = provider("gh", "github", "t", 30, selected);
1571 let error = keep_selection_with_ci(&CiCheck(Some(&[])), &mut config)
1572 .await
1573 .unwrap_err();
1574 assert!(matches!(error, DomainError::InvalidConfig(_)), "{error}");
1575 assert_eq!(
1576 config.config["selected_items"],
1577 "acme/api, acme/empty,acme/web"
1578 );
1579 }
1580
1581 #[tokio::test]
1582 async fn test_export_providers_toml_round_trips_without_tokens() {
1583 let source = Harness::with_real_plugins().await;
1584 let repository = source.repository.clone();
1585 let service = &source.provider_service;
1586
1587 let originals = [
1588 provider(
1589 "github-main",
1590 "github",
1591 GITHUB_SECRET,
1592 45,
1593 &[("selected_items", "acme/api,acme/web")],
1594 ),
1595 provider(
1596 "My Jenkins",
1597 "jenkins",
1598 &format!("${{JENKINS_API_TOKEN:-{JENKINS_DEFAULT_SECRET}}}"),
1599 120,
1600 &[
1601 ("display_name", "Prod Jenkins"),
1602 ("server_url", "http://127.0.0.1:9"),
1603 ("username", "ci"),
1604 ("selected_items", "(root)/deploy"),
1605 ],
1606 ),
1607 provider("gitlab_main", "gitlab", GITLAB_SECRET, 30, &[]),
1608 ];
1609 for original in &originals {
1610 repository.add_provider(original).await.unwrap();
1611 }
1612
1613 let exported = service.export_providers_toml().await.unwrap();
1614
1615 for secret in [GITHUB_SECRET, GITLAB_SECRET, JENKINS_DEFAULT_SECRET] {
1616 assert!(!exported.contains(secret), "export leaked {secret}");
1617 }
1618 assert!(exported.contains("token = \"${PIPEDASH_TOKEN_GITHUB_MAIN:-}\""));
1619 assert!(exported.contains("token = \"${JENKINS_API_TOKEN:-}\""));
1620 assert!(exported.contains("token = \"${PIPEDASH_TOKEN_GITLAB_MAIN:-}\""));
1621
1622 let _env = EnvGuard::set(&[
1623 ("PIPEDASH_TOKEN_GITHUB_MAIN", Some("env-github-token")),
1624 ("JENKINS_API_TOKEN", Some("env-jenkins-token")),
1625 ("PIPEDASH_TOKEN_GITLAB_MAIN", None),
1626 ]);
1627 let parsed = ConfigLoader::parse(&exported).unwrap();
1628
1629 let mut keys: Vec<_> = parsed.providers.keys().map(String::as_str).collect();
1630 keys.sort_unstable();
1631 assert_eq!(keys, ["My Jenkins", "github-main", "gitlab_main"]);
1632 assert_eq!(parsed.providers["github-main"].token, "env-github-token");
1633 assert_eq!(parsed.providers["My Jenkins"].token, "env-jenkins-token");
1634 assert_eq!(parsed.providers["gitlab_main"].token, "");
1635 assert_eq!(
1636 parsed.providers["My Jenkins"].name.as_deref(),
1637 Some("Prod Jenkins")
1638 );
1639
1640 let target_harness = Harness::with_real_plugins().await;
1641 let target = &target_harness.repository;
1642 crate::infrastructure::config::sync::ProviderSyncService::new()
1643 .sync_toml_to_db(
1644 parsed.providers,
1645 target.config_backend().as_ref(),
1646 &crate::infrastructure::MemoryTokenStore::new(),
1647 false,
1648 )
1649 .await
1650 .unwrap();
1651
1652 let imported = target.config_backend().list_providers().await.unwrap();
1653
1654 assert_eq!(imported.len(), originals.len());
1655 for original in &originals {
1656 let imported = imported.iter().find(|p| p.name == original.name).unwrap();
1657 assert_eq!(imported.name, original.name);
1658 assert_eq!(imported.provider_type, original.provider_type);
1659 assert_eq!(imported.refresh_interval, original.refresh_interval);
1660 assert_eq!(imported.config, original.config);
1661 }
1662 }
1663
1664 #[tokio::test]
1665 async fn test_import_providers_toml_adds_missing_providers_without_tokens() {
1666 let source = Harness::with_real_plugins().await;
1667 let jenkins = provider(
1668 "My Jenkins",
1669 "jenkins",
1670 "${JENKINS_API_TOKEN}",
1671 120,
1672 &[
1673 ("display_name", "Prod Jenkins"),
1674 ("server_url", "http://127.0.0.1:9"),
1675 ("username", "ci"),
1676 ("selected_items", "(root)/deploy"),
1677 ],
1678 );
1679 for original in [
1680 provider(
1681 "github-main",
1682 "github",
1683 GITHUB_SECRET,
1684 45,
1685 &[("selected_items", "acme/api")],
1686 ),
1687 jenkins.clone(),
1688 ] {
1689 source.repository.add_provider(&original).await.unwrap();
1690 }
1691 let exported = source
1692 .provider_service
1693 .export_providers_toml()
1694 .await
1695 .unwrap();
1696
1697 let target = Harness::with_real_plugins().await;
1698 let kept = provider(
1699 "github-main",
1700 "github",
1701 "target-token",
1702 300,
1703 &[("selected_items", "acme/web")],
1704 );
1705 let kept_id = target.repository.add_provider(&kept).await.unwrap();
1706
1707 let _env = EnvGuard::set(&[("JENKINS_API_TOKEN", Some("env-jenkins-token"))]);
1708 let result = target
1709 .provider_service
1710 .import_providers_toml(&exported)
1711 .await
1712 .unwrap();
1713
1714 assert_eq!(
1715 result,
1716 ProviderImportResult {
1717 added: vec!["Prod Jenkins".to_string()],
1718 skipped: vec!["github-main".to_string()],
1719 }
1720 );
1721 let unchanged = target.repository.get_provider(kept_id).await.unwrap();
1722 assert_eq!(unchanged.token, kept.token);
1723 assert_eq!(unchanged.refresh_interval, kept.refresh_interval);
1724 assert_eq!(unchanged.config, kept.config);
1725
1726 let imported = target.repository.list_providers().await.unwrap();
1727 let imported = imported.iter().find(|p| p.name == jenkins.name).unwrap();
1728 let row = target
1729 .repository
1730 .config_backend()
1731 .get_provider(imported.id.unwrap())
1732 .await
1733 .unwrap()
1734 .unwrap();
1735 assert_eq!(imported.token, "");
1736 assert_eq!(row.token, "");
1737 assert_eq!(imported.provider_type, jenkins.provider_type);
1738 assert_eq!(imported.refresh_interval, jenkins.refresh_interval);
1739 assert_eq!(imported.config, jenkins.config);
1740 assert_eq!(target.bus.count("providers-changed"), 1);
1741 }
1742
1743 #[tokio::test]
1744 async fn test_import_providers_toml_rejects_unknown_type_before_adding() {
1745 let h = Harness::with_real_plugins().await;
1746 let content = r#"
1747[providers.github-main]
1748type = "github"
1749
1750[providers.mystery]
1751type = "travis"
1752"#;
1753
1754 let err = h
1755 .provider_service
1756 .import_providers_toml(content)
1757 .await
1758 .unwrap_err();
1759
1760 assert!(matches!(err, DomainError::InvalidProviderType(_)), "{err}");
1761 assert!(h.repository.list_providers().await.unwrap().is_empty());
1762 assert_eq!(h.bus.count("providers-changed"), 0);
1763 }
1764
1765 #[test]
1766 fn quota_pause_starts_under_ten_percent_until_reset() {
1767 let now = Utc::now();
1768 let reset_at = now + chrono::Duration::minutes(20);
1769 let pause = |remaining, reset_at| {
1770 quota_pause(
1771 &RateLimit {
1772 limit: 5000,
1773 remaining,
1774 reset_at,
1775 },
1776 now,
1777 )
1778 };
1779
1780 assert_eq!(pause(500, reset_at), None);
1781 assert_eq!(pause(499, reset_at), Some(reset_at));
1782 assert_eq!(pause(0, reset_at), Some(reset_at));
1783 assert_eq!(pause(0, now), None);
1784 assert_eq!(
1785 quota_pause(
1786 &RateLimit {
1787 limit: 0,
1788 remaining: 0,
1789 reset_at,
1790 },
1791 now,
1792 ),
1793 None
1794 );
1795 }
1796
1797 type StubGate = Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>;
1798
1799 async fn stub_github(
1800 remaining: u64, reset: i64, gate: StubGate,
1801 ) -> (String, Arc<Mutex<Vec<String>>>) {
1802 use tokio::io::{
1803 AsyncReadExt,
1804 AsyncWriteExt,
1805 };
1806
1807 let _ = rustls::crypto::ring::default_provider().install_default();
1808 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1809 let addr = listener.local_addr().unwrap();
1810 let requests: Arc<Mutex<Vec<String>>> = Arc::default();
1811 let log = requests.clone();
1812 tokio::spawn(async move {
1813 loop {
1814 let (mut stream, _) = listener.accept().await.unwrap();
1815 let mut request = Vec::new();
1816 let mut buffer = [0; 4096];
1817 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1818 let count = stream.read(&mut buffer).await.unwrap();
1819 if count == 0 {
1820 break;
1821 }
1822 request.extend_from_slice(&buffer[..count]);
1823 }
1824 let request = String::from_utf8(request).unwrap();
1825 let path = request.split_whitespace().nth(1).unwrap_or_default();
1826 let path = path.split('?').next().unwrap().to_string();
1827 let (status, body) = if path == "/api/v3/rate_limit" {
1828 if let Some((arrived, release)) = &gate {
1829 arrived.notify_one();
1830 release.notified().await;
1831 }
1832 let rate = serde_json::json!({
1833 "limit": 5000, "used": 5000 - remaining, "remaining": remaining, "reset": reset
1834 });
1835 let search = serde_json::json!({"limit": 30, "used": 0, "remaining": 30, "reset": reset});
1836 let body = serde_json::json!({
1837 "resources": {"core": rate, "search": search},
1838 "rate": rate
1839 });
1840 ("200 OK", body.to_string())
1841 } else {
1842 ("404 Not Found", r#"{"message":"Not Found"}"#.to_string())
1843 };
1844 log.lock().await.push(path);
1845 let response = format!(
1846 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1847 body.len()
1848 );
1849 let _ = stream.write_all(response.as_bytes()).await;
1850 }
1851 });
1852
1853 (format!("http://{addr}"), requests)
1854 }
1855
1856 #[tokio::test]
1857 async fn refresh_skips_a_github_provider_with_its_quota_almost_used_up() {
1858 let h = Harness::with_real_plugins().await;
1859 h.start().await;
1860 let reset = Utc::now().timestamp() + 1200;
1861 let (base_url, requests) = stub_github(0, reset, None).await;
1862 let id = h
1863 .repository
1864 .add_provider(&provider(
1865 "github",
1866 "github",
1867 "ghp_stub",
1868 30,
1869 &[("base_url", &base_url), ("selected_items", "acme/app")],
1870 ))
1871 .await
1872 .unwrap();
1873
1874 let first = h
1875 .pipeline_service
1876 .fetch_now(&[id], Duration::from_secs(5))
1877 .await;
1878 let again = h
1879 .pipeline_service
1880 .fetch_now(&[id], Duration::from_secs(5))
1881 .await;
1882 let summaries = h.provider_service.list_providers().await.unwrap();
1883
1884 assert_eq!(*requests.lock().await, ["/api/v3/rate_limit"]);
1885 assert_eq!(
1886 summaries[0].paused_until,
1887 DateTime::from_timestamp(reset, 0)
1888 );
1889 assert!(matches!(summaries[0].last_fetch_status, FetchStatus::Never));
1890 assert_eq!(first[0].state, crate::domain::RefreshState::Recent);
1891 assert_eq!(again[0].state, crate::domain::RefreshState::Recent);
1892 assert_eq!(again[0].kind, Some(crate::domain::FailureKind::RateLimited));
1893 assert_eq!(*requests.lock().await, ["/api/v3/rate_limit"]);
1894 }
1895
1896 #[tokio::test]
1897 async fn refresh_fetches_a_github_provider_with_quota_left() {
1898 let h = Harness::with_real_plugins().await;
1899 h.start().await;
1900 let (base_url, requests) = stub_github(4000, Utc::now().timestamp() + 1200, None).await;
1901 let id = h
1902 .repository
1903 .add_provider(&provider(
1904 "github",
1905 "github",
1906 "ghp_stub",
1907 30,
1908 &[("base_url", &base_url), ("selected_items", "acme/app")],
1909 ))
1910 .await
1911 .unwrap();
1912
1913 h.pipeline_service
1914 .fetch_now(&[id], Duration::from_secs(5))
1915 .await;
1916 let summaries = h.provider_service.list_providers().await.unwrap();
1917 let requests = requests.lock().await;
1918
1919 assert_eq!(requests[0], "/api/v3/rate_limit");
1920 assert!(requests
1921 .iter()
1922 .any(|path| path.starts_with("/api/v3/repos/acme/app")));
1923 assert_eq!(summaries[0].paused_until, None);
1924 }
1925
1926 #[tokio::test]
1927 async fn quota_answer_for_replaced_credentials_does_not_pause_the_new_ones() {
1928 let h = Harness::with_real_plugins().await;
1929 let reset = Utc::now().timestamp() + 1200;
1930 let arrived = Arc::new(tokio::sync::Notify::new());
1931 let release = Arc::new(tokio::sync::Notify::new());
1932 let (base_url, _) = stub_github(0, reset, Some((arrived.clone(), release.clone()))).await;
1933 let id = h
1934 .repository
1935 .add_provider(&provider(
1936 "github",
1937 "github",
1938 "ghp_old",
1939 30,
1940 &[("base_url", &base_url), ("selected_items", "acme/app")],
1941 ))
1942 .await
1943 .unwrap();
1944 let service = Arc::clone(&h.provider_service);
1945 let lease = service.acquire(id).await.unwrap();
1946
1947 let check = tokio::spawn({
1948 let service = Arc::clone(&service);
1949 let lease = lease.clone();
1950 async move { service.check_quota(&lease).await }
1951 });
1952 arrived.notified().await;
1953 let replacement =
1954 (service.provider_factory)(&h.repository.get_provider(id).await.unwrap()).unwrap();
1955 service.replace(id, replacement, 30).await;
1956 release.notify_one();
1957
1958 assert_eq!(check.await.unwrap(), None);
1959 assert_eq!(h.scheduler.paused_until(id, Utc::now()), None);
1960 }
1961
1962 fn updated(h: &Harness, id: i64) -> ProviderConfig {
1963 let mut config = h.config("renamed", 45);
1964 config.id = Some(id);
1965 config
1966 }
1967
1968 #[tokio::test]
1969 async fn an_update_during_a_blocked_fetch_supersedes_the_old_attempt() {
1970 let h = Harness::new().await;
1971 h.start().await;
1972 let old_gate = Gate::new();
1973 let (id, old) = h
1974 .add_stub(|id| {
1975 StubProvider::new(id)
1976 .gated(&old_gate)
1977 .returning(vec![pipeline(id, "old", PipelineStatus::Success)])
1978 })
1979 .await;
1980 let new = h.stubs.queue(StubProvider::new(id).returning(vec![pipeline(
1981 id,
1982 "new",
1983 PipelineStatus::Failed,
1984 )]));
1985 let blocked = h.spawn_fetch(id).await;
1986 old_gate.arrived().await;
1987 let before = h.provider_service.acquire(id).await.unwrap().generation;
1988
1989 h.provider_service
1990 .update_provider(id, updated(&h, id))
1991 .await
1992 .unwrap();
1993 let updated_at = h
1994 .bus
1995 .names()
1996 .iter()
1997 .position(|name| *name == "provider-updated")
1998 .unwrap();
1999 h.bus
2000 .wait_for("the fetch queued by the update", |events| {
2001 events[updated_at..]
2002 .iter()
2003 .any(|event| matches!(event, CoreEvent::PipelinesUpdated { .. }))
2004 })
2005 .await;
2006 old_gate.open();
2007 let old_result = blocked.await.unwrap();
2008
2009 assert!(matches!(old_result, FetchResult::Superseded));
2010 assert_eq!(h.cached_names(id).await, ["new"]);
2011 let published: Vec<_> = h.bus.events()[updated_at..]
2012 .iter()
2013 .filter_map(|event| match event {
2014 CoreEvent::PipelinesUpdated { pipelines, .. } => {
2015 Some(pipelines.iter().map(|p| p.name.clone()).collect::<Vec<_>>())
2016 }
2017 _ => None,
2018 })
2019 .collect();
2020 assert_eq!(published, vec![vec!["new".to_string()]]);
2021 let view = h.scheduler.view(id, Instant::now()).unwrap();
2022 assert_eq!(
2023 (view.failures, view.last_failure, view.in_flight),
2024 (0, None, false)
2025 );
2026 assert_ne!(
2027 h.provider_service.acquire(id).await.unwrap().generation,
2028 before
2029 );
2030 assert_eq!((old.fetch_count(), new.fetch_count()), (1, 1));
2031 }
2032
2033 #[tokio::test]
2034 async fn an_update_resets_the_fetch_status_and_the_schedule() {
2035 let h = Harness::new().await;
2036 let (id, _) = h
2037 .add_stub(|id| StubProvider::new(id).failing(DomainError::NetworkError("down".into())))
2038 .await;
2039 h.fetch_directly(id).await;
2040 h.health_inputs();
2041 assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 1);
2042
2043 h.provider_service
2044 .update_provider(id, updated(&h, id))
2045 .await
2046 .unwrap();
2047 let inputs = h.health_inputs();
2048
2049 let event = h
2050 .bus
2051 .events()
2052 .into_iter()
2053 .find_map(|event| match event {
2054 CoreEvent::ProviderUpdated { provider, .. } => Some(provider),
2055 _ => None,
2056 })
2057 .unwrap();
2058 assert!(matches!(event.last_fetch_status, FetchStatus::Never));
2059 assert_eq!(event.last_fetch_error, None);
2060 assert_eq!(event.last_fetch_error_kind, None);
2061 assert_eq!(event.health.next_attempt_at, None);
2062 assert_eq!(event.name, "renamed");
2063 assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 0);
2064 let lease = h.provider_service.acquire(id).await.unwrap();
2065 assert!(matches!(
2066 inputs.as_slice(),
2067 [HealthInput::ProvidersChanged { changed }]
2068 if changed.len() == 1 && changed[0].generation == Some(lease.generation)
2069 ));
2070 }
2071
2072 #[tokio::test]
2073 async fn an_old_failure_finishing_after_an_update_leaves_the_new_schedule_clean() {
2074 let h = Harness::new().await;
2075 h.start().await;
2076 let old_gate = Gate::new();
2077 let new_gate = Gate::new();
2078 let (id, _) = h
2079 .add_stub(|id| {
2080 StubProvider::new(id)
2081 .gated(&old_gate)
2082 .failing(DomainError::NetworkError("down".into()))
2083 })
2084 .await;
2085 h.stubs.queue(StubProvider::new(id).gated(&new_gate));
2086 let blocked = h.spawn_fetch(id).await;
2087 old_gate.arrived().await;
2088
2089 h.provider_service
2090 .update_provider(id, updated(&h, id))
2091 .await
2092 .unwrap();
2093 new_gate.arrived().await;
2094 old_gate.open();
2095 let old_result = blocked.await.unwrap();
2096
2097 assert!(matches!(old_result, FetchResult::Superseded));
2098 let view = h.scheduler.view(id, Instant::now()).unwrap();
2099 assert_eq!(
2100 (view.failures, view.last_failure, view.in_flight),
2101 (0, None, true)
2102 );
2103 let fetch = h.repository.get_provider_fetch_status(id).await.unwrap();
2104 assert_eq!((fetch.status.as_str(), fetch.error), ("never", None));
2105 new_gate.open();
2106 h.bus
2107 .wait_for("the new fetch", |events| {
2108 events
2109 .iter()
2110 .any(|event| matches!(event, CoreEvent::PipelinesUpdated { .. }))
2111 })
2112 .await;
2113 assert_eq!(h.scheduler.view(id, Instant::now()).unwrap().failures, 0);
2114 }
2115
2116 #[tokio::test]
2117 async fn a_removal_during_a_blocked_fetch_leaves_nothing_behind() {
2118 let h = Harness::new().await;
2119 let gate = Gate::new();
2120 let (id, _) = h.add_stub(|id| StubProvider::new(id).gated(&gate)).await;
2121 let blocked = h.spawn_fetch(id).await;
2122 gate.arrived().await;
2123 h.health_inputs();
2124
2125 h.provider_service.remove_provider(id).await.unwrap();
2126 let removed_at = h.bus.events().len();
2127 gate.open();
2128 let result = blocked.await.unwrap();
2129 let inputs = h.health_inputs();
2130
2131 assert!(matches!(result, FetchResult::Superseded));
2132 assert!(h.repository.get_provider(id).await.is_err());
2133 assert!(h.repository.get_provider_fetch_status(id).await.is_err());
2134 assert!(h.cached_names(id).await.is_empty());
2135 assert!(h.scheduler.view(id, Instant::now()).is_none());
2136 assert!(h.bus.names()[removed_at..].is_empty());
2137 assert_eq!(h.bus.names()[removed_at - 1], "provider-removed");
2138 assert!(matches!(
2139 inputs.as_slice(),
2140 [HealthInput::ProvidersChanged { changed }]
2141 if changed[0].id == id && changed[0].generation.is_none()
2142 ));
2143 }
2144
2145 #[tokio::test]
2146 async fn concurrent_acquires_share_one_provider() {
2147 let h = Harness::new().await;
2148 let id = h.add_provider("shared").await;
2149
2150 let leases =
2151 futures::future::join_all((0..8).map(|_| h.provider_service.acquire(id))).await;
2152
2153 let first = leases[0].as_ref().unwrap();
2154 for lease in &leases {
2155 let lease = lease.as_ref().unwrap();
2156 assert!(Arc::ptr_eq(&lease.provider, &first.provider));
2157 assert_eq!(lease.generation, first.generation);
2158 }
2159 }
2160
2161 #[tokio::test]
2162 async fn a_lazy_acquire_racing_a_removal_does_not_bring_the_provider_back() {
2163 let h = Harness::new().await;
2164 let id = h.add_provider("racing").await;
2165 let lock = h.provider_service.commit_lock(id);
2166 let held = lock.lock().await;
2167 let removal = tokio::spawn({
2168 let service = Arc::clone(&h.provider_service);
2169 async move { service.remove_provider(id).await }
2170 });
2171 tokio::time::sleep(Duration::from_millis(100)).await;
2172 let acquire = tokio::spawn({
2173 let service = Arc::clone(&h.provider_service);
2174 async move { service.acquire(id).await }
2175 });
2176 tokio::time::sleep(Duration::from_millis(100)).await;
2177
2178 drop(held);
2179 removal.await.unwrap().unwrap();
2180 let acquired = acquire.await.unwrap();
2181
2182 assert!(matches!(acquired, Err(DomainError::ProviderNotFound(_))));
2183 assert!(h.provider_service.current(id).await.is_none());
2184 assert!(h.scheduler.view(id, Instant::now()).is_none());
2185 }
2186
2187 #[tokio::test]
2188 async fn changing_the_refresh_interval_keeps_the_generation_and_reschedules() {
2189 let h = Harness::new().await;
2190 h.start().await;
2191 let (id, _) = h.add_stub(StubProvider::new).await;
2192 h.pipeline_service
2193 .fetch_now(&[id], Duration::from_secs(5))
2194 .await;
2195 let before = h.provider_service.acquire(id).await.unwrap().generation;
2196 assert!(
2197 h.scheduler
2198 .view(id, Instant::now())
2199 .unwrap()
2200 .next_attempt_in
2201 > Duration::from_secs(20)
2202 );
2203
2204 h.provider_service
2205 .update_provider_refresh_interval(id, 5)
2206 .await
2207 .unwrap();
2208
2209 let after = h.provider_service.acquire(id).await.unwrap().generation;
2210 assert_eq!(before, after);
2211 assert!(
2212 h.scheduler
2213 .view(id, Instant::now())
2214 .unwrap()
2215 .next_attempt_in
2216 <= Duration::from_secs(5)
2217 );
2218 assert_eq!(
2219 h.repository
2220 .get_provider(id)
2221 .await
2222 .unwrap()
2223 .refresh_interval,
2224 5
2225 );
2226 }
2227
2228 #[tokio::test]
2229 async fn health_hears_about_every_provider_change() {
2230 let h = Harness::new().await;
2231
2232 let id = h
2233 .provider_service
2234 .add_provider(h.config("argo", 30))
2235 .await
2236 .unwrap();
2237 let added = h.health_inputs();
2238 let added_generation = h.provider_service.acquire(id).await.unwrap().generation;
2239 h.provider_service
2240 .update_provider(id, updated(&h, id))
2241 .await
2242 .unwrap();
2243 let updated_inputs = h.health_inputs();
2244 h.provider_service.load_all_providers().await.unwrap();
2245 let reloaded = h.health_inputs();
2246 let reloaded_generation = h.provider_service.acquire(id).await.unwrap().generation;
2247 h.provider_service.remove_provider(id).await.unwrap();
2248 let removed = h.health_inputs();
2249
2250 let generations = |inputs: &[HealthInput]| match inputs {
2251 [HealthInput::ProvidersChanged { changed }] => changed
2252 .iter()
2253 .map(|input| (input.id, input.generation))
2254 .collect::<Vec<_>>(),
2255 other => panic!("unexpected health inputs: {other:?}"),
2256 };
2257 assert_eq!(generations(&added), [(id, Some(added_generation))]);
2258 let updated_generation = generations(&updated_inputs)[0].1;
2259 assert!(updated_generation.is_some());
2260 assert_ne!(updated_generation, Some(added_generation));
2261 assert_eq!(generations(&reloaded), [(id, Some(reloaded_generation))]);
2262 assert_ne!(Some(reloaded_generation), updated_generation);
2263 assert_eq!(generations(&removed), [(id, None)]);
2264 assert_eq!(h.bus.count("provider-added"), 1);
2265 assert_eq!(h.bus.count("provider-updated"), 1);
2266 assert_eq!(h.bus.count("provider-removed"), 1);
2267 }
2268
2269 #[tokio::test]
2270 async fn workflow_parameters_are_cached_for_ten_minutes() {
2271 let h = Harness::new().await;
2272 let (id, stub) = h.add_stub(StubProvider::new).await;
2273 let workflow = format!("github__{id}__web");
2274
2275 let first = h
2276 .provider_service
2277 .get_workflow_parameters(id, &workflow)
2278 .await
2279 .unwrap();
2280 h.provider_service
2281 .get_workflow_parameters(id, &workflow)
2282 .await
2283 .unwrap();
2284 let cached_fetches = stub
2285 .parameter_fetches
2286 .load(std::sync::atomic::Ordering::SeqCst);
2287 sqlx::query(
2288 "UPDATE workflow_parameters_cache SET cached_at = datetime('now', '-11 minutes')",
2289 )
2290 .execute(&h.pool)
2291 .await
2292 .unwrap();
2293 h.provider_service
2294 .get_workflow_parameters(id, &workflow)
2295 .await
2296 .unwrap();
2297
2298 assert_eq!(first.len(), 1);
2299 assert_eq!(cached_fetches, 1);
2300 assert_eq!(
2301 stub.parameter_fetches
2302 .load(std::sync::atomic::Ordering::SeqCst),
2303 2
2304 );
2305 }
2306
2307 #[tokio::test]
2308 async fn workflow_parameter_failures_keep_their_kind_and_leave_no_lock_behind() {
2309 let h = Harness::new().await;
2310 let (id, stub) = h.add_stub(StubProvider::new).await;
2311 *stub.parameters.lock().unwrap() = Err(DomainError::RateLimited {
2312 message: "slow".to_string(),
2313 retry_after: None,
2314 });
2315
2316 let failed = h
2317 .provider_service
2318 .get_workflow_parameters(id, "github__1__web")
2319 .await;
2320 let missing = h
2321 .provider_service
2322 .get_workflow_parameters(4242, "github__4242__web")
2323 .await;
2324
2325 assert!(matches!(failed, Err(DomainError::RateLimited { .. })));
2326 assert!(matches!(missing, Err(DomainError::ProviderNotFound(_))));
2327 assert!(h.provider_service.parameter_fetches.is_empty());
2328 }
2329
2330 #[tokio::test]
2331 async fn fetching_workflow_parameters_does_not_block_the_provider_map() {
2332 let h = Harness::new().await;
2333 let gate = Gate::new();
2334 let (id, _) = h
2335 .add_stub(|id| StubProvider::new(id).parameter_gated(&gate))
2336 .await;
2337 let other = h.add_provider("other").await;
2338 let fetch = tokio::spawn({
2339 let service = Arc::clone(&h.provider_service);
2340 async move { service.get_workflow_parameters(id, "github__1__web").await }
2341 });
2342 gate.arrived().await;
2343
2344 let acquired =
2345 tokio::time::timeout(Duration::from_secs(2), h.provider_service.acquire(other)).await;
2346 gate.open();
2347
2348 assert!(acquired.is_ok());
2349 fetch.await.unwrap().unwrap();
2350 }
2351
2352 #[tokio::test]
2353 async fn workflow_parameters_fetched_across_a_removal_are_not_cached() {
2354 let h = Harness::new().await;
2355 let gate = Gate::new();
2356 let (id, _) = h
2357 .add_stub(|id| StubProvider::new(id).parameter_gated(&gate))
2358 .await;
2359 let workflow = format!("github__{id}__web");
2360 let fetch = tokio::spawn({
2361 let service = Arc::clone(&h.provider_service);
2362 let workflow = workflow.clone();
2363 async move { service.get_workflow_parameters(id, &workflow).await }
2364 });
2365 gate.arrived().await;
2366
2367 h.provider_service.remove_provider(id).await.unwrap();
2368 gate.open();
2369
2370 assert_eq!(fetch.await.unwrap().unwrap().len(), 1);
2371 assert!(h
2372 .repository
2373 .get_cached_workflow_parameters(&workflow)
2374 .await
2375 .unwrap()
2376 .is_none());
2377 }
2378
2379 #[tokio::test]
2380 async fn field_options_work_when_the_plugin_rejects_the_partial_form() {
2381 let h = Harness::with_real_plugins().await;
2382 let kubeconfig = h._temp_dir.path().join("kubeconfig");
2383 std::fs::write(
2384 &kubeconfig,
2385 "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",
2386 )
2387 .unwrap();
2388 let config = HashMap::from([
2389 ("kubeconfig".to_string(), kubeconfig.display().to_string()),
2390 ("namespaces".to_string(), "Not_A_Namespace".to_string()),
2391 ]);
2392 assert!(h
2393 .provider_service
2394 .init_plugin("fluxcd", config.clone(), 0)
2395 .await
2396 .is_err());
2397
2398 let contexts = h
2399 .provider_service
2400 .field_options("fluxcd", "context", config)
2401 .await
2402 .unwrap();
2403
2404 assert_eq!(contexts, ["ctx-a", "ctx-b"]);
2405 }
2406
2407 #[tokio::test]
2408 async fn field_options_that_need_a_client_use_the_initialized_plugin() {
2409 let h = Harness::with_real_plugins().await;
2410 let ready = HashMap::from([
2411 ("server_url".to_string(), h.argocd_url.clone()),
2412 ("token".to_string(), "token".to_string()),
2413 ]);
2414 let incomplete = HashMap::from([("token".to_string(), "token".to_string())]);
2415
2416 let organizations = h
2417 .provider_service
2418 .field_options("argocd", "organizations", ready)
2419 .await;
2420 let rejected = h
2421 .provider_service
2422 .field_options("argocd", "organizations", incomplete)
2423 .await;
2424
2425 assert_eq!(organizations.unwrap(), Vec::<String>::new());
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}