use aws_config::{Region, SdkConfig};
use aws_sdk_acm::Client as AcmClient;
use aws_sdk_cloudwatch::Client as CwClient;
use aws_sdk_cloudwatchlogs::Client as CwLogsClient;
use aws_sdk_costexplorer::Client as CostExplorerClient;
use aws_sdk_ec2::Client as Ec2Client;
use aws_sdk_elasticbeanstalk::Client;
use aws_sdk_iam::Client as IamClient;
use aws_sdk_organizations::Client as OrgClient;
use aws_sdk_s3::Client as S3Client;
use aws_sdk_secretsmanager::Client as SecretsClient;
use aws_sdk_sqs::Client as SqsClient;
use aws_sdk_ssm::Client as SsmClient;
use aws_sdk_sts::Client as StsClient;
use chrono::{DateTime, Utc};
use color_eyre::eyre::{eyre, Result, WrapErr};
mod acm;
mod cloudwatch;
mod cost;
mod eb; mod ec2;
mod iam;
mod logs;
mod org;
mod s3;
mod secrets;
mod sqs;
mod ssm;
mod waf;
pub use acm::*;
pub use cloudwatch::*;
pub use cost::*;
pub use eb::*;
pub use ec2::*;
pub use iam::*;
pub use logs::*;
pub use org::*;
pub use s3::*;
pub use secrets::*;
pub use sqs::*;
pub use ssm::*;
#[derive(Clone, Debug)]
pub struct AwsContext {
pub region: String,
pub profile: Option<String>,
pub account_id: Option<String>,
pub caller_arn: Option<String>,
}
#[derive(Clone, Debug)]
pub struct Identity {
pub account_id: Option<String>,
pub caller_arn: Option<String>,
}
pub struct AwsClient {
client: Client,
sqs: SqsClient,
cw: CwClient,
cw_logs: CwLogsClient,
s3: S3Client,
ec2: Ec2Client,
org: std::sync::OnceLock<OrgClient>,
cost: std::sync::OnceLock<CostExplorerClient>,
iam: std::sync::OnceLock<IamClient>,
secrets: std::sync::OnceLock<SecretsClient>,
acm: std::sync::OnceLock<AcmClient>,
ssm: std::sync::OnceLock<SsmClient>,
config: SdkConfig,
pub context: AwsContext,
}
impl AwsClient {
fn cost(&self) -> &CostExplorerClient {
self.cost.get_or_init(|| cost_explorer_client(&self.config))
}
fn iam(&self) -> &IamClient {
self.iam.get_or_init(|| iam_client(&self.config))
}
fn org(&self) -> &OrgClient {
self.org.get_or_init(|| OrgClient::new(&self.config))
}
fn secrets(&self) -> &SecretsClient {
self.secrets
.get_or_init(|| SecretsClient::new(&self.config))
}
fn acm(&self) -> &AcmClient {
self.acm.get_or_init(|| AcmClient::new(&self.config))
}
fn ssm(&self) -> &SsmClient {
self.ssm.get_or_init(|| SsmClient::new(&self.config))
}
pub async fn with(profile: Option<String>, region: Option<String>) -> Result<Self> {
let mut builder = aws_config::defaults(aws_config::BehaviorVersion::latest());
if let Some(p) = profile.clone() {
builder = builder.profile_name(p);
}
if let Some(r) = region.clone() {
builder = builder.region(Region::new(r));
}
let config = builder.load().await;
let resolved_region = config
.region()
.map(|r| r.as_ref().to_string())
.unwrap_or_else(|| "unknown".to_string());
if region.as_deref().is_some_and(|r| r != resolved_region) {
tracing::warn!(
target: "ebman::aws",
requested = ?region,
resolved = %resolved_region,
env_aws_region = ?std::env::var("AWS_REGION").ok(),
env_aws_default_region = ?std::env::var("AWS_DEFAULT_REGION").ok(),
"AwsClient::with region mismatch — explicit override was ignored by SDK"
);
}
let region = resolved_region;
let profile = profile.or_else(|| std::env::var("AWS_PROFILE").ok());
let client = Client::new(&config);
let sqs = SqsClient::new(&config);
let cw = CwClient::new(&config);
let cw_logs = CwLogsClient::new(&config);
let s3 = S3Client::new(&config);
let ec2 = Ec2Client::new(&config);
Ok(Self {
client,
sqs,
cw,
cw_logs,
s3,
ec2,
org: std::sync::OnceLock::new(),
cost: std::sync::OnceLock::new(),
iam: std::sync::OnceLock::new(),
secrets: std::sync::OnceLock::new(),
acm: std::sync::OnceLock::new(),
ssm: std::sync::OnceLock::new(),
config,
context: AwsContext {
region,
profile,
account_id: None,
caller_arn: None,
},
})
}
pub async fn assume_role(target_name: &str, spec: &crate::config::AccountSpec) -> Result<Self> {
let mut builder = aws_config::defaults(aws_config::BehaviorVersion::latest());
if let Some(p) = spec.source_profile.as_ref() {
builder = builder.profile_name(p.clone());
}
if let Some(r) = spec.region.clone() {
builder = builder.region(Region::new(r));
}
let base_config = builder.load().await;
let sts = StsClient::new(&base_config);
let session_name = format!("ebman-{target_name}");
let mut req = sts
.assume_role()
.role_arn(spec.role_arn.clone())
.role_session_name(session_name);
if let Some(eid) = spec.external_id.as_ref() {
req = req.external_id(eid.clone());
}
let resp = req.send().await.wrap_err("sts:AssumeRole failed")?;
let creds = resp
.credentials
.ok_or_else(|| eyre!("sts:AssumeRole returned no credentials"))?;
let access_key = creds.access_key_id;
let secret_key = creds.secret_access_key;
let session_token = creds.session_token;
let aws_creds = aws_credential_types::Credentials::new(
access_key,
secret_key,
Some(session_token),
Some(sts_expiry_to_system_time(creds.expiration.secs())?),
"ebman-assume-role",
);
let mut builder = aws_config::defaults(aws_config::BehaviorVersion::latest());
builder = builder.credentials_provider(aws_creds);
if let Some(r) = spec.region.clone() {
builder = builder.region(Region::new(r));
} else if let Some(r) = base_config.region().cloned() {
builder = builder.region(r);
}
let config = builder.load().await;
let region = config
.region()
.map(|r| r.as_ref().to_string())
.unwrap_or_else(|| "unknown".to_string());
Ok(Self {
client: Client::new(&config),
sqs: SqsClient::new(&config),
cw: CwClient::new(&config),
cw_logs: CwLogsClient::new(&config),
s3: S3Client::new(&config),
ec2: Ec2Client::new(&config),
org: std::sync::OnceLock::new(),
cost: std::sync::OnceLock::new(),
iam: std::sync::OnceLock::new(),
secrets: std::sync::OnceLock::new(),
acm: std::sync::OnceLock::new(),
ssm: std::sync::OnceLock::new(),
config,
context: AwsContext {
region,
profile: Some(target_name.to_string()),
account_id: None,
caller_arn: None,
},
})
}
pub(crate) fn stub() -> Self {
let cfg = aws_config::SdkConfig::builder()
.region(Region::new("us-east-1"))
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
Self::for_tests(
Client::new(&cfg),
SqsClient::new(&cfg),
CwClient::new(&cfg),
CwLogsClient::new(&cfg),
S3Client::new(&cfg),
Ec2Client::new(&cfg),
)
}
pub(crate) fn for_tests(
client: Client,
sqs: SqsClient,
cw: CwClient,
cw_logs: CwLogsClient,
s3: S3Client,
ec2: Ec2Client,
) -> Self {
let config = aws_config::SdkConfig::builder()
.region(Region::new("us-east-1"))
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
Self {
client,
sqs,
cw,
cw_logs,
s3,
ec2,
org: std::sync::OnceLock::new(),
cost: std::sync::OnceLock::new(),
iam: std::sync::OnceLock::new(),
secrets: std::sync::OnceLock::new(),
acm: std::sync::OnceLock::new(),
ssm: std::sync::OnceLock::new(),
config,
context: AwsContext {
region: "us-east-1".to_string(),
profile: None,
account_id: None,
caller_arn: None,
},
}
}
pub async fn verify_identity(&self) -> Result<Identity> {
let ident = StsClient::new(&self.config)
.get_caller_identity()
.send()
.await
.wrap_err("sts get-caller-identity failed")?;
Ok(Identity {
account_id: ident.account,
caller_arn: ident.arn,
})
}
pub async fn fetch_url_text(url: &str) -> Result<String> {
use tokio::process::Command;
let out = Command::new("curl")
.args([
"-s",
"-S",
"--fail-with-body",
"--proto",
"=https",
"--max-time",
"15",
"--no-buffer",
"--",
])
.arg(url)
.output()
.await
.wrap_err("could not invoke curl (is it installed?)")?;
if !out.status.success() {
return Err(eyre!(
"curl exit {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
}
#[derive(Debug)]
#[must_use = "a paginated walk reports whether it was cut short — take \
`.items()` to accept a possibly-short list, or `.complete()` \
to refuse one"]
pub(crate) struct Paged<T> {
items: Vec<T>,
pub(crate) truncated: bool,
}
impl<T> Paged<T> {
pub(crate) fn new(items: Vec<T>, truncated: bool) -> Self {
Self { items, truncated }
}
pub(crate) fn items(self) -> Vec<T> {
self.items
}
pub(crate) fn complete(self, what: &str) -> Result<Vec<T>> {
if self.truncated {
return Err(eyre!(
"{what}: the scan hit its page budget — refusing to report a \
partial result, because this listing is filtered after \
collection and a partial scan looks identical to no match"
));
}
Ok(self.items)
}
}
const MAX_PAGES: usize = 100;
const SCAN_PAGES: usize = 500;
const WALK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(45);
pub(crate) async fn paginate<T, F, Fut>(what: &'static str, page: F) -> Result<Paged<T>>
where
F: FnMut(Option<String>) -> Fut,
Fut: std::future::Future<Output = Result<(Vec<T>, Option<String>)>>,
{
paginate_capped(what, MAX_PAGES, page).await
}
pub(crate) async fn paginate_capped<T, F, Fut>(
what: &'static str,
max_pages: usize,
page: F,
) -> Result<Paged<T>>
where
F: FnMut(Option<String>) -> Fut,
Fut: std::future::Future<Output = Result<(Vec<T>, Option<String>)>>,
{
paginate_until(what, max_pages, WALK_DEADLINE, page).await
}
pub(crate) async fn paginate_until<T, F, Fut>(
what: &'static str,
max_pages: usize,
deadline: std::time::Duration,
mut page: F,
) -> Result<Paged<T>>
where
F: FnMut(Option<String>) -> Fut,
Fut: std::future::Future<Output = Result<(Vec<T>, Option<String>)>>,
{
let mut items = Vec::new();
let mut token: Option<String> = None;
let started = std::time::Instant::now();
for _ in 0..max_pages {
let (batch, next) = page(token.take()).await?;
items.extend(batch);
if started.elapsed() >= deadline {
tracing::warn!(
target: "ebman::aws",
operation = what,
collected = items.len(),
secs = deadline.as_secs(),
"pagination deadline reached — result may be incomplete"
);
return Ok(Paged {
items,
truncated: true,
});
}
match next {
Some(t) if !t.is_empty() => token = Some(t),
_ => {
return Ok(Paged {
items,
truncated: false,
})
}
}
}
tracing::warn!(
target: "ebman::aws",
operation = what,
pages = max_pages,
collected = items.len(),
"pagination cap reached — result may be incomplete"
);
Ok(Paged {
items,
truncated: true,
})
}
type ClientCache = std::sync::Mutex<
std::collections::HashMap<
(Option<String>, String),
(std::time::Instant, std::sync::Arc<AwsClient>),
>,
>;
static CLIENT_CACHE: std::sync::OnceLock<ClientCache> = std::sync::OnceLock::new();
static CACHE_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub(crate) const CLIENT_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
fn client_cache() -> &'static ClientCache {
CLIENT_CACHE.get_or_init(Default::default)
}
static ROLE_CACHE: std::sync::OnceLock<RoleCache> = std::sync::OnceLock::new();
type RoleCache = std::sync::Mutex<
std::collections::HashMap<(String, String), (std::time::Instant, std::sync::Arc<AwsClient>)>,
>;
fn role_cache() -> &'static RoleCache {
ROLE_CACHE.get_or_init(Default::default)
}
pub async fn cached_role_client(
name: &str,
spec: &crate::config::AccountSpec,
) -> Result<std::sync::Arc<AwsClient>> {
use std::sync::atomic::Ordering;
let key = (name.to_string(), spec.region.clone().unwrap_or_default());
let fresh = role_cache().lock().ok().and_then(|c| {
c.get(&key)
.filter(|(built, _)| built.elapsed() < CLIENT_CACHE_TTL)
.map(|(_, client)| client.clone())
});
if let Some(found) = fresh {
return Ok(found);
}
let epoch = CACHE_EPOCH.load(Ordering::SeqCst);
let built = std::sync::Arc::new(AwsClient::assume_role(name, spec).await?);
if let Ok(mut cache) = role_cache().lock() {
if CACHE_EPOCH.load(Ordering::SeqCst) == epoch {
cache.insert(key, (std::time::Instant::now(), built.clone()));
}
}
Ok(built)
}
#[cfg(test)]
pub(crate) fn seed_role_cache_for_tests(
name: &str,
region: &str,
client: std::sync::Arc<AwsClient>,
) {
if let Ok(mut cache) = role_cache().lock() {
cache.insert(
(name.to_string(), region.to_string()),
(std::time::Instant::now(), client),
);
}
}
pub async fn cached_client(
profile: Option<String>,
region: String,
) -> Result<std::sync::Arc<AwsClient>> {
use std::sync::atomic::Ordering;
let key = (profile.clone(), region.clone());
let fresh = client_cache().lock().ok().and_then(|c| {
c.get(&key)
.filter(|(built, _)| built.elapsed() < CLIENT_CACHE_TTL)
.map(|(_, client)| client.clone())
});
if let Some(found) = fresh {
return Ok(found);
}
let epoch = CACHE_EPOCH.load(Ordering::SeqCst);
let built = std::sync::Arc::new(AwsClient::with(profile, Some(region)).await?);
install_if_current(key, epoch, built.clone());
Ok(built)
}
fn install_if_current(
key: (Option<String>, String),
epoch: u64,
client: std::sync::Arc<AwsClient>,
) -> bool {
use std::sync::atomic::Ordering;
let Ok(mut cache) = client_cache().lock() else {
return false;
};
if CACHE_EPOCH.load(Ordering::SeqCst) != epoch {
return false;
}
cache.insert(key, (std::time::Instant::now(), client));
true
}
#[cfg(test)]
pub(crate) static CACHE_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[cfg(test)]
pub(crate) fn install_if_current_for_tests(
key: (Option<String>, String),
epoch: u64,
client: std::sync::Arc<AwsClient>,
) -> bool {
install_if_current(key, epoch, client)
}
#[cfg(test)]
pub(crate) fn is_cached_for_tests(key: &(Option<String>, String)) -> bool {
client_cache()
.lock()
.map(|c| c.contains_key(key))
.unwrap_or(false)
}
#[cfg(test)]
pub(crate) fn cache_epoch_for_tests() -> u64 {
CACHE_EPOCH.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn clear_client_cache() {
CACHE_EPOCH.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if let Ok(mut cache) = client_cache().lock() {
cache.clear();
}
if let Ok(mut cache) = role_cache().lock() {
cache.clear();
}
}
fn global_service_region(operator_region: &str) -> &'static str {
crate::util::partition_for_region(operator_region).global_region
}
fn sts_expiry_to_system_time(secs: i64) -> Result<std::time::SystemTime> {
u64::try_from(secs)
.ok()
.and_then(|s| {
std::time::SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(s))
})
.ok_or_else(|| {
eyre!(
"sts:AssumeRole returned an unusable credential expiry \
({secs}s since the epoch) — refusing rather than treating \
the session as never-expiring"
)
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialHint {
Expired(String),
Invalid(String),
}
pub fn rewrite_credential_error(profile: &str, msg: &str) -> Option<CredentialHint> {
let lower = msg.to_lowercase();
let sso_signals = [
"expiredtoken",
"expired token",
"token has expired",
"the security token included in the request is expired",
"unable to load credentials",
"no credentials in the property bag",
"sso session has expired",
];
if sso_signals.iter().any(|s| lower.contains(s)) {
return Some(CredentialHint::Expired(format!(
"credentials expired — run: aws sso login --profile {profile}"
)));
}
let invalid_creds_signals = [
"invalidclienttokenid",
"the security token included in the request is invalid",
"signaturedoesnotmatch",
"the request signature we calculated does not match",
];
if invalid_creds_signals.iter().any(|s| lower.contains(s)) {
return Some(CredentialHint::Invalid(format!(
"credentials invalid for profile '{profile}' — run: aws configure --profile {profile}"
)));
}
None
}
#[cfg(test)]
mod tests;