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 fn routable_repository_name(&self, repo_name: &str) -> String {
93 if self.repository_prefix.is_empty() {
94 repo_name.to_string()
95 } else {
96 format!("{}/{}", self.repository_prefix, repo_name)
97 }
98 }
99}
100
101impl Binding for AcrArtifactRegistry {}
102
103#[async_trait]
104impl ArtifactRegistry for AcrArtifactRegistry {
105 fn registry_endpoint(&self) -> String {
106 format!("https://{}", self.registry_endpoint)
107 }
108
109 fn upstream_repository_prefix(&self) -> String {
110 self.repository_prefix.clone()
111 }
112
113 async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
114 let routable_name = self.routable_repository_name(repo_name);
117 let repository_uri = format!("{}/{}", self.registry_endpoint, routable_name);
118
119 Ok(RepositoryResponse {
120 name: routable_name,
121 uri: Some(repository_uri),
122 created_at: None,
123 })
124 }
125
126 async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
127 let repository_uri = format!("{}/{}", self.registry_endpoint, repo_id);
129
130 Ok(RepositoryResponse {
131 name: repo_id.to_string(),
132 uri: Some(repository_uri),
133 created_at: None,
134 })
135 }
136
137 async fn add_cross_account_access(
138 &self,
139 repo_id: &str,
140 _access: CrossAccountAccess,
141 ) -> Result<()> {
142 let repo_name = repo_id;
143
144 info!(
145 repo_name = %repo_name,
146 registry_name = %self.registry_name,
147 "Azure Container Registry cross-account access not supported"
148 );
149
150 Err(AlienError::new(ErrorData::OperationNotSupported {
151 operation: "add_cross_account_access".to_string(),
152 reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
153 }))
154 }
155
156 async fn remove_cross_account_access(
157 &self,
158 repo_id: &str,
159 _access: CrossAccountAccess,
160 ) -> Result<()> {
161 let repo_name = repo_id;
162
163 info!(
164 repo_name = %repo_name,
165 registry_name = %self.registry_name,
166 "Azure Container Registry cross-account access not supported"
167 );
168
169 Err(AlienError::new(ErrorData::OperationNotSupported {
170 operation: "remove_cross_account_access".to_string(),
171 reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
172 }))
173 }
174
175 async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
176 let repo_name = repo_id;
177
178 info!(
179 repo_name = %repo_name,
180 registry_name = %self.registry_name,
181 "Azure Container Registry cross-account access not supported"
182 );
183
184 Err(AlienError::new(ErrorData::OperationNotSupported {
185 operation: "get_cross_account_access".to_string(),
186 reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
187 }))
188 }
189
190 async fn generate_credentials(
191 &self,
192 repo_id: &str,
193 permissions: ArtifactRegistryPermissions,
194 _ttl_seconds: Option<u32>,
195 ) -> Result<ArtifactRegistryCredentials> {
196 info!(
197 registry = %self.registry_endpoint,
198 repo_id = %repo_id,
199 permissions = ?permissions,
200 "Generating ACR credentials via AAD → refresh → access token flow"
201 );
202
203 let aad_token = self
205 .azure_token_cache
206 .get_bearer_token_with_scope("https://management.azure.com/.default")
207 .await
208 .map_err(|e| {
209 map_cloud_client_error(e, "Failed to get AAD token for ACR".to_string(), None)
210 })?;
211
212 let exchange_url = format!("https://{}/oauth2/exchange", self.registry_endpoint);
215 let exchange_resp = self
216 .http_client
217 .post(&exchange_url)
218 .form(&[
219 ("grant_type", "access_token"),
220 ("service", &self.registry_endpoint),
221 ("access_token", &aad_token),
222 ])
223 .send()
224 .await
225 .into_alien_error()
226 .context(ErrorData::Other {
227 message: "ACR OAuth2 exchange request failed".to_string(),
228 })?;
229
230 if !exchange_resp.status().is_success() {
231 let status = exchange_resp.status();
232 let body = exchange_resp.text().await.unwrap_or_default();
233 return Err(AlienError::new(ErrorData::Other {
234 message: format!("ACR OAuth2 exchange failed with {}: {}", status, body),
235 }));
236 }
237
238 #[derive(serde::Deserialize)]
239 struct ExchangeResponse {
240 refresh_token: String,
241 }
242 let refresh_token = exchange_resp
243 .json::<ExchangeResponse>()
244 .await
245 .into_alien_error()
246 .context(ErrorData::Other {
247 message: "Failed to parse ACR exchange response".to_string(),
248 })?
249 .refresh_token;
250
251 let scope = if repo_id.is_empty() {
255 "registry:catalog:*".to_string()
257 } else {
258 let actions = match permissions {
259 ArtifactRegistryPermissions::Pull => "pull",
260 ArtifactRegistryPermissions::PushPull => "pull,push",
261 };
262 format!("repository:{}:{}", repo_id, actions)
263 };
264
265 let token_url = format!("https://{}/oauth2/token", self.registry_endpoint);
266 let token_resp = self
267 .http_client
268 .post(&token_url)
269 .form(&[
270 ("grant_type", "refresh_token"),
271 ("service", &self.registry_endpoint),
272 ("scope", &scope),
273 ("refresh_token", &refresh_token),
274 ])
275 .send()
276 .await
277 .into_alien_error()
278 .context(ErrorData::Other {
279 message: "ACR OAuth2 token request failed".to_string(),
280 })?;
281
282 if !token_resp.status().is_success() {
283 let status = token_resp.status();
284 let body = token_resp.text().await.unwrap_or_default();
285 return Err(AlienError::new(ErrorData::Other {
286 message: format!("ACR OAuth2 token failed with {}: {}", status, body),
287 }));
288 }
289
290 #[derive(serde::Deserialize)]
291 struct TokenResponse {
292 access_token: String,
293 }
294 let access_token = token_resp
295 .json::<TokenResponse>()
296 .await
297 .into_alien_error()
298 .context(ErrorData::Other {
299 message: "Failed to parse ACR token response".to_string(),
300 })?
301 .access_token;
302
303 info!(
304 registry = %self.registry_endpoint,
305 scope = %scope,
306 "ACR access token generated"
307 );
308
309 let expires_at = Some((chrono::Utc::now() + chrono::Duration::seconds(300)).to_rfc3339());
311
312 Ok(ArtifactRegistryCredentials {
313 auth_method: RegistryAuthMethod::Bearer,
314 username: String::new(),
315 password: access_token,
316 expires_at,
317 })
318 }
319
320 async fn delete_repository(&self, _repo_id: &str) -> Result<()> {
325 Ok(())
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use alien_core::bindings::{AcrArtifactRegistryBinding, ArtifactRegistryBinding, BindingValue};
334 use alien_core::AzureCredentials;
335
336 fn test_config() -> AzureClientConfig {
337 AzureClientConfig {
338 subscription_id: "sub-test".to_string(),
339 tenant_id: "tenant-test".to_string(),
340 region: Some("eastus".to_string()),
341 credentials: AzureCredentials::AccessToken {
342 token: "token-test".to_string(),
343 },
344 service_overrides: None,
345 }
346 }
347
348 #[tokio::test]
349 async fn new_fails_when_configured_repository_prefix_cannot_be_resolved() {
350 let binding = ArtifactRegistryBinding::Acr(AcrArtifactRegistryBinding {
351 registry_name: BindingValue::Value("registrytest".to_string()),
352 resource_group_name: BindingValue::Value("rg-test".to_string()),
353 repository_prefix: Some(BindingValue::expression(serde_json::json!({
354 "ref": "repositoryPrefix"
355 }))),
356 });
357
358 let result =
359 AcrArtifactRegistry::new("artifact-registry".to_string(), binding, &test_config())
360 .await;
361 let Err(error) = result else {
362 panic!("configured repository_prefix resolution failure should fail initialization");
363 };
364
365 assert!(error
366 .to_string()
367 .contains("Failed to extract repository_prefix from binding"));
368 }
369
370 #[tokio::test]
371 async fn new_uses_empty_repository_prefix_when_repository_prefix_is_omitted() {
372 let binding = ArtifactRegistryBinding::Acr(AcrArtifactRegistryBinding {
373 registry_name: BindingValue::Value("registrytest".to_string()),
374 resource_group_name: BindingValue::Value("rg-test".to_string()),
375 repository_prefix: None,
376 });
377
378 let registry =
379 AcrArtifactRegistry::new("artifact-registry".to_string(), binding, &test_config())
380 .await
381 .expect("omitted repository_prefix should initialize");
382
383 assert_eq!(registry.upstream_repository_prefix(), "");
384 }
385
386 #[tokio::test]
387 async fn new_uses_configured_repository_prefix_for_routable_names() {
388 let binding = ArtifactRegistryBinding::Acr(AcrArtifactRegistryBinding {
389 registry_name: BindingValue::Value("registrytest".to_string()),
390 resource_group_name: BindingValue::Value("rg-test".to_string()),
391 repository_prefix: Some(BindingValue::Value("team-a".to_string())),
392 });
393
394 let registry =
395 AcrArtifactRegistry::new("artifact-registry".to_string(), binding, &test_config())
396 .await
397 .expect("configured repository_prefix should initialize");
398 let repository = registry
399 .create_repository("worker")
400 .await
401 .expect("ACR repository response should be implicit");
402
403 assert_eq!(registry.upstream_repository_prefix(), "team-a");
404 assert_eq!(repository.name, "team-a/worker");
405 assert_eq!(
406 repository.uri,
407 Some("registrytest.azurecr.io/team-a/worker".to_string())
408 );
409 }
410}