Skip to main content

alien_bindings/providers/artifact_registry/
local.rs

1use crate::{
2    error::{binding_env_var, ErrorData, Result},
3    traits::{
4        ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions, Binding,
5        CrossAccountAccess, CrossAccountPermissions, RegistryAuthMethod, 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                    env_var: binding_env_var(&binding_name),
50                    binding_name,
51                    reason: "Expected Local artifact registry binding variant".to_string(),
52                }));
53            }
54        };
55
56        let registry_endpoint = config
57            .registry_url
58            .into_value(&binding_name, "registry_url")
59            .context(ErrorData::BindingConfigInvalid {
60                env_var: binding_env_var(&binding_name),
61                binding_name: binding_name.clone(),
62                reason: "Failed to extract registry_url from binding".to_string(),
63            })?;
64
65        // Validate the registry endpoint format
66        if registry_endpoint.is_empty() {
67            return Err(AlienError::new(ErrorData::BindingConfigInvalid {
68                env_var: binding_env_var(&binding_name),
69                binding_name: binding_name.clone(),
70                reason: "Registry endpoint cannot be empty".to_string(),
71            }));
72        }
73
74        info!(
75            binding_name = %binding_name,
76            endpoint = %registry_endpoint,
77            "Local artifact registry client configured"
78        );
79
80        Ok(Self {
81            binding_name,
82            registry_endpoint,
83        })
84    }
85
86    /// Gets the registry endpoint for this local registry
87    pub fn registry_endpoint(&self) -> &str {
88        &self.registry_endpoint
89    }
90
91    /// Creates an OCI client for communicating with the local registry
92    fn create_oci_client(&self) -> OciClient {
93        OciClient::new(OciClientConfig {
94            protocol: ClientProtocol::Http,
95            ..Default::default()
96        })
97    }
98
99    /// Creates an OCI Reference from a logical repository name (e.g. `"my-app"`).
100    /// The reference points at `{registry}/{binding_name}/{logical}:latest`.
101    fn create_reference(&self, logical: &str) -> Result<Reference> {
102        // registry_endpoint is like "localhost:5000"
103        // The container-registry crate requires a two-level path: /v2/:repository/:image/...
104        // We use the binding name as :repository and the logical name as :image to match
105        // the conceptual model: "artifacts registry contains alien-prj_xxx repository".
106        // This also enables namespace separation for multiple ArtifactRegistry resources.
107        let ref_string = format!(
108            "{}/{}/{}:latest",
109            self.registry_endpoint, self.binding_name, logical
110        );
111        Reference::try_from(ref_string.as_str())
112            .into_alien_error()
113            .context(ErrorData::Other {
114                message: format!("Invalid repository reference: {}", ref_string),
115            })
116    }
117
118    /// Build the routable name (`{binding_name}/{logical}`) returned to
119    /// callers. Per `traits::RepositoryResponse::name`, this is the value
120    /// that round-trips through `get_repository`/`delete_repository`.
121    fn routable_name(&self, logical: &str) -> String {
122        if logical.is_empty() {
123            self.binding_name.clone()
124        } else {
125            format!("{}/{}", self.binding_name, logical)
126        }
127    }
128
129    /// Recover the logical name from a routable name passed back by a
130    /// caller. Tolerates either form: a routable name like
131    /// `"{binding_name}/{logical}"` is stripped, anything else is treated
132    /// as already-logical.
133    fn logical_from_routable<'a>(&self, repo_id: &'a str) -> &'a str {
134        let prefix = format!("{}/", self.binding_name);
135        repo_id.strip_prefix(prefix.as_str()).unwrap_or(repo_id)
136    }
137}
138
139impl Binding for LocalArtifactRegistry {}
140
141#[async_trait]
142impl ArtifactRegistry for LocalArtifactRegistry {
143    fn registry_endpoint(&self) -> String {
144        let host = &self.registry_endpoint;
145        if host.starts_with("http://") || host.starts_with("https://") {
146            host.clone()
147        } else {
148            format!("http://{}", host)
149        }
150    }
151
152    fn upstream_repository_prefix(&self) -> String {
153        // The embedded local registry accepts two-segment repo paths (e.g.,
154        // "namespace/repo"). We use "artifacts/default" as the canonical prefix
155        // — this matches what the CLI hardcodes in dev mode and what the proxy
156        // routing table uses to route pushes to this local registry.
157        "artifacts/default".to_string()
158    }
159
160    async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
161        info!(
162            binding_name = %self.binding_name,
163            repo_name = %repo_name,
164            "Creating local Docker repository"
165        );
166
167        // For Docker registries, repositories are created implicitly on first manifest push
168        // We push a minimal empty OCI Image Manifest to make the repository exist
169        // This ensures consistent behavior with cloud providers where create_repository
170        // makes the repository immediately queryable
171
172        let client = self.create_oci_client();
173        let reference = self.create_reference(repo_name)?;
174
175        // Create a minimal OCI Image Manifest with inline config and no layers
176        use oci_client::manifest::{OciDescriptor, OciImageManifest, OciManifest};
177
178        // Create minimal empty config
179        let config_json = serde_json::json!({
180            "architecture": "amd64",
181            "os": "linux",
182            "rootfs": {
183                "type": "layers",
184                "diff_ids": []
185            },
186            "config": {}
187        });
188
189        let config_bytes = serde_json::to_vec(&config_json)
190            .into_alien_error()
191            .context(ErrorData::Other {
192                message: "Failed to serialize config".to_string(),
193            })?;
194
195        // Calculate SHA256 digest for config
196        use sha2::{Digest as Sha2Digest, Sha256};
197        let config_digest = format!("sha256:{:x}", Sha256::digest(&config_bytes));
198
199        // Create config descriptor
200        let config_descriptor = OciDescriptor {
201            media_type: "application/vnd.oci.image.config.v1+json".to_string(),
202            size: config_bytes.len() as i64,
203            digest: config_digest.clone(),
204            urls: None,
205            annotations: None,
206        };
207
208        // Create minimal manifest with just the config (no layers)
209        let manifest = OciImageManifest {
210            schema_version: 2,
211            media_type: Some("application/vnd.oci.image.manifest.v1+json".to_string()),
212            config: config_descriptor,
213            layers: vec![], // Empty - no layers
214            annotations: Some({
215                let mut map = std::collections::BTreeMap::new();
216                map.insert(
217                    "dev.alien.marker".to_string(),
218                    "empty-repository-created-by-alien".to_string(),
219                );
220                map
221            }),
222            subject: None,
223            artifact_type: None,
224        };
225
226        // Push the config blob first (OCI spec requires all referenced blobs to exist before
227        // pushing a manifest), then push the manifest to create the repository.
228        let auth = RegistryAuth::Anonymous;
229        client
230            .store_auth_if_needed(&self.registry_endpoint, &auth)
231            .await;
232
233        client
234            .push_blob(&reference, &config_bytes, &config_digest)
235            .await
236            .into_alien_error()
237            .context(ErrorData::Other {
238                message: format!("Failed to push config blob for repository '{}'", repo_name),
239            })?;
240
241        client
242            .push_manifest(&reference, &OciManifest::Image(manifest))
243            .await
244            .into_alien_error()
245            .context(ErrorData::Other {
246                message: format!(
247                    "Failed to push marker manifest for repository '{}'",
248                    repo_name
249                ),
250            })?;
251
252        // Repository URI uses binding name as first component for namespace separation.
253        // Format: registry/binding-name/repository (e.g., localhost:5000/artifacts/alien-prj_xxx)
254        // This satisfies container-registry's two-level requirement and provides semantic clarity.
255        let repository_uri = format!(
256            "{}/{}/{}",
257            self.registry_endpoint, self.binding_name, repo_name
258        );
259
260        info!(
261            binding_name = %self.binding_name,
262            repo_name = %repo_name,
263            uri = %repository_uri,
264            "Local Docker repository created successfully"
265        );
266
267        // Return the routable name (`{binding_name}/{logical}`) — matches
268        // both the on-disk OCI path and the docs at
269        // `alien.dev/content/docs/infrastructure/artifact-registry/behavior.mdx`.
270        // The manager proxy routes via `upstream_repository_prefix()`, which
271        // is a separate concern from this binding-level identifier.
272        Ok(RepositoryResponse {
273            name: self.routable_name(repo_name),
274            uri: Some(repository_uri),
275            created_at: None,
276        })
277    }
278
279    async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
280        debug!(
281            binding_name = %self.binding_name,
282            repo_id = %repo_id,
283            "Checking local repository existence via OCI API"
284        );
285
286        // Per the trait contract, `repo_id` is the routable name returned by
287        // `create_repository` (`{binding_name}/{logical}`). Recover the
288        // logical segment so we can build the OCI reference.
289        let logical = self.logical_from_routable(repo_id);
290
291        // Use oci-client to check if repository exists by trying to fetch a manifest
292        let client = self.create_oci_client();
293        let reference = self.create_reference(logical)?;
294
295        // Store auth credentials for this registry
296        let auth = RegistryAuth::Anonymous;
297        client
298            .store_auth_if_needed(&self.registry_endpoint, &auth)
299            .await;
300
301        // Try to pull the manifest we created (or any manifest with :latest tag)
302        // This hits /v2/<repository>/<image>/manifests/<reference> endpoint
303        match client.pull_manifest(&reference, &auth).await {
304            Ok(_) => {
305                // Repository exists and has at least one manifest.
306                // URI format matches create_repository: registry/binding-name/logical.
307                let repository_uri = format!(
308                    "{}/{}/{}",
309                    self.registry_endpoint, self.binding_name, logical
310                );
311
312                debug!(
313                    binding_name = %self.binding_name,
314                    repo_id = %repo_id,
315                    repo_uri = %repository_uri,
316                    "Local repository exists"
317                );
318
319                Ok(RepositoryResponse {
320                    name: self.routable_name(logical),
321                    uri: Some(repository_uri),
322                    created_at: None,
323                })
324            }
325            Err(OciDistributionError::ServerError { code: 404, .. }) => {
326                // Repository or manifest doesn't exist (404 from registry)
327                debug!(
328                    binding_name = %self.binding_name,
329                    repo_id = %repo_id,
330                    "Local repository not found (404)"
331                );
332
333                Err(AlienError::new(ErrorData::ResourceNotFound {
334                    resource_id: repo_id.to_string(),
335                }))
336            }
337            Err(OciDistributionError::ImageManifestNotFoundError(_)) => {
338                // Manifest doesn't exist - treat as repository not found
339                debug!(
340                    binding_name = %self.binding_name,
341                    repo_id = %repo_id,
342                    "Local repository not found (manifest not found)"
343                );
344
345                Err(AlienError::new(ErrorData::ResourceNotFound {
346                    resource_id: repo_id.to_string(),
347                }))
348            }
349            Err(OciDistributionError::RegistryError { envelope, .. })
350                if envelope.errors.iter().any(|e| {
351                    matches!(
352                        e.code,
353                        oci_client::errors::OciErrorCode::BlobUnknown
354                            | oci_client::errors::OciErrorCode::ManifestUnknown
355                            | oci_client::errors::OciErrorCode::NameUnknown
356                    )
357                }) =>
358            {
359                // Blob/manifest/repository doesn't exist - expected "not found" case
360                debug!(
361                    binding_name = %self.binding_name,
362                    repo_id = %repo_id,
363                    "Local repository not found (OCI error: blob/manifest/name unknown)"
364                );
365
366                Err(AlienError::new(ErrorData::ResourceNotFound {
367                    resource_id: repo_id.to_string(),
368                }))
369            }
370            Err(e) => {
371                // Actual unexpected errors (connection issues, auth failures, etc.)
372                // Fail fast - don't silently treat these as "not found"
373                Err(e.into_alien_error().context(ErrorData::Other {
374                    message: "Failed to check repository existence".to_string(),
375                }))
376            }
377        }
378    }
379
380    async fn add_cross_account_access(
381        &self,
382        repo_id: &str,
383        _access: CrossAccountAccess,
384    ) -> Result<()> {
385        info!(
386            binding_name = %self.binding_name,
387            repo_id = %repo_id,
388            "Local artifact registry cross-account access not supported"
389        );
390
391        Err(AlienError::new(ErrorData::OperationNotSupported {
392            operation: "add_cross_account_access".to_string(),
393            reason: "Local artifact registry does not support cross-account access".to_string(),
394        }))
395    }
396
397    async fn remove_cross_account_access(
398        &self,
399        repo_id: &str,
400        _access: CrossAccountAccess,
401    ) -> Result<()> {
402        info!(
403            binding_name = %self.binding_name,
404            repo_id = %repo_id,
405            "Local artifact registry cross-account access not supported"
406        );
407
408        Err(AlienError::new(ErrorData::OperationNotSupported {
409            operation: "remove_cross_account_access".to_string(),
410            reason: "Local artifact registry does not support cross-account access".to_string(),
411        }))
412    }
413
414    async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
415        info!(
416            binding_name = %self.binding_name,
417            repo_id = %repo_id,
418            "Local artifact registry cross-account access not supported"
419        );
420
421        Err(AlienError::new(ErrorData::OperationNotSupported {
422            operation: "get_cross_account_access".to_string(),
423            reason: "Local artifact registry does not support cross-account access".to_string(),
424        }))
425    }
426
427    async fn generate_credentials(
428        &self,
429        repo_id: &str,
430        permissions: ArtifactRegistryPermissions,
431        ttl_seconds: Option<u32>,
432    ) -> Result<ArtifactRegistryCredentials> {
433        info!(
434            repo_id = %repo_id,
435            permissions = ?permissions,
436            ttl_seconds = ?ttl_seconds,
437            "Generating local artifact registry credentials"
438        );
439
440        // Local registry runs on localhost without auth.
441        // Return empty credentials — callers should use anonymous access.
442        Ok(ArtifactRegistryCredentials {
443            auth_method: RegistryAuthMethod::Basic,
444            username: String::new(),
445            password: String::new(),
446            expires_at: None,
447        })
448    }
449
450    async fn delete_repository(&self, repo_id: &str) -> Result<()> {
451        info!(
452            binding_name = %self.binding_name,
453            repo_id = %repo_id,
454            "Deleting local repository (stateless - no-op)"
455        );
456
457        // For local registries, deletion is a no-op since we don't track state.
458        // The actual registry server handles storage.
459        info!(
460            binding_name = %self.binding_name,
461            repo_id = %repo_id,
462            "Local repository deletion acknowledged (no-op for stateless client)"
463        );
464
465        Ok(())
466    }
467}