greentic_bundle/catalog/
client.rs1use std::path::Path;
2
3use anyhow::{Result, bail};
4use greentic_distributor_client::oci_packs::DefaultRegistryClient;
5use greentic_distributor_client::{OciPackFetcher, PackFetchOptions};
6use tokio::runtime::Runtime;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct FetchedCatalog {
10 pub resolved_ref: String,
11 pub digest: String,
12 pub bytes: Vec<u8>,
13}
14
15pub trait CatalogArtifactClient {
16 fn fetch_catalog(&self, root: &Path, reference: &str) -> Result<FetchedCatalog>;
17}
18
19#[derive(Debug, Default, Clone, Copy)]
20pub struct CacheOnlyCatalogClient;
21
22impl CatalogArtifactClient for CacheOnlyCatalogClient {
23 fn fetch_catalog(&self, _root: &Path, reference: &str) -> Result<FetchedCatalog> {
24 bail!(
25 "catalog {reference} is not cached locally and this build does not yet provide a remote fetch backend; seed the workspace-local cache first or rerun with a local file:// catalog"
26 )
27 }
28}
29
30#[derive(Debug, Default, Clone, Copy)]
31pub struct DistributorCatalogClient;
32
33impl CatalogArtifactClient for DistributorCatalogClient {
34 fn fetch_catalog(&self, root: &Path, reference: &str) -> Result<FetchedCatalog> {
35 let mapped = map_remote_catalog_reference(reference)?;
36 let fetcher: OciPackFetcher<DefaultRegistryClient> =
37 OciPackFetcher::new(PackFetchOptions {
38 allow_tags: true,
39 offline: false,
40 cache_dir: root
41 .join(crate::catalog::CACHE_ROOT_DIR)
42 .join("distributor"),
43 ..PackFetchOptions::default()
44 });
45 let runtime = Runtime::new()?;
46 let resolved = runtime.block_on(fetcher.fetch_pack_to_cache(&mapped.oci_reference))?;
47 let bytes = std::fs::read(&resolved.path)?;
48 Ok(FetchedCatalog {
49 resolved_ref: format!("oci://{}", mapped.oci_reference),
50 digest: resolved.resolved_digest,
51 bytes,
52 })
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct RemoteCatalogRef {
58 pub oci_reference: String,
59 pub source_kind: RemoteCatalogSourceKind,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum RemoteCatalogSourceKind {
64 Oci,
65 GhcrWellKnown,
66}
67
68pub fn map_remote_catalog_reference(reference: &str) -> Result<RemoteCatalogRef> {
69 if let Some(raw) = reference.strip_prefix("oci://") {
70 let trimmed = raw.trim();
71 if trimmed.is_empty() {
72 bail!("catalog reference {reference} is missing an OCI path");
73 }
74 return Ok(RemoteCatalogRef {
75 oci_reference: trimmed.to_string(),
76 source_kind: RemoteCatalogSourceKind::Oci,
77 });
78 }
79
80 if let Some(raw) = reference.strip_prefix("ghcr://") {
81 let trimmed = raw.trim().trim_start_matches('/');
82 if trimmed.is_empty() {
83 bail!("catalog reference {reference} is missing a GHCR path");
84 }
85 return Ok(RemoteCatalogRef {
86 oci_reference: format!("ghcr.io/greenticai/{}", default_ghcr_tag(trimmed)),
87 source_kind: RemoteCatalogSourceKind::GhcrWellKnown,
88 });
89 }
90
91 bail!(
92 "catalog {reference} is not a supported remote catalog ref; use file://, oci://<registry>/<repo>[:tag|@sha256:...], or ghcr://<path>[:tag|@sha256:...] for ghcr.io/greenticai"
93 )
94}
95
96fn default_ghcr_tag(reference: &str) -> String {
97 if reference.contains('@')
98 || reference
99 .rsplit('/')
100 .next()
101 .unwrap_or_default()
102 .contains(':')
103 {
104 reference.to_string()
105 } else {
106 format!("{reference}:latest")
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::{RemoteCatalogSourceKind, map_remote_catalog_reference};
113
114 #[test]
115 fn maps_ghcr_shortcut_to_greenticai_namespace() {
116 let mapped = map_remote_catalog_reference("ghcr://catalogs/well-known").expect("mapped");
117 assert_eq!(
118 mapped.oci_reference,
119 "ghcr.io/greenticai/catalogs/well-known:latest"
120 );
121 assert_eq!(mapped.source_kind, RemoteCatalogSourceKind::GhcrWellKnown);
122 }
123
124 #[test]
125 fn preserves_explicit_digest_for_ghcr_shortcut() {
126 let mapped = map_remote_catalog_reference("ghcr://catalogs/well-known@sha256:abc123")
127 .expect("mapped");
128 assert_eq!(
129 mapped.oci_reference,
130 "ghcr.io/greenticai/catalogs/well-known@sha256:abc123"
131 );
132 }
133
134 #[test]
135 fn preserves_raw_oci_reference() {
136 let mapped =
137 map_remote_catalog_reference("oci://ghcr.io/example/catalogs/demo:1").expect("mapped");
138 assert_eq!(mapped.oci_reference, "ghcr.io/example/catalogs/demo:1");
139 assert_eq!(mapped.source_kind, RemoteCatalogSourceKind::Oci);
140 }
141}