Skip to main content

bsdkrun_sdk/
cache.rs

1//! Cached guest directories — [`Sandbox::cache`](crate::Sandbox::cache), plus
2//! host-level listing.
3//!
4//! Entries are keyed, so a rebuild can pick up where the last one left off.
5//! Where they live — host disk or S3 — is host configuration, not an SDK
6//! concern: set `BSDKRUN_CACHE_BACKEND` / `BSDKRUN_CACHE_S3_*`, or write
7//! `~/.config/bsdkrun/cache.toml`.
8
9use serde_json::Value;
10
11use crate::error::{Error, Result};
12use crate::process::run;
13
14/// An archive format a cache entry can be stored in.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum Compression {
17    #[default]
18    Gzip,
19    Zstd,
20    Estargz,
21    None,
22}
23
24impl Compression {
25    fn as_str(self) -> &'static str {
26        match self {
27            Compression::Gzip => "gzip",
28            Compression::Zstd => "zstd",
29            Compression::Estargz => "estargz",
30            Compression::None => "none",
31        }
32    }
33}
34
35/// A stored cache entry, as `cache ls` reports it.
36#[derive(Debug, Clone, Default)]
37pub struct CacheEntry {
38    /// The exact key it was saved under.
39    pub key: String,
40    /// Guest path the tree came from.
41    pub path: String,
42    pub compression: String,
43    /// Archive size in bytes.
44    pub size: u64,
45    /// Unix seconds when it was saved.
46    pub created: u64,
47    /// `sha256:…` over the archive.
48    pub digest: String,
49}
50
51impl CacheEntry {
52    fn from_value(v: &Value) -> CacheEntry {
53        CacheEntry {
54            key: str_at(v, "key"),
55            path: str_at(v, "path"),
56            compression: str_at(v, "compression"),
57            size: num_at(v, "size").unwrap_or(0),
58            created: num_at(v, "created").unwrap_or(0),
59            digest: str_at(v, "digest"),
60        }
61    }
62}
63
64/// What a restore did. A miss is not an error — check [`restored`](Self::restored).
65#[derive(Debug, Clone, Default)]
66pub struct RestoreResult {
67    pub restored: bool,
68    /// The key asked for.
69    pub requested_key: String,
70    /// The entry actually used. Differs from [`requested_key`](Self::requested_key)
71    /// when a restore-key prefix matched, and is `None` on a miss.
72    pub key: Option<String>,
73    /// Guest path it was restored into.
74    pub path: Option<String>,
75    pub size: Option<u64>,
76    pub compression: Option<String>,
77    pub created: Option<u64>,
78}
79
80impl RestoreResult {
81    fn from_value(v: &Value) -> RestoreResult {
82        RestoreResult {
83            restored: v.get("restored").and_then(Value::as_bool).unwrap_or(false),
84            requested_key: str_at(v, "requested_key"),
85            key: opt_str_at(v, "key"),
86            path: opt_str_at(v, "path"),
87            size: num_at(v, "size"),
88            compression: opt_str_at(v, "compression"),
89            created: num_at(v, "created"),
90        }
91    }
92}
93
94// Lenient accessors, matching `types.rs`: the SDK reads the CLI's JSON through
95// `serde_json::Value` rather than deriving, so a field the CLI adds later never
96// turns into a decode error.
97fn str_at(v: &Value, key: &str) -> String {
98    v.get(key)
99        .and_then(Value::as_str)
100        .unwrap_or_default()
101        .to_string()
102}
103
104fn opt_str_at(v: &Value, key: &str) -> Option<String> {
105    v.get(key).and_then(Value::as_str).map(str::to_string)
106}
107
108fn num_at(v: &Value, key: &str) -> Option<u64> {
109    v.get(key).and_then(Value::as_u64)
110}
111
112/// Save and restore guest directories under a key.
113///
114/// ```no_run
115/// # use bsdkrun_sdk::{Sandbox, cache::Compression};
116/// # fn main() -> bsdkrun_sdk::Result<()> {
117/// let sbx = Sandbox::get("web")?;
118/// let hit = sbx.cache().restore("deps-abc123", None, &["deps-".to_string()])?;
119/// if !hit.restored {
120///     sbx.exec(["npm", "ci"])?;
121///     sbx.cache().save("/app/node_modules", "deps-abc123", Compression::Zstd, false)?;
122/// }
123/// # Ok(())
124/// # }
125/// ```
126pub struct Cache {
127    id: String,
128}
129
130impl Cache {
131    pub(crate) fn new(id: impl Into<String>) -> Self {
132        Cache { id: id.into() }
133    }
134
135    /// Archive the guest directory at `path` under `key`.
136    pub fn save(
137        &self,
138        path: &str,
139        key: &str,
140        compression: Compression,
141        force: bool,
142    ) -> Result<CacheEntry> {
143        let mut args = vec![
144            "cache".to_string(),
145            "save".to_string(),
146            format!("{}:{}", self.id, path),
147            "--key".to_string(),
148            key.to_string(),
149            "--json".to_string(),
150        ];
151        if compression != Compression::Gzip {
152            args.push("--compression".to_string());
153            args.push(compression.as_str().to_string());
154        }
155        if force {
156            args.push("--force".to_string());
157        }
158        Ok(CacheEntry::from_value(&json(&args, "bsdkrun cache save")?))
159    }
160
161    /// Restore a stored tree.
162    ///
163    /// `path` defaults to the directory the entry was saved from.
164    /// `restore_keys` are prefixes tried in order when `key` misses; within a
165    /// prefix the newest matching entry wins.
166    pub fn restore(
167        &self,
168        key: &str,
169        path: Option<&str>,
170        restore_keys: &[String],
171    ) -> Result<RestoreResult> {
172        let target = match path {
173            Some(p) => format!("{}:{}", self.id, p),
174            None => self.id.clone(),
175        };
176        let mut args = vec![
177            "cache".to_string(),
178            "restore".to_string(),
179            target,
180            "--key".to_string(),
181            key.to_string(),
182            "--json".to_string(),
183        ];
184        if !restore_keys.is_empty() {
185            args.push("--restore-keys".to_string());
186            args.extend(restore_keys.iter().cloned());
187        }
188        Ok(RestoreResult::from_value(&json(
189            &args,
190            "bsdkrun cache restore",
191        )?))
192    }
193}
194
195/// Every stored cache entry, newest first.
196pub fn list() -> Result<Vec<CacheEntry>> {
197    let v = json(
198        &["cache".to_string(), "ls".to_string(), "--json".to_string()],
199        "bsdkrun cache ls",
200    )?;
201    Ok(v.as_array()
202        .map(|rows| rows.iter().map(CacheEntry::from_value).collect())
203        .unwrap_or_default())
204}
205
206/// Remove entries by key, or every one of them with `all`.
207pub fn remove(keys: &[String], all: bool) -> Result<()> {
208    let mut args = vec!["cache".to_string(), "rm".to_string()];
209    if all {
210        args.push("--all".to_string());
211    } else {
212        args.extend(keys.iter().cloned());
213    }
214    let res = run(args)?;
215    if res.exit_code != 0 {
216        return Err(Error::CommandFailed {
217            exit_code: res.exit_code,
218            stdout: res.stdout,
219            stderr: res.stderr,
220            command: "bsdkrun cache rm".to_string(),
221        });
222    }
223    Ok(())
224}
225
226fn json(args: &[String], label: &str) -> Result<Value> {
227    let res = run(args.to_vec())?;
228    if res.exit_code != 0 {
229        return Err(Error::CommandFailed {
230            exit_code: res.exit_code,
231            stdout: res.stdout,
232            stderr: res.stderr,
233            command: label.to_string(),
234        });
235    }
236    serde_json::from_str(res.stdout.trim()).map_err(|e| Error::CommandFailed {
237        exit_code: 0,
238        stdout: res.stdout.clone(),
239        stderr: format!("could not decode {label} output: {e}"),
240        command: label.to_string(),
241    })
242}