alien_bindings/providers/artifact_registry/
acr.rs1use crate::{
2 error::{map_cloud_client_error, ErrorData, Result},
3 traits::{
4 ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions, Binding,
5 CrossAccountAccess, CrossAccountPermissions, RegistryAuthMethod, RepositoryResponse,
6 },
7};
8use alien_azure_clients::{AzureClientConfig, AzureTokenCache};
9use alien_core::bindings::ArtifactRegistryBinding;
10use alien_error::{AlienError, Context, IntoAlienError};
11use async_trait::async_trait;
12use tracing::info;
13
14#[derive(Debug)]
16pub struct AcrArtifactRegistry {
17 registry_name: String,
18 registry_endpoint: String,
19 repository_prefix: String,
20 azure_token_cache: AzureTokenCache,
22 http_client: reqwest::Client,
23}
24
25impl AcrArtifactRegistry {
26 pub async fn new(
32 binding_name: String,
33 binding: ArtifactRegistryBinding,
34 azure_config: &AzureClientConfig,
35 ) -> Result<Self> {
36 info!(
37 binding_name = %binding_name,
38 "Initializing Azure Container Registry"
39 );
40
41 let config = match binding {
43 ArtifactRegistryBinding::Acr(config) => config,
44 _ => {
45 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
46 binding_name: binding_name.clone(),
47 reason: "Expected ACR binding, got different service type".to_string(),
48 }));
49 }
50 };
51
52 let registry_name = config
53 .registry_name
54 .into_value(&binding_name, "registry_name")
55 .context(ErrorData::BindingConfigInvalid {
56 binding_name: binding_name.clone(),
57 reason: "Failed to extract registry_name from binding".to_string(),
58 })?;
59
60 config
61 .resource_group_name
62 .into_value(&binding_name, "resource_group_name")
63 .context(ErrorData::BindingConfigInvalid {
64 binding_name: binding_name.clone(),
65 reason: "Failed to extract resource_group_name from binding".to_string(),
66 })?;
67
68 let registry_endpoint = format!("{}.azurecr.io", registry_name);
70 let client = crate::http_client::create_http_client();
71 let azure_token_cache = AzureTokenCache::new(azure_config.clone());
72
73 let repository_prefix = match config.repository_prefix {
74 Some(bv) => bv.into_value(&binding_name, "repository_prefix").context(
75 ErrorData::BindingConfigInvalid {
76 binding_name: binding_name.clone(),
77 reason: "Failed to extract repository_prefix from binding".to_string(),
78 },
79 )?,
80 None => String::new(),
81 };
82
83 Ok(Self {
84 registry_name,
85 registry_endpoint,
86 repository_prefix,
87 azure_token_cache,
88 http_client: client,
89 })
90 }
91}
92
93impl Binding for AcrArtifactRegistry {}
94
95#[async_trait]
96impl ArtifactRegistry for AcrArtifactRegistry {
97 fn registry_endpoint(&self) -> String {
98 format!("https://{}", self.registry_endpoint)
99 }
100
101 fn upstream_repository_prefix(&self) -> String {
102 self.repository_prefix.clone()
103 }
104
105 async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
106 let repository_uri = format!("{}/{}", self.registry_endpoint, repo_name);
109
110 Ok(RepositoryResponse {
111 name: repo_name.to_string(),
112 uri: Some(repository_uri),
113 created_at: None,
114 })
115 }
116
117 async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
118 let repository_uri = format!("{}/{}", self.registry_endpoint, repo_id);
120
121 Ok(RepositoryResponse {
122 name: repo_id.to_string(),
123 uri: Some(repository_uri),
124 created_at: None,
125 })
126 }
127
128 async fn add_cross_account_access(
129 &self,
130 repo_id: &str,
131 _access: CrossAccountAccess,
132 ) -> Result<()> {
133 let repo_name = repo_id;
134
135 info!(
136 repo_name = %repo_name,
137 registry_name = %self.registry_name,
138 "Azure Container Registry cross-account access not supported"
139 );
140
141 Err(AlienError::new(ErrorData::OperationNotSupported {
142 operation: "add_cross_account_access".to_string(),
143 reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
144 }))
145 }
146
147 async fn remove_cross_account_access(
148 &self,
149 repo_id: &str,
150 _access: CrossAccountAccess,
151 ) -> Result<()> {
152 let repo_name = repo_id;
153
154 info!(
155 repo_name = %repo_name,
156 registry_name = %self.registry_name,
157 "Azure Container Registry cross-account access not supported"
158 );
159
160 Err(AlienError::new(ErrorData::OperationNotSupported {
161 operation: "remove_cross_account_access".to_string(),
162 reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
163 }))
164 }
165
166 async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
167 let repo_name = repo_id;
168
169 info!(
170 repo_name = %repo_name,
171 registry_name = %self.registry_name,
172 "Azure Container Registry cross-account access not supported"
173 );
174
175 Err(AlienError::new(ErrorData::OperationNotSupported {
176 operation: "get_cross_account_access".to_string(),
177 reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
178 }))
179 }
180
181 async fn generate_credentials(
182 &self,
183 repo_id: &str,
184 permissions: ArtifactRegistryPermissions,
185 _ttl_seconds: Option<u32>,
186 ) -> Result<ArtifactRegistryCredentials> {
187 info!(
188 registry = %self.registry_endpoint,
189 repo_id = %repo_id,
190 permissions = ?permissions,
191 "Generating ACR credentials via AAD → refresh → access token flow"
192 );
193
194 let aad_token = self
196 .azure_token_cache
197 .get_bearer_token_with_scope("https://management.azure.com/.default")
198 .await
199 .map_err(|e| {
200 map_cloud_client_error(e, "Failed to get AAD token for ACR".to_string(), None)
201 })?;
202
203 let exchange_url = format!("https://{}/oauth2/exchange", self.registry_endpoint);
206 let exchange_resp = self
207 .http_client
208 .post(&exchange_url)
209 .form(&[
210 ("grant_type", "access_token"),
211 ("service", &self.registry_endpoint),
212 ("access_token", &aad_token),
213 ])
214 .send()
215 .await
216 .into_alien_error()
217 .context(ErrorData::Other {
218 message: "ACR OAuth2 exchange request failed".to_string(),
219 })?;
220
221 if !exchange_resp.status().is_success() {
222 let status = exchange_resp.status();
223 let body = exchange_resp.text().await.unwrap_or_default();
224 return Err(AlienError::new(ErrorData::Other {
225 message: format!("ACR OAuth2 exchange failed with {}: {}", status, body),
226 }));
227 }
228
229 #[derive(serde::Deserialize)]
230 struct ExchangeResponse {
231 refresh_token: String,
232 }
233 let refresh_token = exchange_resp
234 .json::<ExchangeResponse>()
235 .await
236 .into_alien_error()
237 .context(ErrorData::Other {
238 message: "Failed to parse ACR exchange response".to_string(),
239 })?
240 .refresh_token;
241
242 let scope = if repo_id.is_empty() {
246 "registry:catalog:*".to_string()
248 } else {
249 let actions = match permissions {
250 ArtifactRegistryPermissions::Pull => "pull",
251 ArtifactRegistryPermissions::PushPull => "pull,push",
252 };
253 format!("repository:{}:{}", repo_id, actions)
254 };
255
256 let token_url = format!("https://{}/oauth2/token", self.registry_endpoint);
257 let token_resp = self
258 .http_client
259 .post(&token_url)
260 .form(&[
261 ("grant_type", "refresh_token"),
262 ("service", &self.registry_endpoint),
263 ("scope", &scope),
264 ("refresh_token", &refresh_token),
265 ])
266 .send()
267 .await
268 .into_alien_error()
269 .context(ErrorData::Other {
270 message: "ACR OAuth2 token request failed".to_string(),
271 })?;
272
273 if !token_resp.status().is_success() {
274 let status = token_resp.status();
275 let body = token_resp.text().await.unwrap_or_default();
276 return Err(AlienError::new(ErrorData::Other {
277 message: format!("ACR OAuth2 token failed with {}: {}", status, body),
278 }));
279 }
280
281 #[derive(serde::Deserialize)]
282 struct TokenResponse {
283 access_token: String,
284 }
285 let access_token = token_resp
286 .json::<TokenResponse>()
287 .await
288 .into_alien_error()
289 .context(ErrorData::Other {
290 message: "Failed to parse ACR token response".to_string(),
291 })?
292 .access_token;
293
294 info!(
295 registry = %self.registry_endpoint,
296 scope = %scope,
297 "ACR access token generated"
298 );
299
300 let expires_at = Some((chrono::Utc::now() + chrono::Duration::seconds(300)).to_rfc3339());
302
303 Ok(ArtifactRegistryCredentials {
304 auth_method: RegistryAuthMethod::Bearer,
305 username: String::new(),
306 password: access_token,
307 expires_at,
308 })
309 }
310
311 async fn delete_repository(&self, _repo_id: &str) -> Result<()> {
316 Ok(())
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use alien_core::bindings::{AcrArtifactRegistryBinding, ArtifactRegistryBinding, BindingValue};
325 use alien_core::AzureCredentials;
326
327 fn test_config() -> AzureClientConfig {
328 AzureClientConfig {
329 subscription_id: "sub-test".to_string(),
330 tenant_id: "tenant-test".to_string(),
331 region: Some("eastus".to_string()),
332 credentials: AzureCredentials::AccessToken {
333 token: "token-test".to_string(),
334 },
335 service_overrides: None,
336 }
337 }
338
339 #[tokio::test]
340 async fn new_fails_when_configured_repository_prefix_cannot_be_resolved() {
341 let binding = ArtifactRegistryBinding::Acr(AcrArtifactRegistryBinding {
342 registry_name: BindingValue::Value("registrytest".to_string()),
343 resource_group_name: BindingValue::Value("rg-test".to_string()),
344 repository_prefix: Some(BindingValue::expression(serde_json::json!({
345 "ref": "repositoryPrefix"
346 }))),
347 });
348
349 let result =
350 AcrArtifactRegistry::new("artifact-registry".to_string(), binding, &test_config())
351 .await;
352 let Err(error) = result else {
353 panic!("configured repository_prefix resolution failure should fail initialization");
354 };
355
356 assert!(error
357 .to_string()
358 .contains("Failed to extract repository_prefix from binding"));
359 }
360
361 #[tokio::test]
362 async fn new_uses_empty_repository_prefix_when_repository_prefix_is_omitted() {
363 let binding = ArtifactRegistryBinding::Acr(AcrArtifactRegistryBinding {
364 registry_name: BindingValue::Value("registrytest".to_string()),
365 resource_group_name: BindingValue::Value("rg-test".to_string()),
366 repository_prefix: None,
367 });
368
369 let registry =
370 AcrArtifactRegistry::new("artifact-registry".to_string(), binding, &test_config())
371 .await
372 .expect("omitted repository_prefix should initialize");
373
374 assert_eq!(registry.upstream_repository_prefix(), "");
375 }
376}