use crate::cache_manager::CacheManager;
use aws_sdk_secretsmanager::types::{Filter, FilterNameStringType};
use aws_workload_credentials_provider_common::config::types::{
SecretPrefetchConfig, SecretsManagerConfig, TagFilter,
};
use log::{debug, error, info, warn};
use rand::RngExt;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::time::{sleep, Duration};
const BATCH_SIZE: usize = 20;
const BATCH_DELAY_MS: u64 = 34;
fn role_display(role_arn: Option<&str>) -> &str {
role_arn.unwrap_or("default credentials")
}
#[derive(Debug, Default)]
struct PrefetchStats {
success: usize,
failed: usize,
}
pub fn start_prefetch_task(cache_manager: Arc<CacheManager>, config: SecretsManagerConfig) {
tokio::spawn(async move {
let max_jitter = config.prefetch.max_jitter_seconds;
if max_jitter > 0 {
let jitter = rand::rng().random_range(0.0..max_jitter as f64);
sleep(Duration::from_secs_f64(jitter)).await;
}
let mut stats = PrefetchStats::default();
run_prefetch(&cache_manager, &config, &mut stats).await;
info!(
"Pre-fetch complete: success={}, failed={}",
stats.success, stats.failed
);
});
}
async fn run_prefetch(cm: &CacheManager, config: &SecretsManagerConfig, stats: &mut PrefetchStats) {
let cache_size: usize = config.cache.cache_size.get();
let warmup_limit = ((cache_size as f32 * config.prefetch.cache_buffer_ratio) as usize).max(1);
let prefetch = &config.prefetch;
let mut remaining_by_role: HashMap<Option<String>, usize> = HashMap::new();
if !prefetch.secrets.is_empty() {
let grouped = group_secrets_by_role(&prefetch.secrets);
for (role_arn, secret_ids) in &grouped {
let remaining = remaining_by_role
.entry(role_arn.clone())
.or_insert(warmup_limit);
fetch_secret_id_group(cm, role_arn.as_deref(), secret_ids, remaining, stats).await;
}
}
if !prefetch.filter_tags.is_empty() {
let grouped = group_tags_by_role(&prefetch.filter_tags);
for (role_arn, keys) in &grouped {
let remaining = remaining_by_role
.entry(role_arn.clone())
.or_insert(warmup_limit);
if *remaining == 0 {
continue;
}
fetch_tag_group(cm, role_arn.as_deref(), keys, remaining, stats).await;
}
}
}
fn group_secrets_by_role(secrets: &[SecretPrefetchConfig]) -> HashMap<Option<String>, Vec<String>> {
let mut grouped: HashMap<Option<String>, Vec<String>> = HashMap::new();
for s in secrets {
grouped
.entry(s.role_arn.clone())
.or_default()
.push(s.secret_id.clone());
}
grouped
}
fn group_tags_by_role(tags: &[TagFilter]) -> HashMap<Option<String>, Vec<String>> {
let mut grouped: HashMap<Option<String>, Vec<String>> = HashMap::new();
for t in tags {
grouped
.entry(t.role_arn.clone())
.or_default()
.push(t.key.clone());
}
grouped
}
async fn fetch_secret_id_group(
cm: &CacheManager,
role_arn: Option<&str>,
secret_ids: &[String],
remaining: &mut usize,
stats: &mut PrefetchStats,
) {
let client = match cm.get_client(role_arn).await {
Ok(c) => c,
Err(e) => {
error!(
"Pre-fetch: failed to get client for {}: {}",
role_display(role_arn),
e.1
);
stats.failed += secret_ids.len();
return;
}
};
let total = secret_ids.len();
let mut processed = 0;
for chunk in secret_ids.chunks(BATCH_SIZE) {
if *remaining == 0 {
warn!(
"Pre-fetch: cache buffer limit reached for {}, {} secret(s) not fetched",
role_display(role_arn),
total - processed
);
break;
}
let truncated = chunk.len() > *remaining;
let batch_size = chunk.len().min(*remaining);
if truncated {
warn!(
"Pre-fetch: cache buffer limit reached for {}, {} secret(s) not fetched",
role_display(role_arn),
total - processed - batch_size
);
}
let batch: Vec<String> = chunk[..batch_size].to_vec();
let batch_len = batch.len();
match client
.batch_get_secret_value(Some(batch), None, None, None)
.await
{
Ok(Some(resp)) => {
let fetched = resp.secret_values().len();
stats.success += fetched;
stats.failed += resp.errors().len();
*remaining = remaining.saturating_sub(fetched);
for err in resp.errors() {
debug!(
"Pre-fetch: failed to fetch {}: {}",
err.secret_id().unwrap_or("unknown"),
err.error_code().unwrap_or("unknown")
);
}
}
Ok(None) => {
stats.failed += batch_len;
}
Err(e) => {
error!("Pre-fetch: BatchGetSecretValue failed: {}", e);
stats.failed += batch_len;
}
}
processed += batch_len;
if truncated {
break;
}
if *remaining > 0 {
sleep(Duration::from_millis(BATCH_DELAY_MS)).await;
}
}
}
async fn fetch_tag_group(
cm: &CacheManager,
role_arn: Option<&str>,
keys: &[String],
remaining: &mut usize,
stats: &mut PrefetchStats,
) {
let client = match cm.get_client(role_arn).await {
Ok(c) => c,
Err(e) => {
error!(
"Pre-fetch: failed to get client for {}: {}",
role_display(role_arn),
e.1
);
return;
}
};
let filters: Vec<Filter> = keys
.iter()
.map(|k| {
Filter::builder()
.key(FilterNameStringType::TagKey)
.values(k)
.build()
})
.collect();
let mut next_token: Option<String> = None;
loop {
if *remaining == 0 {
warn!(
"Pre-fetch: cache buffer limit reached for {}",
role_display(role_arn)
);
break;
}
let max_results = (*remaining as i32).min(BATCH_SIZE as i32);
match client
.batch_get_secret_value(None, Some(filters.clone()), Some(max_results), next_token)
.await
{
Ok(Some(resp)) => {
let fetched = resp.secret_values().len();
stats.success += fetched;
stats.failed += resp.errors().len();
*remaining = remaining.saturating_sub(fetched);
for err in resp.errors() {
debug!(
"Pre-fetch: failed to fetch {}: {}",
err.secret_id().unwrap_or("unknown"),
err.error_code().unwrap_or("unknown")
);
}
if fetched == 0 && resp.errors().is_empty() {
warn!("Pre-fetch: no secrets found for tag filters {:?}", keys);
}
next_token = resp.next_token().map(String::from);
}
Ok(None) => break,
Err(e) => {
error!("Pre-fetch: BatchGetSecretValue with filters failed: {}", e);
break;
}
}
if next_token.is_none() {
break;
}
sleep(Duration::from_millis(BATCH_DELAY_MS)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use aws_workload_credentials_provider_common::config::types::PrefetchConfig;
async fn test_cache_manager(config: &SecretsManagerConfig) -> Arc<CacheManager> {
Arc::new(
CacheManager::new(config)
.await
.expect("cache manager failed"),
)
}
#[test]
fn test_group_secrets_no_roles() {
let secrets = vec![
SecretPrefetchConfig {
secret_id: "s1".into(),
role_arn: None,
},
SecretPrefetchConfig {
secret_id: "s2".into(),
role_arn: None,
},
];
let grouped = group_secrets_by_role(&secrets);
assert_eq!(grouped.len(), 1);
assert_eq!(grouped[&None].len(), 2);
}
#[test]
fn test_group_secrets_mixed_roles() {
let secrets = vec![
SecretPrefetchConfig {
secret_id: "s1".into(),
role_arn: None,
},
SecretPrefetchConfig {
secret_id: "s2".into(),
role_arn: Some("arn:aws:iam::111:role/A".into()),
},
SecretPrefetchConfig {
secret_id: "s3".into(),
role_arn: Some("arn:aws:iam::111:role/A".into()),
},
SecretPrefetchConfig {
secret_id: "s4".into(),
role_arn: Some("arn:aws:iam::222:role/B".into()),
},
];
let grouped = group_secrets_by_role(&secrets);
assert_eq!(grouped.len(), 3);
assert_eq!(grouped[&None].len(), 1);
assert_eq!(grouped[&Some("arn:aws:iam::111:role/A".into())].len(), 2);
assert_eq!(grouped[&Some("arn:aws:iam::222:role/B".into())].len(), 1);
}
#[test]
fn test_group_tags_no_roles() {
let tags = vec![
TagFilter {
key: "Env".into(),
role_arn: None,
},
TagFilter {
key: "Team".into(),
role_arn: None,
},
];
let grouped = group_tags_by_role(&tags);
assert_eq!(grouped.len(), 1);
assert_eq!(grouped[&None].len(), 2);
}
#[test]
fn test_group_tags_mixed_roles() {
let tags = vec![
TagFilter {
key: "Env".into(),
role_arn: None,
},
TagFilter {
key: "Team".into(),
role_arn: Some("arn:aws:iam::111:role/A".into()),
},
TagFilter {
key: "App".into(),
role_arn: Some("arn:aws:iam::111:role/A".into()),
},
];
let grouped = group_tags_by_role(&tags);
assert_eq!(grouped.len(), 2);
assert_eq!(grouped[&None].len(), 1);
assert_eq!(grouped[&Some("arn:aws:iam::111:role/A".into())].len(), 2);
}
#[tokio::test]
async fn test_fetch_secret_id_group_success() {
let config = SecretsManagerConfig::default();
let cache_manager = test_cache_manager(&config).await;
let mut stats = PrefetchStats::default();
fetch_secret_id_group(
&cache_manager,
None,
&["MyTest".to_string()],
&mut 100,
&mut stats,
)
.await;
assert_eq!(stats.success, 1);
assert_eq!(stats.failed, 0);
let client = cache_manager.get_client(None).await.unwrap();
assert!(client.cache_contains("MyTest").await);
}
#[tokio::test]
async fn test_fetch_secret_id_group_with_role() {
let config = SecretsManagerConfig::default();
let cache_manager = test_cache_manager(&config).await;
let mut stats = PrefetchStats::default();
let role = "arn:aws:iam::123456789012:role/TestRole";
fetch_secret_id_group(
&cache_manager,
Some(role),
&["MyTest".to_string()],
&mut 100,
&mut stats,
)
.await;
assert_eq!(stats.success, 1);
assert_eq!(stats.failed, 0);
let client = cache_manager.get_client(Some(role)).await.unwrap();
assert!(client.cache_contains("MyTest").await);
}
#[tokio::test]
async fn test_fetch_secret_id_group_buffer_limit() {
let config = SecretsManagerConfig::default();
let cache_manager = test_cache_manager(&config).await;
let mut stats = PrefetchStats::default();
fetch_secret_id_group(
&cache_manager,
None,
&["MyTest".to_string(), "MyTest2".to_string()],
&mut 0,
&mut stats,
)
.await;
assert_eq!(stats.success, 0);
}
#[tokio::test]
async fn test_run_prefetch_with_secrets() {
let config = SecretsManagerConfig {
prefetch: PrefetchConfig {
secrets: vec![SecretPrefetchConfig {
secret_id: "MyTest".into(),
role_arn: None,
}],
..Default::default()
},
..Default::default()
};
let cache_manager = test_cache_manager(&config).await;
let mut stats = PrefetchStats::default();
run_prefetch(&cache_manager, &config, &mut stats).await;
assert_eq!(stats.success, 1);
assert_eq!(stats.failed, 0);
}
#[tokio::test]
async fn test_run_prefetch_empty_config() {
let config = SecretsManagerConfig::default();
let cache_manager = test_cache_manager(&config).await;
let mut stats = PrefetchStats::default();
run_prefetch(&cache_manager, &config, &mut stats).await;
assert_eq!(stats.success, 0);
assert_eq!(stats.failed, 0);
}
#[tokio::test]
async fn test_fetch_secret_id_group_api_error() {
let config = SecretsManagerConfig::default();
let cache_manager = test_cache_manager(&config).await;
let mut stats = PrefetchStats::default();
fetch_secret_id_group(
&cache_manager,
None,
&["BATCHAPIERROR_secret".to_string()],
&mut 100,
&mut stats,
)
.await;
assert_eq!(stats.success, 0);
assert_eq!(stats.failed, 1);
}
#[tokio::test]
async fn test_fetch_secret_id_group_partial_failure() {
let config = SecretsManagerConfig::default();
let cache_manager = test_cache_manager(&config).await;
let mut stats = PrefetchStats::default();
fetch_secret_id_group(
&cache_manager,
None,
&["NOTFOUNDsecret".to_string(), "ValidSecret".to_string()],
&mut 100,
&mut stats,
)
.await;
assert_eq!(stats.success, 1);
assert_eq!(stats.failed, 1);
}
#[tokio::test]
async fn test_fetch_tag_group_success() {
let config = SecretsManagerConfig::default();
let cache_manager = test_cache_manager(&config).await;
let mut stats = PrefetchStats::default();
fetch_tag_group(
&cache_manager,
None,
&["Environment".to_string()],
&mut 100,
&mut stats,
)
.await;
assert_eq!(stats.success, 1);
assert_eq!(stats.failed, 0);
let client = cache_manager.get_client(None).await.unwrap();
assert!(client.cache_contains("TaggedSecret").await);
}
#[tokio::test]
async fn test_fetch_secret_id_group_client_failure_counts_total() {
let config = SecretsManagerConfig {
max_roles: 2,
..Default::default()
};
let cache_manager = test_cache_manager(&config).await;
let mut stats = PrefetchStats::default();
fetch_secret_id_group(
&cache_manager,
Some("arn:aws:iam::111111111111:role/Role1"),
&["MyTest".to_string()],
&mut 100,
&mut stats,
)
.await;
fetch_secret_id_group(
&cache_manager,
Some("arn:aws:iam::222222222222:role/Role2"),
&["MyTest".to_string()],
&mut 100,
&mut stats,
)
.await;
let mut stats3 = PrefetchStats::default();
fetch_secret_id_group(
&cache_manager,
Some("arn:aws:iam::333333333333:role/Role3"),
&["MyTest".to_string(), "MyTest2".to_string()],
&mut 100,
&mut stats3,
)
.await;
assert_eq!(stats3.failed, 2);
assert_eq!(stats3.success, 0);
}
#[tokio::test]
async fn test_fetch_tag_group_buffer_limit() {
let config = SecretsManagerConfig::default();
let cache_manager = test_cache_manager(&config).await;
let mut stats = PrefetchStats::default();
fetch_tag_group(
&cache_manager,
None,
&["Env".to_string()],
&mut 0,
&mut stats,
)
.await;
assert_eq!(stats.success, 0);
}
}