psenv 0.10.0

A CLI tool to fetch secrets from AWS Parameter Store and generate .env files
Documentation
use anyhow::{anyhow, Context, Result};
use aws_config::meta::region::RegionProviderChain;
use aws_config::profile::ProfileFileCredentialsProvider;
use aws_config::retry::RetryConfig;
use aws_config::{BehaviorVersion, Region};
use aws_credential_types::provider::ProvideCredentials;
use aws_sdk_ssm::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_ssm::Client;
use log::debug;

const CRED_HINT: &str = "\n\n💡 Configure AWS credentials with one of:\n  \
    • aws configure                                 (static keys in ~/.aws/credentials)\n  \
    • aws configure sso && aws sso login            (AWS SSO)\n  \
    • export AWS_PROFILE=<profile>                  (use a named profile)\n  \
    • export AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=…  (environment variables)";

pub struct AwsClient {
    ssm_client: Client,
}

impl AwsClient {
    pub async fn new(region: Option<&str>, profile: Option<&str>) -> Result<Self> {
        let mut config_loader = aws_config::defaults(BehaviorVersion::latest())
            // Adaptive retry adds client-side rate limiting on top of exponential
            // backoff + jitter, which is well suited to bulk parameter fetches
            // where we often hit SSM's per-account TPS limit. 8 attempts covers
            // transient throttling / 5xx without blocking the caller too long.
            .retry_config(RetryConfig::adaptive().with_max_attempts(8));

        // Set region if provided
        if let Some(region) = region {
            let region_provider = RegionProviderChain::first_try(Region::new(region.to_string()));
            config_loader = config_loader.region(region_provider);
        }

        // Set profile if provided
        if let Some(profile) = profile {
            let credentials_provider = ProfileFileCredentialsProvider::builder()
                .profile_name(profile)
                .build();
            config_loader = config_loader.credentials_provider(credentials_provider);
        }

        let config = config_loader.load().await;

        debug!("Initialized AWS config with region: {:?}", config.region());

        // Verify credentials are resolvable up-front. Without this check, a
        // missing-credentials environment surfaces as a cryptic
        // `GetParameterError::Unhandled` after each parameter fetch retries 8
        // times — N parameters × 8 retries of useless noise before failing.
        let provider = config.credentials_provider().ok_or_else(|| {
            anyhow!("No AWS credentials provider configured.{}", CRED_HINT)
        })?;

        provider.provide_credentials().await.map_err(|e| {
            anyhow!("AWS credentials not found or invalid: {}{}", e, CRED_HINT)
        })?;

        let ssm_client = Client::new(&config);

        Ok(AwsClient { ssm_client })
    }

    pub async fn get_parameter(&self, name: &str) -> Result<Option<String>> {
        debug!("Getting parameter: {}", name);

        let err = match self
            .ssm_client
            .get_parameter()
            .name(name)
            .with_decryption(true)
            .send()
            .await
        {
            Ok(result) => {
                let value = result.parameter.and_then(|p| p.value);
                if value.is_some() {
                    debug!("Successfully retrieved parameter: {}", name);
                } else {
                    debug!("Parameter {} present but has no value", name);
                }
                return Ok(value);
            }
            Err(err) => err,
        };

        // Distinguish transport-level failures (network, timeouts, mid-run
        // credential expiry) from service-side errors. Calling
        // `into_service_error()` on a non-service variant would either panic
        // or collapse the error into an `Unhandled` value that prints as
        // "Unknown (unhandled error)" — useless for diagnosing root causes.
        match err {
            SdkError::DispatchFailure(failure) => {
                let detail = if failure.is_io() {
                    format!("network I/O error: {:?}", failure.as_connector_error())
                } else if failure.is_timeout() {
                    "request timed out before reaching AWS".to_string()
                } else if failure.is_user() {
                    // Credential / signing failures land here.
                    format!(
                        "client-side failure (likely expired credentials or signing): {:?}{}",
                        failure.as_connector_error(),
                        CRED_HINT
                    )
                } else {
                    format!("dispatch failure: {:?}", failure.as_connector_error())
                };
                Err(anyhow!("AWS SSM dispatch error: {}", detail))
                    .with_context(|| format!("Failed to get parameter: {}", name))
            }
            SdkError::TimeoutError(_) => Err(anyhow!("AWS SSM request timed out"))
                .with_context(|| format!("Failed to get parameter: {}", name)),
            SdkError::ConstructionFailure(c) => {
                Err(anyhow!("AWS SSM request construction failed: {:?}", c))
                    .with_context(|| format!("Failed to get parameter: {}", name))
            }
            SdkError::ResponseError(r) => Err(anyhow!("AWS SSM response error: {:?}", r))
                .with_context(|| format!("Failed to get parameter: {}", name)),
            err => {
                // ServiceError (and any future non-transport variants).
                let code = err.code().unwrap_or("Unknown").to_string();
                let message = err.message().map(|m| m.to_string());
                let service_err = err.into_service_error();
                match service_err {
                    aws_sdk_ssm::operation::get_parameter::GetParameterError::ParameterNotFound(_) => {
                        debug!("Parameter not found: {}", name);
                        Ok(None)
                    }
                    _ => {
                        let detail = match &message {
                            Some(m) if !m.is_empty() => format!("{} - {}", code, m),
                            _ => format!("{} ({})", code, service_err),
                        };
                        Err(anyhow!("AWS SSM error: {}", detail))
                            .with_context(|| format!("Failed to get parameter: {}", name))
                    }
                }
            }
        }
    }
}