1use crate::{
2 error::{ErrorData, Result},
3 traits::{
4 ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions, Binding,
5 CrossAccountAccess, CrossAccountPermissions, RepositoryResponse,
6 },
7};
8use alien_core::bindings::ArtifactRegistryBinding;
9use alien_error::{AlienError, Context, ContextError, IntoAlienError, IntoAlienErrorDirect};
10use async_trait::async_trait;
11use oci_client::{
12 client::{Client as OciClient, ClientConfig as OciClientConfig, ClientProtocol},
13 errors::OciDistributionError,
14 secrets::RegistryAuth,
15 Reference,
16};
17use tracing::{debug, info};
18
19#[derive(Debug)]
29pub struct LocalArtifactRegistry {
30 binding_name: String,
31 registry_endpoint: String,
32}
33
34impl LocalArtifactRegistry {
35 pub async fn new(
41 binding_name: String,
42 binding: alien_core::bindings::ArtifactRegistryBinding,
43 ) -> Result<Self> {
44 let config = match binding {
46 ArtifactRegistryBinding::Local(config) => config,
47 _ => {
48 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
49 binding_name,
50 reason: "Expected Local artifact registry binding variant".to_string(),
51 }));
52 }
53 };
54
55 let registry_endpoint = config
56 .registry_url
57 .into_value(&binding_name, "registry_url")
58 .context(ErrorData::BindingConfigInvalid {
59 binding_name: binding_name.clone(),
60 reason: "Failed to extract registry_url from binding".to_string(),
61 })?;
62
63 if registry_endpoint.is_empty() {
65 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
66 binding_name: binding_name.clone(),
67 reason: "Registry endpoint cannot be empty".to_string(),
68 }));
69 }
70
71 info!(
72 binding_name = %binding_name,
73 endpoint = %registry_endpoint,
74 "Local artifact registry client configured"
75 );
76
77 Ok(Self {
78 binding_name,
79 registry_endpoint,
80 })
81 }
82
83 pub fn registry_endpoint(&self) -> &str {
85 &self.registry_endpoint
86 }
87
88 fn create_oci_client(&self) -> OciClient {
90 OciClient::new(OciClientConfig {
91 protocol: ClientProtocol::Http,
92 ..Default::default()
93 })
94 }
95
96 fn create_reference(&self, repo_id: &str) -> Result<Reference> {
98 let ref_string = format!(
104 "{}/{}/{}:latest",
105 self.registry_endpoint, self.binding_name, repo_id
106 );
107 Reference::try_from(ref_string.as_str())
108 .into_alien_error()
109 .context(ErrorData::Other {
110 message: format!("Invalid repository reference: {}", ref_string),
111 })
112 }
113}
114
115impl Binding for LocalArtifactRegistry {}
116
117#[async_trait]
118impl ArtifactRegistry for LocalArtifactRegistry {
119 fn registry_endpoint(&self) -> String {
120 let host = &self.registry_endpoint;
121 if host.starts_with("http://") || host.starts_with("https://") {
122 host.clone()
123 } else {
124 format!("http://{}", host)
125 }
126 }
127
128 fn upstream_repository_prefix(&self) -> String {
129 "artifacts/default".to_string()
134 }
135
136 async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
137 info!(
138 binding_name = %self.binding_name,
139 repo_name = %repo_name,
140 "Creating local Docker repository"
141 );
142
143 let client = self.create_oci_client();
149 let reference = self.create_reference(repo_name)?;
150
151 use oci_client::manifest::{OciDescriptor, OciImageManifest, OciManifest};
153
154 let config_json = serde_json::json!({
156 "architecture": "amd64",
157 "os": "linux",
158 "rootfs": {
159 "type": "layers",
160 "diff_ids": []
161 },
162 "config": {}
163 });
164
165 let config_bytes = serde_json::to_vec(&config_json)
166 .into_alien_error()
167 .context(ErrorData::Other {
168 message: "Failed to serialize config".to_string(),
169 })?;
170
171 use sha2::{Digest as Sha2Digest, Sha256};
173 let config_digest = format!("sha256:{:x}", Sha256::digest(&config_bytes));
174
175 let config_descriptor = OciDescriptor {
177 media_type: "application/vnd.oci.image.config.v1+json".to_string(),
178 size: config_bytes.len() as i64,
179 digest: config_digest.clone(),
180 urls: None,
181 annotations: None,
182 };
183
184 let manifest = OciImageManifest {
186 schema_version: 2,
187 media_type: Some("application/vnd.oci.image.manifest.v1+json".to_string()),
188 config: config_descriptor,
189 layers: vec![], annotations: Some({
191 let mut map = std::collections::BTreeMap::new();
192 map.insert(
193 "dev.alien.marker".to_string(),
194 "empty-repository-created-by-alien".to_string(),
195 );
196 map
197 }),
198 subject: None,
199 artifact_type: None,
200 };
201
202 let auth = RegistryAuth::Anonymous;
205 client
206 .store_auth_if_needed(&self.registry_endpoint, &auth)
207 .await;
208
209 client
210 .push_blob(&reference, &config_bytes, &config_digest)
211 .await
212 .into_alien_error()
213 .context(ErrorData::Other {
214 message: format!("Failed to push config blob for repository '{}'", repo_name),
215 })?;
216
217 client
218 .push_manifest(&reference, &OciManifest::Image(manifest))
219 .await
220 .into_alien_error()
221 .context(ErrorData::Other {
222 message: format!(
223 "Failed to push marker manifest for repository '{}'",
224 repo_name
225 ),
226 })?;
227
228 let repository_uri = format!(
232 "{}/{}/{}",
233 self.registry_endpoint, self.binding_name, repo_name
234 );
235
236 info!(
237 binding_name = %self.binding_name,
238 repo_name = %repo_name,
239 uri = %repository_uri,
240 "Local Docker repository created successfully"
241 );
242
243 Ok(RepositoryResponse {
244 name: repo_name.to_string(),
245 uri: Some(repository_uri),
246 created_at: None,
247 })
248 }
249
250 async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
251 debug!(
252 binding_name = %self.binding_name,
253 repo_id = %repo_id,
254 "Checking local repository existence via OCI API"
255 );
256
257 let client = self.create_oci_client();
259 let reference = self.create_reference(repo_id)?;
260
261 let auth = RegistryAuth::Anonymous;
263 client
264 .store_auth_if_needed(&self.registry_endpoint, &auth)
265 .await;
266
267 match client.pull_manifest(&reference, &auth).await {
270 Ok(_) => {
271 let repository_uri = format!(
274 "{}/{}/{}",
275 self.registry_endpoint, self.binding_name, repo_id
276 );
277
278 debug!(
279 binding_name = %self.binding_name,
280 repo_id = %repo_id,
281 repo_uri = %repository_uri,
282 "Local repository exists"
283 );
284
285 Ok(RepositoryResponse {
286 name: repo_id.to_string(),
287 uri: Some(repository_uri),
288 created_at: None,
289 })
290 }
291 Err(OciDistributionError::ServerError { code: 404, .. }) => {
292 debug!(
294 binding_name = %self.binding_name,
295 repo_id = %repo_id,
296 "Local repository not found (404)"
297 );
298
299 Err(AlienError::new(ErrorData::ResourceNotFound {
300 resource_id: repo_id.to_string(),
301 }))
302 }
303 Err(OciDistributionError::ImageManifestNotFoundError(_)) => {
304 debug!(
306 binding_name = %self.binding_name,
307 repo_id = %repo_id,
308 "Local repository not found (manifest not found)"
309 );
310
311 Err(AlienError::new(ErrorData::ResourceNotFound {
312 resource_id: repo_id.to_string(),
313 }))
314 }
315 Err(OciDistributionError::RegistryError { envelope, .. })
316 if envelope.errors.iter().any(|e| {
317 matches!(
318 e.code,
319 oci_client::errors::OciErrorCode::BlobUnknown
320 | oci_client::errors::OciErrorCode::ManifestUnknown
321 | oci_client::errors::OciErrorCode::NameUnknown
322 )
323 }) =>
324 {
325 debug!(
327 binding_name = %self.binding_name,
328 repo_id = %repo_id,
329 "Local repository not found (OCI error: blob/manifest/name unknown)"
330 );
331
332 Err(AlienError::new(ErrorData::ResourceNotFound {
333 resource_id: repo_id.to_string(),
334 }))
335 }
336 Err(e) => {
337 Err(e.into_alien_error().context(ErrorData::Other {
340 message: "Failed to check repository existence".to_string(),
341 }))
342 }
343 }
344 }
345
346 async fn add_cross_account_access(
347 &self,
348 repo_id: &str,
349 _access: CrossAccountAccess,
350 ) -> Result<()> {
351 info!(
352 binding_name = %self.binding_name,
353 repo_id = %repo_id,
354 "Local artifact registry cross-account access not supported"
355 );
356
357 Err(AlienError::new(ErrorData::OperationNotSupported {
358 operation: "add_cross_account_access".to_string(),
359 reason: "Local artifact registry does not support cross-account access".to_string(),
360 }))
361 }
362
363 async fn remove_cross_account_access(
364 &self,
365 repo_id: &str,
366 _access: CrossAccountAccess,
367 ) -> Result<()> {
368 info!(
369 binding_name = %self.binding_name,
370 repo_id = %repo_id,
371 "Local artifact registry cross-account access not supported"
372 );
373
374 Err(AlienError::new(ErrorData::OperationNotSupported {
375 operation: "remove_cross_account_access".to_string(),
376 reason: "Local artifact registry does not support cross-account access".to_string(),
377 }))
378 }
379
380 async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
381 info!(
382 binding_name = %self.binding_name,
383 repo_id = %repo_id,
384 "Local artifact registry cross-account access not supported"
385 );
386
387 Err(AlienError::new(ErrorData::OperationNotSupported {
388 operation: "get_cross_account_access".to_string(),
389 reason: "Local artifact registry does not support cross-account access".to_string(),
390 }))
391 }
392
393 async fn generate_credentials(
394 &self,
395 repo_id: &str,
396 permissions: ArtifactRegistryPermissions,
397 ttl_seconds: Option<u32>,
398 ) -> Result<ArtifactRegistryCredentials> {
399 info!(
400 repo_id = %repo_id,
401 permissions = ?permissions,
402 ttl_seconds = ?ttl_seconds,
403 "Generating local artifact registry credentials"
404 );
405
406 Ok(ArtifactRegistryCredentials {
409 username: String::new(),
410 password: String::new(),
411 expires_at: None,
412 })
413 }
414
415 async fn get_manifest(&self, repo_name: &str, reference: &str) -> Result<(Vec<u8>, String)> {
416 let url = format!(
420 "http://{}/v2/{}/manifests/{}",
421 self.registry_endpoint, repo_name, reference
422 );
423
424 let http_client = reqwest::Client::new();
425 let resp = http_client
426 .get(&url)
427 .header("accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
428 .send()
429 .await
430 .into_alien_error()
431 .context(ErrorData::Other {
432 message: format!("Failed to fetch manifest for {}:{}", repo_name, reference),
433 })?;
434
435 if !resp.status().is_success() {
436 return Err(AlienError::new(ErrorData::Other {
437 message: format!(
438 "Upstream registry returned {} for manifest {}:{}",
439 resp.status(),
440 repo_name,
441 reference
442 ),
443 }));
444 }
445
446 let content_type = resp
447 .headers()
448 .get("content-type")
449 .and_then(|v| v.to_str().ok())
450 .unwrap_or("application/vnd.oci.image.manifest.v1+json")
451 .to_string();
452
453 let body = resp
454 .bytes()
455 .await
456 .into_alien_error()
457 .context(ErrorData::Other {
458 message: "Failed to read manifest body".to_string(),
459 })?;
460
461 Ok((body.to_vec(), content_type))
462 }
463
464 async fn head_manifest(
465 &self,
466 repo_name: &str,
467 reference: &str,
468 ) -> Result<Option<(String, String)>> {
469 let url = format!(
471 "http://{}/v2/{}/manifests/{}",
472 self.registry_endpoint, repo_name, reference
473 );
474
475 let http_client = reqwest::Client::new();
476 match http_client
477 .head(&url)
478 .header("accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
479 .send()
480 .await
481 {
482 Ok(resp) if resp.status().is_success() => {
483 let content_type = resp
484 .headers()
485 .get("content-type")
486 .and_then(|v| v.to_str().ok())
487 .unwrap_or("application/vnd.oci.image.manifest.v1+json")
488 .to_string();
489 let digest = resp
490 .headers()
491 .get("docker-content-digest")
492 .and_then(|v| v.to_str().ok())
493 .unwrap_or("")
494 .to_string();
495 if digest.is_empty() {
496 Ok(None)
497 } else {
498 Ok(Some((content_type, digest)))
499 }
500 }
501 _ => Ok(None),
502 }
503 }
504
505 async fn head_blob(&self, repo_name: &str, digest: &str) -> Result<Option<u64>> {
506 let url = format!(
508 "http://{}/v2/{}/blobs/{}",
509 self.registry_endpoint, repo_name, digest
510 );
511
512 let http_client = reqwest::Client::new();
513 match http_client.head(&url).send().await {
514 Ok(resp) if resp.status().is_success() => {
515 let content_length = resp
516 .headers()
517 .get("content-length")
518 .and_then(|v| v.to_str().ok())
519 .and_then(|v| v.parse().ok())
520 .unwrap_or(0);
521 Ok(Some(content_length))
522 }
523 _ => Ok(None),
524 }
525 }
526
527 async fn delete_repository(&self, repo_id: &str) -> Result<()> {
528 info!(
529 binding_name = %self.binding_name,
530 repo_id = %repo_id,
531 "Deleting local repository (stateless - no-op)"
532 );
533
534 info!(
537 binding_name = %self.binding_name,
538 repo_id = %repo_id,
539 "Local repository deletion acknowledged (no-op for stateless client)"
540 );
541
542 Ok(())
543 }
544}