use crate::registry::oci::OciRegistryClient;
use crate::registry::platform::{
fallback_arch_suffixes, target_triple_to_arch_suffix, target_triple_to_oci_platform,
};
use crate::registry::types::{annotations, HostVersionInfo, PluginReference, ResolvedPlugin};
use anyhow::{bail, Context, Result};
use log::{debug, info, warn};
use semver::Version;
pub struct PluginResolver<'a> {
client: &'a OciRegistryClient,
host_info: &'a HostVersionInfo,
}
impl<'a> PluginResolver<'a> {
pub fn new(client: &'a OciRegistryClient, host_info: &'a HostVersionInfo) -> Self {
Self { client, host_info }
}
pub async fn resolve(&self, reference: &str, default_registry: &str) -> Result<ResolvedPlugin> {
let parsed = PluginReference::parse(reference, default_registry)?;
match &parsed.tag {
Some(tag) => self.resolve_exact(&parsed, tag).await,
None => self.resolve_latest_compatible(&parsed).await,
}
}
async fn resolve_exact(&self, parsed: &PluginReference, tag: &str) -> Result<ResolvedPlugin> {
let arch_suffix = target_triple_to_arch_suffix(&self.host_info.target_triple)
.context("unsupported platform — cannot determine architecture suffix")?;
let mut suffixes = vec![arch_suffix];
suffixes.extend(fallback_arch_suffixes(&self.host_info.target_triple));
let mut last_err = None;
for suffix in &suffixes {
let platform_tag = format!("{tag}-{suffix}");
let full_ref = format!("{}/{}:{}", parsed.registry, parsed.repository, platform_tag);
debug!(
"Resolving exact version: {} (platform tag: {})",
tag, platform_tag
);
match self.client.fetch_manifest_annotations(&full_ref).await {
Ok(annotations) => {
self.check_compatibility(&annotations, &full_ref)?;
let digest = self
.client
.get_digest(&full_ref)
.await
.context("failed to get digest")?;
let filename = self.derive_filename(&annotations);
return Ok(ResolvedPlugin {
reference: format!("{}/{}@{}", parsed.registry, parsed.repository, digest),
version: tag.to_string(),
sdk_version: annotations
.get(annotations::SDK_VERSION)
.cloned()
.unwrap_or_default(),
core_version: annotations
.get(annotations::CORE_VERSION)
.cloned()
.unwrap_or_default(),
lib_version: annotations
.get(annotations::LIB_VERSION)
.cloned()
.unwrap_or_default(),
platform: target_triple_to_oci_platform(&self.host_info.target_triple)
.map(|p| p.to_string())
.unwrap_or_default(),
digest,
filename,
});
}
Err(e) => {
if suffixes.len() > 1 {
debug!("Tag {platform_tag} not found, trying next suffix...");
}
last_err = Some(e);
}
}
}
Err(last_err
.unwrap_or_else(|| anyhow::anyhow!("no matching platform tag found"))
.context("failed to fetch manifest annotations"))
}
async fn resolve_latest_compatible(&self, parsed: &PluginReference) -> Result<ResolvedPlugin> {
let base_ref = format!("{}/{}", parsed.registry, parsed.repository);
info!("Resolving latest compatible version for {}...", base_ref);
let arch_suffix = target_triple_to_arch_suffix(&self.host_info.target_triple)
.context("unsupported platform — cannot determine architecture suffix")?;
let mut suffixes = vec![arch_suffix.clone()];
suffixes.extend(fallback_arch_suffixes(&self.host_info.target_triple));
let ref_for_tags = parsed.to_oci_reference();
let tags = self
.client
.list_tags(&ref_for_tags)
.await
.context("failed to list tags")?;
if tags.is_empty() {
bail!("no tags found for {}", base_ref);
}
for suffix in &suffixes {
let expected_suffix = format!("-{suffix}");
let mut semver_tags: Vec<(Version, String)> = tags
.iter()
.filter_map(|tag| {
let version_str = tag.strip_suffix(&expected_suffix)?;
Version::parse(version_str).ok().and_then(|v| {
if v.pre.is_empty() {
Some((v, version_str.to_string()))
} else {
debug!(" Skipping pre-release tag: {}", version_str);
None
}
})
})
.collect();
semver_tags.sort_by(|a, b| b.0.cmp(&a.0));
if semver_tags.is_empty() {
if suffixes.len() > 1 {
debug!("No tags found for suffix {suffix}, trying next...");
}
continue;
}
debug!(
"Found {} semver tags for {}, checking compatibility (newest first)...",
semver_tags.len(),
suffix
);
for (_version, version_str) in &semver_tags {
let platform_tag = format!("{version_str}-{suffix}");
let full_ref = format!("{base_ref}:{platform_tag}");
match self.client.fetch_manifest_annotations(&full_ref).await {
Ok(ann) => {
if self.is_compatible(&ann) {
info!("Found compatible version: {} ({})", version_str, full_ref);
let digest = self
.client
.get_digest(&full_ref)
.await
.context("failed to get digest")?;
let filename = self.derive_filename(&ann);
return Ok(ResolvedPlugin {
reference: format!(
"{}/{}@{}",
parsed.registry, parsed.repository, digest
),
version: version_str.clone(),
sdk_version: ann
.get(annotations::SDK_VERSION)
.cloned()
.unwrap_or_default(),
core_version: ann
.get(annotations::CORE_VERSION)
.cloned()
.unwrap_or_default(),
lib_version: ann
.get(annotations::LIB_VERSION)
.cloned()
.unwrap_or_default(),
platform: target_triple_to_oci_platform(
&self.host_info.target_triple,
)
.map(|p| p.to_string())
.unwrap_or_default(),
digest,
filename,
});
} else {
debug!(
" {} — incompatible (sdk: {}, core: {}, lib: {})",
version_str,
ann.get(annotations::SDK_VERSION)
.unwrap_or(&"?".to_string()),
ann.get(annotations::CORE_VERSION)
.unwrap_or(&"?".to_string()),
ann.get(annotations::LIB_VERSION)
.unwrap_or(&"?".to_string()),
);
}
}
Err(e) => {
warn!("Failed to check {}: {}", full_ref, e);
}
}
}
}
bail!(
"no compatible version found for {} on platform {}\n Host versions: SDK {}, core {}, lib {}\n Checked tags across {} platform suffix(es)",
base_ref,
arch_suffix,
self.host_info.sdk_version,
self.host_info.core_version,
self.host_info.lib_version,
suffixes.len()
)
}
fn is_compatible(&self, ann: &std::collections::BTreeMap<String, String>) -> bool {
let checks = [
(annotations::SDK_VERSION, &self.host_info.sdk_version),
(annotations::CORE_VERSION, &self.host_info.core_version),
(annotations::LIB_VERSION, &self.host_info.lib_version),
];
for (key, host_ver) in &checks {
match ann.get(*key) {
Some(plugin_ver) => {
if !major_minor_match(host_ver, plugin_ver) {
return false;
}
}
None => {
return false;
}
}
}
true
}
fn check_compatibility(
&self,
ann: &std::collections::BTreeMap<String, String>,
reference: &str,
) -> Result<()> {
let checks = [
("SDK", annotations::SDK_VERSION, &self.host_info.sdk_version),
(
"core",
annotations::CORE_VERSION,
&self.host_info.core_version,
),
("lib", annotations::LIB_VERSION, &self.host_info.lib_version),
];
let mut mismatches = Vec::new();
for (name, key, host_ver) in &checks {
if let Some(plugin_ver) = ann.get(*key) {
if !major_minor_match(host_ver, plugin_ver) {
mismatches.push(format!(
" {} version: host={}, plugin={} (major.minor mismatch)",
name, host_ver, plugin_ver
));
}
} else {
mismatches.push(format!(" {} version: missing annotation ({})", name, key));
}
}
if !mismatches.is_empty() {
bail!(
"plugin {} is incompatible with this host:\n{}",
reference,
mismatches.join("\n")
);
}
Ok(())
}
fn derive_filename(&self, ann: &std::collections::BTreeMap<String, String>) -> String {
let kind = ann
.get(annotations::PLUGIN_KIND)
.cloned()
.unwrap_or_default();
let plugin_type = ann
.get(annotations::PLUGIN_TYPE)
.cloned()
.unwrap_or_default();
let crate_name = format!("drasi_{}_{}", plugin_type, kind.replace('-', "_"));
let target = &self.host_info.target_triple;
let is_windows = target.contains("windows");
let ext = if is_windows {
"dll"
} else if target.contains("apple") || target.contains("darwin") {
"dylib"
} else {
"so"
};
let prefix = if is_windows { "" } else { "lib" };
format!("{}{}.{}", prefix, crate_name, ext)
}
}
fn major_minor_match(a: &str, b: &str) -> bool {
match (Version::parse(a), Version::parse(b)) {
(Ok(va), Ok(vb)) => va.major == vb.major && va.minor == vb.minor,
_ => a == b, }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_major_minor_match() {
assert!(major_minor_match("0.3.1", "0.3.8"));
assert!(major_minor_match("1.2.0", "1.2.99"));
assert!(!major_minor_match("0.3.1", "0.4.0"));
assert!(!major_minor_match("1.0.0", "2.0.0"));
}
#[test]
fn test_major_minor_match_invalid() {
assert!(major_minor_match("abc", "abc"));
assert!(!major_minor_match("abc", "def"));
}
#[test]
fn test_prerelease_tags_are_parseable_but_skipped() {
let v = Version::parse("0.1.8-dev.1").unwrap();
assert!(!v.pre.is_empty());
let v = Version::parse("0.1.8").unwrap();
assert!(v.pre.is_empty());
let parsed = PluginReference::parse("source/postgres:0.1.8-dev.1", "ghcr.io/drasi-project");
assert!(parsed.is_ok());
let parsed = parsed.unwrap();
assert_eq!(parsed.tag, Some("0.1.8-dev.1".to_string()));
}
}