Skip to main content

alien_bindings/providers/artifact_registry/
local.rs

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/// Local artifact registry implementation that connects to an external container registry.
20///
21/// This is a **client** that connects to a local container registry server
22/// (e.g., started by LocalArtifactRegistryManager in alien-local).
23///
24/// Unlike cloud providers that have explicit repository creation APIs, Docker registries
25/// implicitly create repositories on first push. To provide a consistent interface,
26/// this implementation pushes a minimal empty manifest when `create_repository()` is called,
27/// ensuring the repository exists and can be queried immediately afterward.
28#[derive(Debug)]
29pub struct LocalArtifactRegistry {
30    binding_name: String,
31    registry_endpoint: String,
32}
33
34impl LocalArtifactRegistry {
35    /// Creates a new local artifact registry instance from binding parameters.
36    ///
37    /// # Arguments
38    /// * `binding_name` - The name of this binding
39    /// * `binding` - The binding configuration containing registry settings
40    pub async fn new(
41        binding_name: String,
42        binding: alien_core::bindings::ArtifactRegistryBinding,
43    ) -> Result<Self> {
44        // Extract fields from Local variant
45        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        // Validate the registry endpoint format
64        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    /// Gets the registry endpoint for this local registry
84    pub fn registry_endpoint(&self) -> &str {
85        &self.registry_endpoint
86    }
87
88    /// Creates an OCI client for communicating with the local registry
89    fn create_oci_client(&self) -> OciClient {
90        OciClient::new(OciClientConfig {
91            protocol: ClientProtocol::Http,
92            ..Default::default()
93        })
94    }
95
96    /// Creates an OCI Reference for a repository in this registry
97    fn create_reference(&self, repo_id: &str) -> Result<Reference> {
98        // registry_endpoint is like "localhost:5000"
99        // The container-registry crate requires a two-level path: /v2/:repository/:image/...
100        // We use the binding name as :repository and repo_id as :image to match the conceptual
101        // model: "artifacts registry contains alien-prj_xxx repository"
102        // This also enables namespace separation for multiple ArtifactRegistry resources.
103        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        // The embedded local registry accepts two-segment repo paths (e.g.,
130        // "namespace/repo"). We use "artifacts/default" as the canonical prefix
131        // — this matches what the CLI hardcodes in dev mode and what the proxy
132        // routing table uses to route pushes to this local registry.
133        "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        // For Docker registries, repositories are created implicitly on first manifest push
144        // We push a minimal empty OCI Image Manifest to make the repository exist
145        // This ensures consistent behavior with cloud providers where create_repository
146        // makes the repository immediately queryable
147
148        let client = self.create_oci_client();
149        let reference = self.create_reference(repo_name)?;
150
151        // Create a minimal OCI Image Manifest with inline config and no layers
152        use oci_client::manifest::{OciDescriptor, OciImageManifest, OciManifest};
153
154        // Create minimal empty config
155        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        // Calculate SHA256 digest for config
172        use sha2::{Digest as Sha2Digest, Sha256};
173        let config_digest = format!("sha256:{:x}", Sha256::digest(&config_bytes));
174
175        // Create config descriptor
176        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        // Create minimal manifest with just the config (no layers)
185        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![], // Empty - no layers
190            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        // Push the config blob first (OCI spec requires all referenced blobs to exist before
203        // pushing a manifest), then push the manifest to create the repository.
204        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        // Repository URI uses binding name as first component for namespace separation.
229        // Format: registry/binding-name/repository (e.g., localhost:5000/artifacts/alien-prj_xxx)
230        // This satisfies container-registry's two-level requirement and provides semantic clarity.
231        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        // Use oci-client to check if repository exists by trying to fetch a manifest
258        let client = self.create_oci_client();
259        let reference = self.create_reference(repo_id)?;
260
261        // Store auth credentials for this registry
262        let auth = RegistryAuth::Anonymous;
263        client
264            .store_auth_if_needed(&self.registry_endpoint, &auth)
265            .await;
266
267        // Try to pull the manifest we created (or any manifest with :latest tag)
268        // This hits /v2/<repository>/<image>/manifests/<reference> endpoint
269        match client.pull_manifest(&reference, &auth).await {
270            Ok(_) => {
271                // Repository exists and has at least one manifest
272                // URI format matches create_repository: registry/binding-name/repository
273                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                // Repository or manifest doesn't exist (404 from registry)
293                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                // Manifest doesn't exist - treat as repository not found
305                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                // Blob/manifest/repository doesn't exist - expected "not found" case
326                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                // Actual unexpected errors (connection issues, auth failures, etc.)
338                // Fail fast - don't silently treat these as "not found"
339                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        // Local registry runs on localhost without auth.
407        // Return empty credentials — callers should use anonymous access.
408        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        // Fetch raw manifest bytes via HTTP to preserve exact byte content.
417        // Re-serializing through oci-client would change whitespace and break
418        // digest verification by OCI clients.
419        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        // HEAD request via HTTP to get the digest without pulling the full manifest.
470        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        // HEAD request to the OCI blob endpoint
507        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        // For local registries, deletion is a no-op since we don't track state.
535        // The actual registry server handles storage.
536        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}