provisioning-platform/crates/catalog-registry/src/oci/client.rs

491 lines
15 KiB
Rust

use std::time::Duration;
use async_trait::async_trait;
use bytes::Bytes;
use chrono::Utc;
use reqwest::Client;
use tracing::debug;
use crate::client::traits::{
BackendType, DistributionClient, ExtensionClient, LayerInfo, ManifestInfo,
};
use crate::config::OciConfig;
use crate::error::{RegistryError, Result};
use crate::models::{Extension, ExtensionSource, ExtensionType, ExtensionVersion};
use crate::oci::models::{OciCatalog, OciManifest, OciTagsList};
/// OCI registry client
pub struct OciClient {
backend_id: String,
registry: String,
namespace: String,
auth_token: Option<String>,
client: Client,
}
impl OciClient {
/// Create new OCI client with a pre-resolved auth token.
///
/// Token resolution (file read or vault:// fetch) is the caller's
/// responsibility. Pass `None` for unauthenticated registry access.
pub fn new(config: &OciConfig, auth_token: Option<String>) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_seconds))
.danger_accept_invalid_certs(!config.verify_ssl)
.build()
.map_err(|e| RegistryError::Oci(format!("Failed to create HTTP client: {}", e)))?;
let backend_id = config
.id
.clone()
.unwrap_or_else(|| format!("oci-{}", config.registry));
Ok(Self {
backend_id,
registry: config.registry.clone(),
namespace: config.namespace.clone(),
auth_token,
client,
})
}
/// List all extensions from OCI registry
pub async fn list_extensions(
&self,
extension_type: Option<ExtensionType>,
) -> Result<Vec<Extension>> {
debug!("Fetching artifacts from OCI registry: {}", self.registry);
let catalog = self.list_catalog().await?;
let mut extensions = Vec::new();
for repo_name in catalog.repositories {
// Skip if not in our namespace
if !repo_name.starts_with(&format!("{}/", self.namespace)) {
continue;
}
// Parse extension type and name from repository
let Some((ext_type, _name)) = self.parse_repository_name(&repo_name) else {
continue;
};
// Filter by type if specified
if let Some(filter_type) = extension_type {
if ext_type != filter_type {
continue;
}
}
// Get tags for this repository
let Ok(tags) = self.list_tags(&repo_name).await else {
continue;
};
let Some(latest_tag) = tags.tags.first() else {
continue;
};
if let Ok(manifest) = self.get_manifest(&repo_name, latest_tag).await {
if let Some(extension) =
self.manifest_to_extension(&repo_name, latest_tag, &manifest, ext_type)
{
extensions.push(extension);
}
}
}
Ok(extensions)
}
/// Get specific extension metadata
pub async fn get_extension(
&self,
extension_type: ExtensionType,
name: &str,
) -> Result<Extension> {
let repo_name = self.format_repository_name(extension_type, name);
debug!("Fetching extension: {}", repo_name);
let tags = self.list_tags(&repo_name).await?;
let latest_tag = tags
.tags
.first()
.ok_or_else(|| RegistryError::NotFound(format!("No tags found for {}", repo_name)))?;
let manifest = self.get_manifest(&repo_name, latest_tag).await?;
self.manifest_to_extension(&repo_name, latest_tag, &manifest, extension_type)
.ok_or_else(|| {
RegistryError::NotFound(format!("Invalid extension metadata for {}", repo_name))
})
}
/// List all versions for an extension
pub async fn list_versions(
&self,
extension_type: ExtensionType,
name: &str,
) -> Result<Vec<ExtensionVersion>> {
let repo_name = self.format_repository_name(extension_type, name);
debug!("Fetching versions for: {}", repo_name);
let tags = self.list_tags(&repo_name).await?;
let mut versions = Vec::new();
for tag in tags.tags {
if let Ok(manifest) = self.get_manifest(&repo_name, &tag).await {
let size = manifest.layers.iter().map(|l| l.size).sum();
versions.push(ExtensionVersion {
version: tag.clone(),
published_at: Utc::now(), /* OCI doesn't provide creation time without
* additional metadata */
download_url: None,
checksum: Some(manifest.config.digest.clone()),
size: Some(size),
});
}
}
Ok(versions)
}
/// Pull extension artifact
pub async fn download_extension(
&self,
extension_type: ExtensionType,
name: &str,
tag: &str,
) -> Result<Bytes> {
let repo_name = self.format_repository_name(extension_type, name);
debug!("Pulling extension: {} tag {}", repo_name, tag);
let manifest = self.get_manifest(&repo_name, tag).await?;
// Download first layer (assuming single-layer artifacts)
let layer = manifest
.layers
.first()
.ok_or_else(|| RegistryError::Oci("No layers found in manifest".to_string()))?;
self.download_blob(&repo_name, &layer.digest).await
}
/// List catalog (all repositories)
async fn list_catalog(&self) -> Result<OciCatalog> {
let url = format!("https://{}/v2/_catalog", self.registry);
let mut request = self.client.get(&url);
if let Some(ref token) = self.auth_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.send()
.await
.map_err(|e| RegistryError::Oci(format!("Request failed: {}", e)))?;
if !response.status().is_success() {
return Err(RegistryError::Oci(format!(
"Failed to list catalog: {}",
response.status()
)));
}
response
.json()
.await
.map_err(|e| RegistryError::Oci(format!("Failed to parse response: {}", e)))
}
/// List tags for repository
async fn list_tags(&self, repository: &str) -> Result<OciTagsList> {
let url = format!("https://{}/v2/{}/tags/list", self.registry, repository);
let mut request = self.client.get(&url);
if let Some(ref token) = self.auth_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.send()
.await
.map_err(|e| RegistryError::Oci(format!("Request failed: {}", e)))?;
if response.status().as_u16() == 404 {
return Err(RegistryError::NotFound(format!(
"Repository not found: {}",
repository
)));
}
if !response.status().is_success() {
return Err(RegistryError::Oci(format!(
"Failed to list tags: {}",
response.status()
)));
}
response
.json()
.await
.map_err(|e| RegistryError::Oci(format!("Failed to parse response: {}", e)))
}
/// Get manifest for tag
async fn get_manifest(&self, repository: &str, tag: &str) -> Result<OciManifest> {
let url = format!(
"https://{}/v2/{}/manifests/{}",
self.registry, repository, tag
);
let mut request = self
.client
.get(&url)
.header("Accept", "application/vnd.oci.image.manifest.v1+json");
if let Some(ref token) = self.auth_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.send()
.await
.map_err(|e| RegistryError::Oci(format!("Request failed: {}", e)))?;
if response.status().as_u16() == 404 {
return Err(RegistryError::NotFound(format!(
"Manifest not found: {}:{}",
repository, tag
)));
}
if !response.status().is_success() {
return Err(RegistryError::Oci(format!(
"Failed to get manifest: {}",
response.status()
)));
}
response
.json()
.await
.map_err(|e| RegistryError::Oci(format!("Failed to parse manifest: {}", e)))
}
/// Download blob by digest
async fn download_blob(&self, repository: &str, digest: &str) -> Result<Bytes> {
let url = format!(
"https://{}/v2/{}/blobs/{}",
self.registry, repository, digest
);
let mut request = self.client.get(&url);
if let Some(ref token) = self.auth_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.send()
.await
.map_err(|e| RegistryError::Oci(format!("Download failed: {}", e)))?;
if !response.status().is_success() {
return Err(RegistryError::Oci(format!(
"Download failed: {}",
response.status()
)));
}
response
.bytes()
.await
.map_err(|e| RegistryError::Oci(format!("Failed to read response: {}", e)))
}
/// Parse repository name into extension type and name
fn parse_repository_name(&self, repo_name: &str) -> Option<(ExtensionType, String)> {
let name = repo_name.strip_prefix(&format!("{}/", self.namespace))?;
name.strip_suffix("-provider")
.map(|base_name| (ExtensionType::Provider, base_name.to_string()))
.or_else(|| {
name.strip_suffix("-taskserv")
.map(|base_name| (ExtensionType::Taskserv, base_name.to_string()))
})
.or_else(|| {
name.strip_suffix("-cluster")
.map(|base_name| (ExtensionType::Cluster, base_name.to_string()))
})
}
/// Format repository name from extension type and name
fn format_repository_name(&self, extension_type: ExtensionType, name: &str) -> String {
let suffix = match extension_type {
ExtensionType::Provider => "-provider",
ExtensionType::Taskserv => "-taskserv",
ExtensionType::Cluster => "-cluster",
};
format!("{}/{}{}", self.namespace, name, suffix)
}
/// Convert OCI manifest to Extension
fn manifest_to_extension(
&self,
repo_name: &str,
tag: &str,
manifest: &OciManifest,
extension_type: ExtensionType,
) -> Option<Extension> {
let (_, name) = self.parse_repository_name(repo_name)?;
let description = manifest
.annotations
.as_ref()
.and_then(|a| a.get("org.opencontainers.image.description"))
.cloned()
.unwrap_or_default();
let author = manifest
.annotations
.as_ref()
.and_then(|a| a.get("org.opencontainers.image.authors"))
.cloned();
let repository = manifest
.annotations
.as_ref()
.and_then(|a| a.get("org.opencontainers.image.url"))
.cloned();
let size = manifest.layers.iter().map(|l| l.size).sum();
Some(Extension {
name,
extension_type,
version: tag.to_string(),
description,
author,
repository,
source: ExtensionSource::Oci,
published_at: Utc::now(),
download_url: None,
checksum: Some(manifest.config.digest.clone()),
size: Some(size),
tags: None,
})
}
/// Check health of OCI connection
pub async fn health_check(&self) -> Result<()> {
let url = format!("https://{}/v2/", self.registry);
let mut request = self.client.get(&url).timeout(Duration::from_secs(5));
if let Some(ref token) = self.auth_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.send()
.await
.map_err(|e| RegistryError::Oci(format!("Health check failed: {}", e)))?;
if response.status().is_success() || response.status().as_u16() == 401 {
// 401 means registry is up but auth is required
Ok(())
} else {
Err(RegistryError::Oci(format!(
"Health check returned: {}",
response.status()
)))
}
}
}
/// ExtensionClient trait implementation for OCI
#[async_trait]
impl ExtensionClient for OciClient {
fn backend_id(&self) -> &str {
&self.backend_id
}
fn backend_type(&self) -> BackendType {
BackendType::Oci
}
async fn list_extensions(
&self,
extension_type: Option<ExtensionType>,
) -> Result<Vec<Extension>> {
OciClient::list_extensions(self, extension_type).await
}
async fn get_extension(&self, extension_type: ExtensionType, name: &str) -> Result<Extension> {
OciClient::get_extension(self, extension_type, name).await
}
async fn list_versions(
&self,
extension_type: ExtensionType,
name: &str,
) -> Result<Vec<ExtensionVersion>> {
OciClient::list_versions(self, extension_type, name).await
}
async fn download_extension(
&self,
extension_type: ExtensionType,
name: &str,
version: &str,
) -> Result<Bytes> {
OciClient::download_extension(self, extension_type, name, version).await
}
async fn health_check(&self) -> Result<()> {
OciClient::health_check(self).await
}
}
/// DistributionClient trait implementation for OCI
#[async_trait]
impl DistributionClient for OciClient {
async fn get_manifest(&self, repo_name: &str, tag: &str) -> Result<ManifestInfo> {
let manifest = self.get_manifest(repo_name, tag).await?;
let total_size: u64 =
manifest.layers.iter().map(|l| l.size).sum::<u64>() + manifest.config.size;
Ok(ManifestInfo {
config_digest: manifest.config.digest,
layers: manifest
.layers
.into_iter()
.map(|l| LayerInfo {
digest: l.digest,
size: l.size,
media_type: l.media_type,
})
.collect(),
total_size,
})
}
async fn list_catalog(&self) -> Result<Vec<String>> {
let catalog = self.list_catalog().await?;
Ok(catalog.repositories)
}
async fn get_digest(&self, repo_name: &str, tag: &str) -> Result<String> {
let manifest = self.get_manifest(repo_name, tag).await?;
Ok(manifest.config.digest)
}
async fn verify_artifact(&self, repo_name: &str, tag: &str, digest: &str) -> Result<bool> {
let manifest = self.get_manifest(repo_name, tag).await?;
Ok(manifest.config.digest == digest)
}
}