use crate::{
error::{map_cloud_client_error, ErrorData, Result},
traits::{
ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions, Binding,
CrossAccountAccess, CrossAccountPermissions, RepositoryResponse,
},
};
use alien_azure_clients::long_running_operation::{LongRunningOperationClient, OperationResult};
use alien_azure_clients::models::containerregistry::ScopeMapProperties;
use alien_azure_clients::{
containerregistry::{AzureContainerRegistryClient, ContainerRegistryApi},
AzureClientConfig, AzureTokenCache,
};
use alien_core::bindings::ArtifactRegistryBinding;
use alien_error::{AlienError, Context, IntoAlienError};
use async_trait::async_trait;
use tracing::{info, warn};
#[derive(Debug)]
pub struct AcrArtifactRegistry {
acr_client: AzureContainerRegistryClient,
lro_client: LongRunningOperationClient,
binding_name: String,
registry_name: String,
registry_endpoint: String,
resource_group_name: String,
repository_prefix: String,
azure_token_cache: AzureTokenCache,
http_client: reqwest::Client,
}
impl AcrArtifactRegistry {
pub async fn new(
binding_name: String,
binding: ArtifactRegistryBinding,
azure_config: &AzureClientConfig,
) -> Result<Self> {
info!(
binding_name = %binding_name,
"Initializing Azure Container Registry"
);
let config = match binding {
ArtifactRegistryBinding::Acr(config) => config,
_ => {
return Err(AlienError::new(ErrorData::BindingConfigInvalid {
binding_name: binding_name.clone(),
reason: "Expected ACR binding, got different service type".to_string(),
}));
}
};
let registry_name = config
.registry_name
.into_value(&binding_name, "registry_name")
.context(ErrorData::BindingConfigInvalid {
binding_name: binding_name.clone(),
reason: "Failed to extract registry_name from binding".to_string(),
})?;
let resource_group_name = config
.resource_group_name
.into_value(&binding_name, "resource_group_name")
.context(ErrorData::BindingConfigInvalid {
binding_name: binding_name.clone(),
reason: "Failed to extract resource_group_name from binding".to_string(),
})?;
let registry_endpoint = format!("{}.azurecr.io", registry_name);
let client = crate::http_client::create_http_client();
let token_cache_1 = AzureTokenCache::new(azure_config.clone());
let token_cache_2 = AzureTokenCache::new(azure_config.clone());
let token_cache_3 = AzureTokenCache::new(azure_config.clone());
let acr_client = AzureContainerRegistryClient::new(client.clone(), token_cache_1);
let lro_client = LongRunningOperationClient::new(client.clone(), token_cache_2);
let repository_prefix = match config.repository_prefix {
Some(bv) => bv
.into_value(&binding_name, "repository_prefix")
.unwrap_or_default(),
None => String::new(),
};
Ok(Self {
acr_client,
lro_client,
binding_name,
registry_name,
registry_endpoint,
resource_group_name,
repository_prefix,
azure_token_cache: token_cache_3,
http_client: client,
})
}
fn make_azure_resource_name(&self, repo_name: &str, suffix: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let max_length = 49;
let combined = format!("{}-{}", repo_name, suffix);
let is_valid = combined.len() >= 5
&& combined.len() <= max_length
&& combined
.chars()
.next()
.map_or(false, |c| c.is_ascii_alphabetic())
&& combined
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
&& !combined.contains("--");
if is_valid {
combined
} else {
let mut hasher = DefaultHasher::new();
repo_name.hash(&mut hasher);
suffix.hash(&mut hasher);
let hash = hasher.finish();
format!("r{:x}-{}", hash, suffix)
}
}
}
impl Binding for AcrArtifactRegistry {}
#[async_trait]
impl ArtifactRegistry for AcrArtifactRegistry {
fn registry_endpoint(&self) -> String {
format!("https://{}", self.registry_endpoint)
}
fn upstream_repository_prefix(&self) -> String {
self.repository_prefix.clone()
}
async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
info!(
repo_name = %repo_name,
registry_name = %self.registry_name,
"Creating Azure Container Registry repository (via scope map)"
);
let scope_map_name = self.make_azure_resource_name(repo_name, "scope");
let actions = vec![
format!("repositories/{}/content/read", repo_name),
format!("repositories/{}/content/write", repo_name),
];
let scope_map_properties = ScopeMapProperties {
description: Some(format!("Scope map for repository {}", repo_name)),
actions,
creation_date: None,
provisioning_state: None,
type_: None,
};
match self
.acr_client
.create_scope_map(
&self.resource_group_name,
&self.registry_name,
&scope_map_name,
&scope_map_properties,
)
.await
{
Ok(operation_result) => {
match operation_result {
OperationResult::Completed(_) => {
info!(
repo_name = %repo_name,
"Azure Container Registry repository scope map created successfully"
);
let repository_uri = format!("{}/{}", self.registry_endpoint, repo_name);
Ok(RepositoryResponse {
name: repo_name.to_string(),
uri: Some(repository_uri),
created_at: None, })
}
OperationResult::LongRunning(_) => {
info!(
repo_name = %repo_name,
"Azure Container Registry repository scope map creation is in progress"
);
Ok(RepositoryResponse {
name: repo_name.to_string(),
uri: None, created_at: None,
})
}
}
}
Err(e) => {
warn!(
repo_name = %repo_name,
error = %e,
"Failed to create Azure Container Registry repository scope map"
);
Err(map_cloud_client_error(
e,
format!(
"Failed to create Azure Container Registry repository '{}'",
repo_name
),
Some(repo_name.to_string()),
))
}
}
}
async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
let repo_name = repo_id;
let scope_map_name = self.make_azure_resource_name(repo_name, "scope");
info!(
repo_name = %repo_name,
registry_name = %self.registry_name,
"Getting Azure Container Registry repository details"
);
let scope_map = self
.acr_client
.get_scope_map(
&self.resource_group_name,
&self.registry_name,
&scope_map_name,
)
.await
.map_err(|_e| {
warn!(
repo_name = %repo_name,
"Azure Container Registry repository not found"
);
AlienError::new(ErrorData::ResourceNotFound {
resource_id: repo_name.to_string(),
})
})?;
let repository_uri = format!("{}/{}", self.registry_endpoint, repo_name);
let created_at = scope_map.properties.and_then(|props| props.creation_date);
info!(
repo_name = %repo_name,
repo_uri = %repository_uri,
"Azure Container Registry repository details retrieved"
);
Ok(RepositoryResponse {
name: repo_name.to_string(),
uri: Some(repository_uri),
created_at,
})
}
async fn add_cross_account_access(
&self,
repo_id: &str,
_access: CrossAccountAccess,
) -> Result<()> {
let repo_name = repo_id;
info!(
repo_name = %repo_name,
registry_name = %self.registry_name,
"Azure Container Registry cross-account access not supported"
);
Err(AlienError::new(ErrorData::OperationNotSupported {
operation: "add_cross_account_access".to_string(),
reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
}))
}
async fn remove_cross_account_access(
&self,
repo_id: &str,
_access: CrossAccountAccess,
) -> Result<()> {
let repo_name = repo_id;
info!(
repo_name = %repo_name,
registry_name = %self.registry_name,
"Azure Container Registry cross-account access not supported"
);
Err(AlienError::new(ErrorData::OperationNotSupported {
operation: "remove_cross_account_access".to_string(),
reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
}))
}
async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
let repo_name = repo_id;
info!(
repo_name = %repo_name,
registry_name = %self.registry_name,
"Azure Container Registry cross-account access not supported"
);
Err(AlienError::new(ErrorData::OperationNotSupported {
operation: "get_cross_account_access".to_string(),
reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
}))
}
async fn generate_credentials(
&self,
repo_id: &str,
permissions: ArtifactRegistryPermissions,
_ttl_seconds: Option<u32>,
) -> Result<ArtifactRegistryCredentials> {
info!(
registry = %self.registry_endpoint,
repo_id = %repo_id,
permissions = ?permissions,
"Generating ACR credentials via AAD → refresh → access token flow"
);
let aad_token = self
.azure_token_cache
.get_bearer_token_with_scope("https://management.azure.com/.default")
.await
.map_err(|e| {
map_cloud_client_error(e, "Failed to get AAD token for ACR".to_string(), None)
})?;
let exchange_url = format!("https://{}/oauth2/exchange", self.registry_endpoint);
let exchange_resp = self
.http_client
.post(&exchange_url)
.form(&[
("grant_type", "access_token"),
("service", &self.registry_endpoint),
("access_token", &aad_token),
])
.send()
.await
.into_alien_error()
.context(ErrorData::Other {
message: "ACR OAuth2 exchange request failed".to_string(),
})?;
if !exchange_resp.status().is_success() {
let status = exchange_resp.status();
let body = exchange_resp.text().await.unwrap_or_default();
return Err(AlienError::new(ErrorData::Other {
message: format!("ACR OAuth2 exchange failed with {}: {}", status, body),
}));
}
#[derive(serde::Deserialize)]
struct ExchangeResponse {
refresh_token: String,
}
let refresh_token = exchange_resp
.json::<ExchangeResponse>()
.await
.into_alien_error()
.context(ErrorData::Other {
message: "Failed to parse ACR exchange response".to_string(),
})?
.refresh_token;
let scope = if repo_id.is_empty() {
"registry:catalog:*".to_string()
} else {
let actions = match permissions {
ArtifactRegistryPermissions::Pull => "pull",
ArtifactRegistryPermissions::PushPull => "pull,push",
};
format!("repository:{}:{}", repo_id, actions)
};
let token_url = format!("https://{}/oauth2/token", self.registry_endpoint);
let token_resp = self
.http_client
.post(&token_url)
.form(&[
("grant_type", "refresh_token"),
("service", &self.registry_endpoint),
("scope", &scope),
("refresh_token", &refresh_token),
])
.send()
.await
.into_alien_error()
.context(ErrorData::Other {
message: "ACR OAuth2 token request failed".to_string(),
})?;
if !token_resp.status().is_success() {
let status = token_resp.status();
let body = token_resp.text().await.unwrap_or_default();
return Err(AlienError::new(ErrorData::Other {
message: format!("ACR OAuth2 token failed with {}: {}", status, body),
}));
}
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
}
let access_token = token_resp
.json::<TokenResponse>()
.await
.into_alien_error()
.context(ErrorData::Other {
message: "Failed to parse ACR token response".to_string(),
})?
.access_token;
info!(
registry = %self.registry_endpoint,
scope = %scope,
"ACR access token generated"
);
Ok(ArtifactRegistryCredentials {
username: String::new(),
password: access_token,
expires_at: None,
})
}
async fn cleanup_credentials(&self, repo_id: &str) -> Result<()> {
let (naming_key, repo_name) = if let Some((_prefix, repo)) = repo_id.split_once("--") {
(repo_id, repo)
} else {
(repo_id, repo_id)
};
let scope_map_name = self.make_azure_resource_name(naming_key, "pull-scope");
let token_name = self.make_azure_resource_name(naming_key, "pull-token");
info!(
repo_name = %repo_name,
scope_map = %scope_map_name,
token = %token_name,
registry_name = %self.registry_name,
"Cleaning up Azure ACR credentials: deleting token and scope map"
);
if let Err(e) = self
.acr_client
.delete_token(&self.resource_group_name, &self.registry_name, &token_name)
.await
{
warn!(token = %token_name, error = %e, "Failed to delete ACR token (may not exist)");
}
if let Err(e) = self
.acr_client
.delete_scope_map(
&self.resource_group_name,
&self.registry_name,
&scope_map_name,
)
.await
{
warn!(scope_map = %scope_map_name, error = %e, "Failed to delete ACR scope map (may not exist)");
}
Ok(())
}
async fn delete_repository(&self, repo_id: &str) -> Result<()> {
let repo_name = repo_id;
let scope_map_name = self.make_azure_resource_name(repo_name, "scope");
info!(
repo_name = %repo_name,
registry_name = %self.registry_name,
"Deleting Azure Container Registry repository scope map"
);
match self
.acr_client
.delete_scope_map(
&self.resource_group_name,
&self.registry_name,
&scope_map_name,
)
.await
{
Ok(_) => {
info!(
repo_name = %repo_name,
"Azure Container Registry repository scope map deleted successfully"
);
let pull_scope_name = self.make_azure_resource_name(repo_name, "pull-scope");
let pull_token_name = self.make_azure_resource_name(repo_name, "pull-token");
let _ = self
.acr_client
.delete_token(
&self.resource_group_name,
&self.registry_name,
&pull_token_name,
)
.await;
let _ = self
.acr_client
.delete_scope_map(
&self.resource_group_name,
&self.registry_name,
&pull_scope_name,
)
.await;
Ok(())
}
Err(e) => {
warn!(
repo_name = %repo_name,
error = %e,
"Failed to delete Azure Container Registry repository scope map"
);
Err(map_cloud_client_error(
e,
format!(
"Failed to delete Azure Container Registry repository '{}'",
repo_name
),
Some(repo_name.to_string()),
))
}
}
}
}