pub use error::RegistryError;
mod discovery;
mod error;
mod models;
mod modules;
mod providers;
use axum::{Router, routing::get};
use base64::prelude::*;
use octocrab::Octocrab;
use octocrab::models::AppId;
use octocrab::service::middleware::base_uri::BaseUriLayer;
use octocrab::service::middleware::extra_headers::ExtraHeadersLayer;
use secrecy::SecretString;
use std::fmt;
use std::sync::Arc;
use tower::ServiceBuilder;
use tower_http::trace::{
DefaultMakeSpan, DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer,
};
use tracing::Level;
const PROVIDERS_API_BASE_URL: &str = "/terraform/providers/v1/";
const MODULES_API_BASE_URL: &str = "/terraform/modules/v1/";
pub struct Registry {
state: Arc<AppState>,
}
impl fmt::Debug for Registry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Registry")
.field("state", &self.state)
.finish()
}
}
impl Registry {
pub fn builder() -> RegistryBuilder {
RegistryBuilder::default()
}
pub fn create_router(&self) -> Router {
let middleware = ServiceBuilder::new().layer(
TraceLayer::new_for_http()
.make_span_with(
DefaultMakeSpan::new()
.include_headers(true)
.level(Level::DEBUG),
)
.on_request(DefaultOnRequest::new().level(Level::DEBUG))
.on_response(
DefaultOnResponse::new()
.include_headers(true)
.level(Level::DEBUG),
)
.on_failure(DefaultOnFailure::new()),
);
let providers_api = Router::new()
.route(
"/{namespace}/{provider_type}/versions",
get(providers::list_provider_versions),
)
.route(
"/{namespace}/{provider_type}/{version}/download/{os}/{arch}",
get(providers::find_provider_package),
);
let modules_api = Router::new()
.route(
"/{namespace}/{name}/{system}/versions",
get(modules::list_module_versions),
)
.route(
"/{namespace}/{name}/{system}/{version}/download",
get(modules::download_module_version),
);
Router::new()
.route("/.well-known/terraform.json", get(discovery::discovery))
.nest(&self.state.providers_api_base_url, providers_api)
.nest(&self.state.modules_api_base_url, modules_api)
.layer(middleware)
.with_state(self.state.clone())
}
}
#[derive(Debug)]
struct AppState {
github: Octocrab,
no_redirect_github: Octocrab,
gpg_key_id: String,
gpg_public_key: String,
providers_api_base_url: String,
modules_api_base_url: String,
}
#[derive(Default)]
pub struct RegistryBuilder {
base_uri: Option<String>,
auth: Option<GitHubAuth>,
gpg: Option<GPGSigningKey>,
providers_api_base_url: Option<String>,
modules_api_base_url: Option<String>,
}
impl RegistryBuilder {
pub fn providers_api_base_url(mut self, url: impl Into<String>) -> Self {
self.providers_api_base_url = Some(url.into());
self
}
pub fn modules_api_base_url(mut self, url: impl Into<String>) -> Self {
self.modules_api_base_url = Some(url.into());
self
}
pub fn github_base_uri(mut self, base_uri: String) -> Self {
self.base_uri = Some(base_uri);
self
}
pub fn github_token(mut self, token: impl Into<String>) -> Self {
self.auth = Some(GitHubAuth::PersonalToken(token.into()));
self
}
pub fn github_app(mut self, app_id: u64, private_key: EncodingKey) -> Self {
self.auth = Some(GitHubAuth::App {
app_id,
private_key,
});
self
}
pub fn gpg_signing_key(mut self, key_id: String, public_key: EncodingKey) -> Self {
self.gpg = Some(GPGSigningKey { key_id, public_key });
self
}
pub async fn build(self) -> Result<Registry, RegistryError> {
let Self {
providers_api_base_url,
modules_api_base_url,
base_uri,
auth,
gpg,
} = self;
let auth = auth.ok_or(RegistryError::MissingAuth)?;
let gpg = gpg.ok_or(RegistryError::MissingGPGSigningKey)?;
let providers_api_base_url =
Self::normalize_api_url(providers_api_base_url, PROVIDERS_API_BASE_URL)?;
let modules_api_base_url =
Self::normalize_api_url(modules_api_base_url, MODULES_API_BASE_URL)?;
let github = Self::create_octocrab_client(base_uri.clone(), &auth).await?;
let no_redirect_github =
Self::create_no_redirect_octocrab_client(base_uri.clone(), &auth).await?;
let state = AppState {
github,
no_redirect_github,
providers_api_base_url,
modules_api_base_url,
gpg_key_id: gpg.key_id.clone(),
gpg_public_key: gpg.get_public_key()?,
};
Ok(Registry {
state: Arc::new(state),
})
}
async fn create_octocrab_client(
base_uri: Option<String>,
auth: &GitHubAuth,
) -> Result<Octocrab, RegistryError> {
match auth {
GitHubAuth::PersonalToken(token) => {
if let Some(val) = base_uri {
Octocrab::builder()
.base_uri(val)?
.personal_token(token.clone())
.build()
.map_err(RegistryError::GitHubInit)
} else {
Octocrab::builder()
.personal_token(token.clone())
.build()
.map_err(RegistryError::GitHubInit)
}
}
GitHubAuth::App { app_id, .. } => {
let private_key = auth.get_private_key()?;
let jwt = jsonwebtoken::EncodingKey::from_rsa_pem(&private_key).unwrap();
let client = match base_uri {
Some(val) => octocrab::Octocrab::builder()
.base_uri(val)?
.app(AppId(*app_id), jwt)
.build()?,
None => octocrab::Octocrab::builder()
.app(AppId(*app_id), jwt)
.build()?,
};
let installations = client
.apps()
.installations()
.send()
.await
.unwrap()
.take_items();
let (client, _) = client
.installation_and_token(installations[0].id)
.await
.unwrap();
Ok(client)
}
}
}
async fn create_no_redirect_octocrab_client(
base_uri: Option<String>,
auth: &GitHubAuth,
) -> Result<Octocrab, RegistryError> {
let connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.unwrap()
.https_or_http()
.enable_http1()
.build();
let client =
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build(connector);
let parsed_uri: http::Uri = base_uri
.unwrap_or_else(|| "https://api.github.com".to_string())
.parse()
.map_err(|_| RegistryError::InvalidConfig("invalid base URI".into()))?;
let client = tower::ServiceBuilder::new()
.layer(
TraceLayer::new_for_http()
.make_span_with(
DefaultMakeSpan::new()
.include_headers(true)
.level(Level::DEBUG),
)
.on_request(DefaultOnRequest::new().level(Level::DEBUG))
.on_response(
DefaultOnResponse::new()
.include_headers(true)
.level(Level::DEBUG),
)
.on_failure(DefaultOnFailure::new()),
)
.service(client);
let header_map = Arc::new(vec![
(
http::header::USER_AGENT,
"no-redirect-octocrab".parse().unwrap(),
),
(
http::header::ACCEPT,
"application/octet-stream".parse().unwrap(),
),
]);
match auth {
GitHubAuth::PersonalToken(token) => {
let client = octocrab::OctocrabBuilder::new_empty()
.with_service(client)
.with_layer(&BaseUriLayer::new(parsed_uri))
.with_layer(&ExtraHeadersLayer::new(header_map))
.with_auth(octocrab::AuthState::AccessToken {
token: SecretString::from(token.as_str()),
})
.build()
.unwrap();
Ok(client)
}
GitHubAuth::App { app_id, .. } => {
let private_key = auth.get_private_key()?;
let jwt = jsonwebtoken::EncodingKey::from_rsa_pem(&private_key).unwrap();
let _client = Octocrab::builder()
.app(AppId(*app_id), jwt.clone())
.build()?;
let installations = _client
.apps()
.installations()
.send()
.await
.map_err(RegistryError::GitHubInit)?
.take_items();
let (_, token) = _client
.installation_and_token(installations.first().unwrap().id)
.await
.map_err(RegistryError::GitHubInit)?;
let custom_client = octocrab::OctocrabBuilder::new_empty()
.with_service(client)
.with_layer(&BaseUriLayer::new(parsed_uri.clone()))
.with_layer(&ExtraHeadersLayer::new(header_map))
.with_auth(octocrab::AuthState::AccessToken { token })
.build()
.unwrap();
Ok(custom_client)
}
}
}
fn normalize_api_url(url: Option<String>, default: &str) -> Result<String, RegistryError> {
let url = url.unwrap_or_else(|| default.to_string());
if url.is_empty() {
return Err(RegistryError::InvalidConfig(
"providers API base URL cannot be empty".into(),
));
}
let url = if !url.starts_with('/') {
format!("/{}", url)
} else {
url
};
let url = if !url.ends_with('/') {
format!("{}/", url)
} else {
url
};
Ok(url)
}
}
#[derive(Debug, Clone)]
pub enum EncodingKey {
Pem(String),
Base64(String),
}
#[derive(Debug, Clone)]
enum GitHubAuth {
PersonalToken(String),
App {
app_id: u64,
private_key: EncodingKey,
},
}
impl GitHubAuth {
fn get_private_key(&self) -> Result<Vec<u8>, RegistryError> {
match self {
GitHubAuth::PersonalToken(_) => {
Err(RegistryError::InvalidConfig("not a GitHub App auth".into()))
}
GitHubAuth::App { private_key, .. } => match private_key {
EncodingKey::Pem(val) => Ok(val.clone().into_bytes()),
EncodingKey::Base64(val) => Ok(BASE64_STANDARD.decode(val).unwrap()),
},
}
}
}
#[derive(Clone)]
struct GPGSigningKey {
key_id: String,
public_key: EncodingKey,
}
impl GPGSigningKey {
fn get_public_key(&self) -> Result<String, RegistryError> {
let GPGSigningKey { public_key, .. } = self;
match public_key {
EncodingKey::Pem(val) if val.is_empty() => Err(RegistryError::InvalidConfig(
"pem gpg public key cannot be empty".into(),
)),
EncodingKey::Base64(val) if val.is_empty() => Err(RegistryError::InvalidConfig(
"base64 gpg public key cannot be empty".into(),
)),
EncodingKey::Pem(val) => Ok(val.clone()),
EncodingKey::Base64(val) => {
let decoded = BASE64_STANDARD.decode(val.trim()).map_err(|_| {
RegistryError::InvalidConfig("invalid base64 gpg public key".into())
})?;
let result = String::from_utf8(decoded).map_err(|_| {
RegistryError::InvalidConfig("invalid decoded base64 gpg public key".into())
})?;
Ok(result)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encoding_key_pem() {
let key = EncodingKey::Pem("test-pem-key".to_string());
match key {
EncodingKey::Pem(s) => assert_eq!(s, "test-pem-key"),
_ => panic!("Expected Pem variant"),
}
}
#[test]
fn test_encoding_key_base64() {
let key = EncodingKey::Base64("dGVzdC1iYXNlNjQta2V5".to_string());
match key {
EncodingKey::Base64(s) => assert_eq!(s, "dGVzdC1iYXNlNjQta2V5"),
_ => panic!("Expected Base64 variant"),
}
}
#[test]
fn test_github_auth_personal_token() {
let auth = GitHubAuth::PersonalToken("ghp_test123".to_string());
match auth {
GitHubAuth::PersonalToken(token) => assert_eq!(token, "ghp_test123"),
_ => panic!("Expected PersonalToken variant"),
}
}
#[test]
fn test_github_auth_app() {
let auth = GitHubAuth::App {
app_id: 12345,
private_key: EncodingKey::Pem("test-key".to_string()),
};
match auth {
GitHubAuth::App { app_id, .. } => assert_eq!(app_id, 12345),
_ => panic!("Expected App variant"),
}
}
#[test]
fn test_github_auth_get_private_key_pem() {
let auth = GitHubAuth::App {
app_id: 12345,
private_key: EncodingKey::Pem("test-private-key".to_string()),
};
let key = auth.get_private_key().unwrap();
assert_eq!(key, "test-private-key".as_bytes());
}
#[test]
fn test_github_auth_get_private_key_base64() {
let original = "test-private-key";
let encoded = BASE64_STANDARD.encode(original);
let auth = GitHubAuth::App {
app_id: 12345,
private_key: EncodingKey::Base64(encoded),
};
let key = auth.get_private_key().unwrap();
assert_eq!(key, original.as_bytes());
}
#[test]
fn test_github_auth_get_private_key_personal_token_error() {
let auth = GitHubAuth::PersonalToken("token".to_string());
let result = auth.get_private_key();
assert!(result.is_err());
match result.unwrap_err() {
RegistryError::InvalidConfig(msg) => assert_eq!(msg, "not a GitHub App auth"),
_ => panic!("Expected InvalidConfig error"),
}
}
#[test]
fn test_gpg_signing_key_get_public_key_pem() {
let gpg = GPGSigningKey {
key_id: "ABCD1234".to_string(),
public_key: EncodingKey::Pem("-----BEGIN PGP PUBLIC KEY BLOCK-----".to_string()),
};
let key = gpg.get_public_key().unwrap();
assert_eq!(key, "-----BEGIN PGP PUBLIC KEY BLOCK-----");
}
#[test]
fn test_gpg_signing_key_get_public_key_base64() {
let original =
"-----BEGIN PGP PUBLIC KEY BLOCK-----\ntest\n-----END PGP PUBLIC KEY BLOCK-----";
let encoded = BASE64_STANDARD.encode(original);
let gpg = GPGSigningKey {
key_id: "ABCD1234".to_string(),
public_key: EncodingKey::Base64(encoded),
};
let key = gpg.get_public_key().unwrap();
assert_eq!(key, original);
}
#[test]
fn test_gpg_signing_key_empty_pem_error() {
let gpg = GPGSigningKey {
key_id: "ABCD1234".to_string(),
public_key: EncodingKey::Pem("".to_string()),
};
let result = gpg.get_public_key();
assert!(result.is_err());
match result.unwrap_err() {
RegistryError::InvalidConfig(msg) => {
assert_eq!(msg, "pem gpg public key cannot be empty")
}
_ => panic!("Expected InvalidConfig error"),
}
}
#[test]
fn test_gpg_signing_key_empty_base64_error() {
let gpg = GPGSigningKey {
key_id: "ABCD1234".to_string(),
public_key: EncodingKey::Base64("".to_string()),
};
let result = gpg.get_public_key();
assert!(result.is_err());
match result.unwrap_err() {
RegistryError::InvalidConfig(msg) => {
assert_eq!(msg, "base64 gpg public key cannot be empty")
}
_ => panic!("Expected InvalidConfig error"),
}
}
#[test]
fn test_gpg_signing_key_invalid_base64() {
let gpg = GPGSigningKey {
key_id: "ABCD1234".to_string(),
public_key: EncodingKey::Base64("not-valid-base64!@#$".to_string()),
};
let result = gpg.get_public_key();
assert!(result.is_err());
match result.unwrap_err() {
RegistryError::InvalidConfig(msg) => {
assert_eq!(msg, "invalid base64 gpg public key")
}
_ => panic!("Expected InvalidConfig error"),
}
}
#[test]
fn test_gpg_signing_key_base64_with_whitespace() {
let original = "test-key";
let encoded = format!(" {} ", BASE64_STANDARD.encode(original));
let gpg = GPGSigningKey {
key_id: "ABCD1234".to_string(),
public_key: EncodingKey::Base64(encoded),
};
let key = gpg.get_public_key().unwrap();
assert_eq!(key, original);
}
#[test]
fn test_registry_builder_default() {
let builder = RegistryBuilder::default();
assert!(builder.auth.is_none());
assert!(builder.gpg.is_none());
}
#[test]
fn test_registry_builder_new() {
let builder = Registry::builder();
assert!(builder.auth.is_none());
assert!(builder.gpg.is_none());
}
#[test]
fn test_registry_builder_github_base_uri() {
let builder = Registry::builder().github_base_uri("http://localhost:9000".to_string());
assert!(builder.base_uri.is_some());
let val = builder.base_uri.unwrap();
assert_eq!(val, "http://localhost:9000")
}
#[test]
fn test_registry_builder_github_token() {
let builder = Registry::builder().github_token("ghp_test123");
assert!(builder.auth.is_some());
match builder.auth.unwrap() {
GitHubAuth::PersonalToken(token) => assert_eq!(token, "ghp_test123"),
_ => panic!("Expected PersonalToken"),
}
}
#[test]
fn test_registry_builder_github_app() {
let builder =
Registry::builder().github_app(12345, EncodingKey::Pem("test-key".to_string()));
assert!(builder.auth.is_some());
match builder.auth.unwrap() {
GitHubAuth::App { app_id, .. } => assert_eq!(app_id, 12345),
_ => panic!("Expected App"),
}
}
#[test]
fn test_registry_builder_gpg_signing_key() {
let builder = Registry::builder().gpg_signing_key(
"ABCD1234".to_string(),
EncodingKey::Pem("test-public-key".to_string()),
);
assert!(builder.gpg.is_some());
let gpg = builder.gpg.unwrap();
assert_eq!(gpg.key_id, "ABCD1234");
}
#[test]
fn test_registry_builder_chaining() {
let builder = Registry::builder()
.github_token("ghp_test123")
.gpg_signing_key(
"ABCD1234".to_string(),
EncodingKey::Pem("test-key".to_string()),
);
assert!(builder.auth.is_some());
assert!(builder.gpg.is_some());
}
#[tokio::test]
async fn test_registry_builder_github_base_uri_missing() {
let builder = Registry::builder()
.github_token("ghp_test123")
.gpg_signing_key(
"ABCD1234".to_string(),
EncodingKey::Pem("test-key".to_string()),
);
assert!(builder.base_uri.is_none());
let result = builder.build().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_registry_builder_build_missing_auth() {
let builder = Registry::builder().gpg_signing_key(
"ABCD1234".to_string(),
EncodingKey::Pem("test-key".to_string()),
);
let result = builder.build().await;
assert!(result.is_err());
match result.unwrap_err() {
RegistryError::MissingAuth => {}
_ => panic!("Expected MissingAuth error"),
}
}
#[tokio::test]
async fn test_registry_builder_build_missing_gpg() {
let builder = Registry::builder().github_token("ghp_test123");
let result = builder.build().await;
assert!(result.is_err());
match result.unwrap_err() {
RegistryError::MissingGPGSigningKey => {}
_ => panic!("Expected MissingGPGSigningKey error"),
}
}
#[test]
fn test_normalize_api_url_default() {
let result = RegistryBuilder::normalize_api_url(None, PROVIDERS_API_BASE_URL).unwrap();
assert_eq!(result, PROVIDERS_API_BASE_URL);
}
#[test]
fn test_normalize_api_url_with_slashes() {
let result =
RegistryBuilder::normalize_api_url(Some("/custom/api/".to_string()), "").unwrap();
assert_eq!(result, "/custom/api/");
}
#[test]
fn test_normalize_api_url_missing_leading_slash() {
let result =
RegistryBuilder::normalize_api_url(Some("custom/api/".to_string()), "").unwrap();
assert_eq!(result, "/custom/api/");
}
#[test]
fn test_normalize_api_url_missing_trailing_slash() {
let result =
RegistryBuilder::normalize_api_url(Some("/custom/api".to_string()), "").unwrap();
assert_eq!(result, "/custom/api/");
}
#[test]
fn test_normalize_api_url_missing_both_slashes() {
let result =
RegistryBuilder::normalize_api_url(Some("custom/api".to_string()), "").unwrap();
assert_eq!(result, "/custom/api/");
}
#[test]
fn test_normalize_api_url_empty_error() {
let result = RegistryBuilder::normalize_api_url(Some("".to_string()), "");
assert!(result.is_err());
match result.unwrap_err() {
RegistryError::InvalidConfig(msg) => {
assert!(msg.contains("cannot be empty"));
}
_ => panic!("Expected InvalidConfig error"),
}
}
#[tokio::test]
async fn test_registry_builder_with_custom_providers_url() {
let builder = Registry::builder()
.github_token("ghp_test123")
.gpg_signing_key(
"ABCD1234".to_string(),
EncodingKey::Pem("test-key".to_string()),
)
.providers_api_base_url("/custom/providers/v2/");
assert!(builder.providers_api_base_url.is_some());
let result = builder.build().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_registry_builder_with_custom_modules_url() {
let builder = Registry::builder()
.github_token("ghp_test123")
.gpg_signing_key(
"ABCD1234".to_string(),
EncodingKey::Pem("test-key".to_string()),
)
.modules_api_base_url("/custom/modules/v2/");
assert!(builder.modules_api_base_url.is_some());
let result = builder.build().await;
assert!(result.is_ok());
}
}