Skip to main content

bv_builder/
oci.rs

1use anyhow::{Context, Result};
2use oci_client::{
3    Reference,
4    client::{Client, ClientConfig, Config, ImageLayer},
5    secrets::RegistryAuth,
6};
7
8use crate::build::{OciImage, OciLayer};
9use bv_core::lockfile::LayerDescriptor;
10
11fn registry_auth() -> RegistryAuth {
12    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
13        RegistryAuth::Basic("token".into(), token)
14    } else {
15        RegistryAuth::Anonymous
16    }
17}
18
19fn token_auth(token: &str) -> RegistryAuth {
20    RegistryAuth::Basic("token".into(), token.to_string())
21}
22
23/// Load an OCI image from a tarball previously saved by `save_oci_tarball`.
24pub fn load_from_tarball(path: &std::path::Path) -> Result<OciImage> {
25    use std::io::Read;
26
27    let f =
28        std::fs::File::open(path).with_context(|| format!("open tarball {}", path.display()))?;
29    let mut archive = tar::Archive::new(f);
30
31    let mut manifest_bytes: Option<Vec<u8>> = None;
32    let mut blobs: std::collections::HashMap<String, Vec<u8>> = std::collections::HashMap::new();
33
34    for entry in archive.entries().context("read tar entries")? {
35        let mut entry = entry.context("read tar entry")?;
36        let path_str = entry
37            .path()
38            .context("get entry path")?
39            .to_string_lossy()
40            .into_owned();
41        let mut data = Vec::new();
42        entry.read_to_end(&mut data).context("read entry data")?;
43
44        if path_str == "manifest.json" {
45            manifest_bytes = Some(data);
46        } else if let Some(hex) = path_str.strip_prefix("blobs/sha256/") {
47            blobs.insert(hex.to_string(), data);
48        }
49    }
50
51    let manifest_bytes = manifest_bytes.context("manifest.json not found in tarball")?;
52    let manifest: serde_json::Value =
53        serde_json::from_slice(&manifest_bytes).context("parse manifest.json")?;
54
55    let config_digest = manifest["config"]["digest"]
56        .as_str()
57        .context("manifest.config.digest missing")?;
58    let config_hex = config_digest
59        .strip_prefix("sha256:")
60        .unwrap_or(config_digest);
61    let config = blobs
62        .remove(config_hex)
63        .with_context(|| format!("config blob {config_hex} not found in tarball"))?;
64
65    let layers_json = manifest["layers"]
66        .as_array()
67        .context("manifest.layers missing")?;
68    let mut layers = Vec::new();
69    for layer_json in layers_json {
70        let digest = layer_json["digest"]
71            .as_str()
72            .context("layer.digest missing")?;
73        let media_type = layer_json["mediaType"]
74            .as_str()
75            .context("layer.mediaType missing")?;
76        let size = layer_json["size"].as_u64().context("layer.size missing")?;
77        let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
78        // Use get+clone, not remove: the same blob digest can appear multiple
79        // times in a manifest (e.g. empty-package layers all share one digest).
80        let compressed = blobs
81            .get(hex)
82            .cloned()
83            .with_context(|| format!("layer blob {hex} not found in tarball"))?;
84
85        layers.push(OciLayer {
86            compressed,
87            uncompressed_digest: String::new(),
88            descriptor: LayerDescriptor {
89                digest: digest.to_string(),
90                size,
91                media_type: media_type.to_string(),
92                conda_package: None,
93            },
94        });
95    }
96
97    Ok(OciImage {
98        name: String::new(),
99        version: String::new(),
100        layers,
101        config,
102    })
103}
104
105/// Push an `OciImage` to a registry using an explicit token.
106///
107/// Use this from callers that already have a GHCR token from user auth,
108/// rather than relying on the `GITHUB_TOKEN` env var.
109pub async fn push_authenticated(image: &OciImage, reference: &str, token: &str) -> Result<String> {
110    push_inner(image, reference, token_auth(token)).await
111}
112
113/// Push an `OciImage` to a registry.
114///
115/// Returns the digest of the pushed manifest.
116pub async fn push(image: &OciImage, reference: &str) -> Result<String> {
117    push_inner(image, reference, registry_auth()).await
118}
119
120async fn push_inner(image: &OciImage, reference: &str, auth: RegistryAuth) -> Result<String> {
121    let reference: Reference = reference
122        .parse()
123        .with_context(|| format!("parse OCI reference '{reference}'"))?;
124
125    let config = ClientConfig {
126        protocol: oci_client::client::ClientProtocol::HttpsExcept(vec![
127            "localhost".into(),
128            "127.0.0.1".into(),
129        ]),
130        ..Default::default()
131    };
132
133    let client = Client::new(config);
134
135    let mut delay = std::time::Duration::from_secs(30);
136    let mut last_err: Option<anyhow::Error> = None;
137
138    for attempt in 0..8u32 {
139        if attempt > 0 {
140            eprintln!(
141                "  rate limited, retrying in {:?} (attempt {}/8)...",
142                delay,
143                attempt + 1
144            );
145            tokio::time::sleep(delay).await;
146            delay = (delay * 2).min(std::time::Duration::from_secs(120));
147        }
148
149        // Reconstruct layers/config each attempt; oci-client takes ownership.
150        let layers: Vec<ImageLayer> = image
151            .layers
152            .iter()
153            .map(|l| ImageLayer::new(l.compressed.clone(), l.descriptor.media_type.clone(), None))
154            .collect();
155        let oci_config = Config::oci_v1(image.config.clone(), None);
156
157        match client
158            .push(&reference, &layers, oci_config, &auth, None)
159            .await
160        {
161            Ok(resp) => {
162                // Extract manifest digest. GHCR returns the URL as
163                // `.../manifests/sha256:<hex>` so check the last path segment
164                // first, then fall back to the `@sha256:<hex>` style.
165                let digest = resp
166                    .manifest_url
167                    .rsplit('/')
168                    .next()
169                    .filter(|s| s.starts_with("sha256:"))
170                    .or_else(|| resp.manifest_url.split('@').nth(1))
171                    .unwrap_or("unknown")
172                    .to_string();
173                return Ok(digest);
174            }
175            Err(e) if is_rate_limited(&e) => {
176                last_err = Some(anyhow::anyhow!("{e}"));
177            }
178            Err(e) => {
179                return Err(anyhow::anyhow!("{e}"))
180                    .with_context(|| format!("push image to '{reference}'"));
181            }
182        }
183    }
184
185    Err(last_err.unwrap()).with_context(|| {
186        format!("push image to '{reference}' (rate limit: all retries exhausted after 8 attempts)")
187    })
188}
189
190fn is_rate_limited(e: &oci_client::errors::OciDistributionError) -> bool {
191    let s = format!("{e:?}");
192    s.contains("429") || s.contains("TOOMANYREQUESTS")
193}
194
195/// Fetch an image manifest from a registry and verify its digest matches
196/// `expected_digest`.
197pub async fn verify(reference: &str, expected_digest: &str) -> Result<()> {
198    let reference: Reference = reference
199        .parse()
200        .with_context(|| format!("parse OCI reference '{reference}'"))?;
201
202    let client = Client::new(ClientConfig::default());
203    let auth = registry_auth();
204
205    let (_manifest, digest) = client
206        .pull_manifest(&reference, &auth)
207        .await
208        .with_context(|| format!("pull manifest for '{reference}'"))?;
209
210    if digest != expected_digest {
211        anyhow::bail!(
212            "digest mismatch for '{reference}': expected {expected_digest} but registry returned {digest}"
213        );
214    }
215
216    Ok(())
217}