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