use std::{env, net::SocketAddr, path::PathBuf, time::Duration}; use admin_api::{ app::build_app, auth::{AuthSettings, BootstrapAdminConfig}, service::AdminServiceBuilder, state::AppState, }; use crank_community_auth::PasswordIdentityProvider; use crank_observability::{ CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle, capture_critical_error, }; use crank_registry::{PostgresPoolConfig, PostgresRegistry}; use crank_runtime::{ RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores, RuntimeLimits, SecretCrypto, }; use sqlx::{PgPool, postgres::PgConnectOptions}; use tokio::net::TcpListener; use tracing::{info, warn}; #[tokio::main] async fn main() -> Result<(), Box> { let observability = crank_observability::init(ObservabilityConfig::from_env( "admin-api", env!("CARGO_PKG_VERSION"), "admin_api=info,tower_http=info", )?)?; let mut startup_completed = false; let result = run(&observability, &mut startup_completed).await; if result.is_err() { capture_critical_error(if startup_completed { CriticalErrorCategory::Internal } else { CriticalErrorCategory::Startup }); } result } async fn run( observability: &ObservabilityLifecycle, startup_completed: &mut bool, ) -> Result<(), Box> { let metrics_config = MetricsConfig::from_env("CRANK_ADMIN_METRICS_BIND", "127.0.0.1:9464".parse()?)?; let metrics_enabled = metrics_config.enabled(); let metrics_server = if metrics_config.enabled() { Some(observability.metrics_surface(metrics_config).bind().await?) } else { None }; let storage_root = PathBuf::from( env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()), ); let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into()); let base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()); let socket_addr: SocketAddr = bind_addr.parse()?; let pool_config = PostgresPoolConfig::from_env()?; let registry = PostgresRegistry::connect_with_options_and_pool_config( database_options_from_env()?, pool_config, ) .await?; if metrics_enabled { spawn_postgres_pool_metrics(registry.pool().clone()); } let auth_settings = AuthSettings { session_secret: env::var("CRANK_SESSION_SECRET")?, password_pepper: env::var("CRANK_PASSWORD_PEPPER")?, session_ttl_hours: env::var("CRANK_SESSION_TTL_HOURS") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(24), cookie_secure: base_url.starts_with("https://"), bootstrap_admin: BootstrapAdminConfig { email: env::var("CRANK_BOOTSTRAP_ADMIN_EMAIL")?, password: env::var("CRANK_BOOTSTRAP_ADMIN_PASSWORD")?, display_name: env::var("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME") .unwrap_or_else(|_| "Crank Owner".into()), }, }; let runtime_limits = RuntimeLimits::from_env()?; let cache_config = RuntimeCacheConfig::from_env()?; let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?; let api_rate_limit = admin_api_rate_limit_config_from_env()?; let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; let outbound_http_policy = crank_runtime::OutboundHttpPolicy::from_env()?; let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy.clone()) .with_limits(runtime_limits) .with_response_cache(cache_stores.response.clone()) .with_coordination_store(cache_stores.coordination.clone()) .build(); let identity_provider = PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone()); let service = AdminServiceBuilder::new( registry, storage_root, auth_settings, secret_crypto, runtime, ) .with_outbound_http_policy(outbound_http_policy) .with_identity_provider(std::sync::Arc::new(identity_provider)) .build(); let invocation_log_retention_days = positive_i64_from_env("CRANK_INVOCATION_LOG_RETENTION_DAYS", 30)?; service.bootstrap_admin_user().await?; if env_flag("CRANK_DEMO_SEED") { service.seed_demo_assets().await?; } spawn_invocation_log_cleanup(service.clone(), invocation_log_retention_days); let state = AppState { service, api_rate_limiter: if cache_config.backend.is_external() { RequestRateLimiter::new_shared(api_rate_limit, cache_stores.rate_limit.clone()) } else { RequestRateLimiter::new(api_rate_limit) }, trust_forwarded_headers: env_flag("CRANK_TRUST_FORWARDED_HEADERS"), }; let app = build_app(state); let listener = TcpListener::bind(socket_addr).await?; let make_service = app.into_make_service_with_connect_info::(); info!( name: "admin.postgres_pool.configured", runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary, admin_rate_limit_rps = api_rate_limit.requests_per_second, admin_rate_limit_burst = api_rate_limit.burst, cache_backend = %cache_config.backend, max_connections = pool_config.max_connections, min_connections = pool_config.min_connections, acquire_timeout_ms = pool_config.acquire_timeout_ms, idle_timeout_ms = pool_config.idle_timeout_ms, max_lifetime_ms = pool_config.max_lifetime_ms, invocation_log_retention_days, "postgres pool configured" ); info!( name: "admin.server.listening", bind_address = %socket_addr, "admin-api listening" ); *startup_completed = true; if let Some(metrics_server) = metrics_server { tokio::select! { result = axum::serve(listener, make_service) => result?, result = metrics_server.serve() => result?, } } else { axum::serve(listener, make_service).await?; } Ok(()) } fn positive_i64_from_env( name: &'static str, default: i64, ) -> Result> { let value = match env::var(name) { Ok(raw) => raw.parse::()?, Err(env::VarError::NotPresent) => default, Err(error) => return Err(error.into()), }; if value <= 0 { return Err(format!("{name} must be greater than zero").into()); } Ok(value) } fn spawn_invocation_log_cleanup(service: admin_api::service::AdminService, retention_days: i64) { tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(60 * 60)); loop { interval.tick().await; let cutoff = time::OffsetDateTime::now_utc() - time::Duration::days(retention_days); match service.cleanup_invocation_logs_before(cutoff).await { Ok(removed) if removed > 0 => info!( name: "admin.invocation_log_cleanup.completed", removed, "expired invocation logs removed" ), Ok(_) => {} Err(_) => warn!( name: "admin.invocation_log_cleanup.failed", error_category = "registry_cleanup", "failed to remove expired invocation logs" ), } } }); } fn spawn_postgres_pool_metrics(pool: PgPool) { tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(5)); loop { interval.tick().await; crank_observability::record_db_pool_connections(pool.size(), pool.num_idle()); } }); } fn env_flag(name: &str) -> bool { matches!( env::var(name) .ok() .as_deref() .map(str::to_ascii_lowercase) .as_deref(), Some("1" | "true" | "yes" | "on") ) } fn admin_api_rate_limit_config_from_env() -> Result> { let requests_per_second = env::var("CRANK_ADMIN_RATE_LIMIT_RPS") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(30); let burst = env::var("CRANK_ADMIN_RATE_LIMIT_BURST") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(60); Ok(RequestRateLimitConfig::new(requests_per_second, burst)?) } fn database_options_from_env() -> Result> { if let Ok(database_url) = env::var("CRANK_DATABASE_URL") { return Ok(database_url.parse::()?); } let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into()); let port = env::var("POSTGRES_PORT") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(5432); let database = env::var("POSTGRES_DB").unwrap_or_else(|_| "crank".into()); let username = env::var("POSTGRES_USER").unwrap_or_else(|_| "crank".into()); let password = env::var("POSTGRES_PASSWORD").unwrap_or_else(|_| "crank".into()); Ok(PgConnectOptions::new() .host(&host) .port(port) .database(&database) .username(&username) .password(&password)) }