use crate::auth::Auth;
use crate::config::ARTIFACTS_DIR;
use asterai_runtime::component::Component;
use asterai_runtime::resource::metadata::ResourceKind;
use eyre::{Context, bail};
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Deserialize)]
pub struct TokenResponse {
pub token: String,
#[allow(dead_code)]
pub expires_in: u64,
#[allow(dead_code)]
pub issued_at: i64,
}
#[derive(Deserialize)]
pub struct OciManifest {
#[serde(rename = "schemaVersion")]
#[allow(dead_code)]
pub schema_version: u32,
#[serde(rename = "mediaType")]
#[allow(dead_code)]
pub media_type: Option<String>,
#[allow(dead_code)]
pub config: OciDescriptor,
pub layers: Vec<OciDescriptor>,
}
#[derive(Deserialize)]
pub struct OciIndex {
pub manifests: Vec<OciIndexEntry>,
}
#[derive(Deserialize)]
pub struct OciIndexEntry {
pub digest: String,
#[serde(rename = "artifactType")]
pub artifact_type: Option<String>,
}
#[derive(Deserialize)]
pub struct TagListResponse {
pub tags: Vec<String>,
}
const ARTIFACT_TYPE_WIT: &str = "application/vnd.wasm.wit.v1+wasm";
#[derive(Deserialize)]
pub struct OciDescriptor {
#[serde(rename = "mediaType")]
#[allow(dead_code)]
pub media_type: String,
pub digest: String,
#[allow(dead_code)]
pub size: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetEnvironmentResponse {
pub namespace: String,
pub name: String,
pub version: String,
pub components: Vec<String>,
pub vars: HashMap<String, String>,
}
pub struct RegistryClient<'a> {
client: &'a reqwest::Client,
api_url: &'a str,
registry_url: &'a str,
}
impl<'a> RegistryClient<'a> {
pub fn new(client: &'a reqwest::Client, api_url: &'a str, registry_url: &'a str) -> Self {
Self {
client,
api_url,
registry_url,
}
}
pub async fn get_token(&self, api_key: Option<&str>, repo_name: &str) -> eyre::Result<String> {
let scope = format!("repository:{}:pull", repo_name);
let token_url = format!("{}/v1/registry/token?scope={}", self.api_url, scope);
let mut request = self.client.get(&token_url);
let effective_key = api_key
.map(|k| k.to_string())
.or_else(Auth::read_stored_api_key);
if let Some(key) = effective_key {
request = request.header("Authorization", format!("Bearer {}", key.trim()));
}
let response = request
.send()
.await
.wrap_err("failed to get registry token")?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "unknown error".to_string());
bail!("failed to get registry token ({}): {}", status, error_text);
}
let token_response: TokenResponse = response
.json()
.await
.wrap_err("failed to parse token response")?;
Ok(token_response.token)
}
pub async fn fetch_manifest(
&self,
repo_name: &str,
tag: &str,
token: &str,
) -> eyre::Result<(OciManifest, String)> {
let manifest_url = format!("{}/v2/{}/manifests/{}", self.registry_url, repo_name, tag);
let response = self
.client
.get(&manifest_url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.oci.image.manifest.v1+json")
.send()
.await
.wrap_err("failed to fetch manifest")?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "unknown error".to_string());
bail!("failed to fetch manifest ({}): {}", status, error_text);
}
let digest = response
.headers()
.get("Docker-Content-Digest")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let manifest: OciManifest = response.json().await.wrap_err("failed to parse manifest")?;
Ok((manifest, digest))
}
pub async fn download_blob(
&self,
repo_name: &str,
digest: &str,
token: &str,
) -> eyre::Result<Vec<u8>> {
let blob_url = format!("{}/v2/{}/blobs/{}", self.registry_url, repo_name, digest);
let response = self
.client
.get(&blob_url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.wrap_err("failed to fetch blob")?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "unknown error".to_string());
bail!("failed to fetch blob ({}): {}", status, error_text);
}
let bytes = response
.bytes()
.await
.wrap_err("failed to read blob bytes")?;
Ok(bytes.to_vec())
}
pub async fn fetch_wit_referrer(
&self,
repo_name: &str,
manifest_digest: &str,
token: &str,
) -> eyre::Result<Option<Vec<u8>>> {
if manifest_digest.is_empty() {
return Ok(None);
}
let index = self
.fetch_referrers_native(repo_name, manifest_digest, token)
.await?;
let index = match index {
Some(idx) => idx,
None => {
match self
.fetch_referrers_tag(repo_name, manifest_digest, token)
.await?
{
Some(idx) => idx,
None => return Ok(None),
}
}
};
let wit_entry = index.manifests.iter().find(|m| {
m.artifact_type
.as_deref()
.is_some_and(|t| t == ARTIFACT_TYPE_WIT)
});
let Some(entry) = wit_entry else {
return Ok(None);
};
let (wit_manifest, _) = self.fetch_manifest(repo_name, &entry.digest, token).await?;
let Some(layer) = wit_manifest.layers.first() else {
return Ok(None);
};
let blob = self.download_blob(repo_name, &layer.digest, token).await?;
Ok(Some(blob))
}
async fn fetch_referrers_native(
&self,
repo_name: &str,
manifest_digest: &str,
token: &str,
) -> eyre::Result<Option<OciIndex>> {
let url = format!(
"{}/v2/{}/referrers/{}?artifactType={}",
self.registry_url, repo_name, manifest_digest, ARTIFACT_TYPE_WIT
);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.oci.image.index.v1+json")
.send()
.await
.wrap_err("failed to fetch referrers")?;
if !response.status().is_success() {
return Ok(None);
}
let index: OciIndex = response
.json()
.await
.wrap_err("failed to parse referrers index")?;
Ok(Some(index))
}
async fn fetch_referrers_tag(
&self,
repo_name: &str,
manifest_digest: &str,
token: &str,
) -> eyre::Result<Option<OciIndex>> {
let fallback_tag = manifest_digest.replace(':', "-");
let url = format!(
"{}/v2/{}/manifests/{}",
self.registry_url, repo_name, fallback_tag
);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.oci.image.index.v1+json")
.send()
.await
.wrap_err("failed to fetch referrers tag index")?;
if !response.status().is_success() {
return Ok(None);
}
let index: OciIndex = response
.json()
.await
.wrap_err("failed to parse referrers tag index")?;
Ok(Some(index))
}
pub async fn list_tags(
&self,
api_key: Option<&str>,
repo_name: &str,
) -> eyre::Result<Vec<String>> {
let token = self.get_token(api_key, repo_name).await?;
let url = format!("{}/v2/{}/tags/list", self.registry_url, repo_name);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.wrap_err("failed to list tags")?;
if !response.status().is_success() {
return Ok(vec![]);
}
let result: TagListResponse = response.json().await.wrap_err("failed to parse tag list")?;
Ok(result.tags)
}
pub async fn pull_component(
&self,
api_key: Option<&str>,
component: &Component,
quiet: bool,
) -> eyre::Result<PathBuf> {
let namespace = component.namespace();
let name = component.name();
let version = component.version().to_string();
let repo_name = format!("{}/{}", namespace, name);
let output_dir = ARTIFACTS_DIR
.join(namespace)
.join(format!("{}@{}", name, version));
if output_dir.exists() {
if !quiet {
println!(" {}@{} (cached)", repo_name, version);
}
return Ok(output_dir);
}
if !quiet {
println!(" pulling {}@{}...", repo_name, version);
}
let token = self.get_token(api_key, &repo_name).await?;
let (manifest, manifest_digest) = self.fetch_manifest(&repo_name, &version, &token).await?;
fs::create_dir_all(&output_dir)?;
for (i, layer) in manifest.layers.iter().enumerate() {
let blob_bytes = self
.download_blob(&repo_name, &layer.digest, &token)
.await?;
let filename = match i {
0 => "component.wasm",
_ => "package.wasm",
};
let file_path = output_dir.join(filename);
fs::write(&file_path, &blob_bytes)?;
}
if let Ok(Some(wit_bytes)) = self
.fetch_wit_referrer(&repo_name, &manifest_digest, &token)
.await
{
fs::write(output_dir.join("package.wasm"), &wit_bytes)?;
}
let metadata = serde_json::json!({
"kind": ResourceKind::Component.to_string(),
"pulled_from": format!("{}@{}", repo_name, version),
});
let metadata_path = output_dir.join("metadata.json");
fs::write(&metadata_path, serde_json::to_string_pretty(&metadata)?)?;
Ok(output_dir)
}
}