Skip to main content

sui_dockerfile_wrapper/
daemon_client.rs

1//! Daemon-aware cache client — Phase 3b of `supa-charge-akeyless-ci`.
2//!
3//! [`DaemonAwareCacheClient`] implements the same
4//! [`StorageBackend`](sui_castore::StorageBackend) trait the
5//! plain Phase 2 wrapper already consumes, so wiring it in is a
6//! **substitution at construction time**, not a change to
7//! [`crate::run_wrapper`] or its 8 Phase 2 tests: whoever builds the
8//! `Arc<dyn StorageBackend>` decides whether that's a direct remote
9//! backend (today's default) or this daemon-aware wrapper around one.
10//!
11//! Behavior:
12//! - `get_narinfo`/`put_narinfo` first try to reach the node-local
13//!   `sui-dockerfile-node-cache-daemon` over its Unix domain socket
14//!   (see [`sui_dockerfile_node_cache_daemon::default_socket_path`] for
15//!   the well-known integration-contract path a runner pod must mount
16//!   the same `hostPath` volume at to reach it).
17//! - **Daemon reachable**: routes the request through it. The daemon
18//!   itself may transparently fall through to the remote tier on a
19//!   local L0 miss — this client does not duplicate that fetch.
20//! - **Daemon unreachable** (socket missing, or the connection attempt
21//!   fails/times out): falls straight through to the `remote` backend
22//!   passed at construction, byte-for-byte the same call this client
23//!   would have made with no daemon awareness at all — the "never
24//!   require the daemon, always degrade gracefully" contract.
25//! - `put_narinfo` always writes through to `remote` (so the shared
26//!   L1/L2 tiers — and every other node's future local misses — see
27//!   the new entry), and additionally best-effort informs the daemon
28//!   (if reachable) so this node's local L0 is warm without a second
29//!   round trip on the next `Get`. A daemon-`Put` failure is logged,
30//!   never propagated — the remote write already succeeded and is the
31//!   one that matters for correctness.
32//! - Every other [`StorageBackend`] method (`get_nar`/`put_nar`/
33//!   `delete`/`list_narinfos`) is delegated straight to `remote`
34//!   unchanged — the node cache daemon only speaks the narinfo-shaped
35//!   `Get`/`Put`/`Warm` protocol the Dockerfile-graph cache uses.
36
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::time::Duration;
40
41use async_trait::async_trait;
42use sui_cache::StorageBackend;
43use sui_cache::CacheError;
44use sui_dockerfile_node_cache_daemon::protocol::{read_message, write_message};
45use sui_dockerfile_node_cache_daemon::{CachedArtifact, DaemonRequest, DaemonResponse};
46use tokio::net::UnixStream;
47
48/// How long to wait for a UDS connect before deciding "no daemon on
49/// this node". Kept small — this is a same-host socket, not a network
50/// hop; a real daemon accepts near-instantly, so any delay this size
51/// means the socket simply isn't there (or the daemon is wedged, which
52/// should degrade the same as "not there").
53const CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
54
55/// A [`StorageBackend`] that prefers a node-local daemon and falls
56/// through to a direct remote backend when the daemon can't be
57/// reached. See the module docs for the exact per-method contract.
58pub struct DaemonAwareCacheClient {
59    socket_path: Option<PathBuf>,
60    remote: Arc<dyn StorageBackend>,
61}
62
63impl DaemonAwareCacheClient {
64    /// `socket_path = None` makes this client behave identically to
65    /// calling `remote` directly — the byte-for-byte Phase 2 fallback
66    /// path, useful for callers that want the daemon-aware *type*
67    /// without unconditionally trying to connect.
68    #[must_use]
69    pub fn new(socket_path: Option<PathBuf>, remote: Arc<dyn StorageBackend>) -> Self {
70        Self { socket_path, remote }
71    }
72
73    /// Attempt to reach the daemon and exchange one request/response.
74    /// Returns `None` (never an error) when the daemon is unreachable —
75    /// callers treat `None` as "fall through to remote", not a failure.
76    async fn try_daemon_roundtrip(&self, request: &DaemonRequest) -> Option<DaemonResponse> {
77        let path = self.socket_path.as_ref()?;
78        let connect = tokio::time::timeout(CONNECT_TIMEOUT, UnixStream::connect(path)).await;
79        let Ok(Ok(mut stream)) = connect else { return None };
80        if write_message(&mut stream, request).await.is_err() {
81            return None;
82        }
83        read_message::<_, DaemonResponse>(&mut stream).await.ok()
84    }
85}
86
87#[async_trait]
88impl StorageBackend for DaemonAwareCacheClient {
89    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, CacheError> {
90        let request = DaemonRequest::Get { content_hash: hash.to_string() };
91        match self.try_daemon_roundtrip(&request).await {
92            Some(DaemonResponse::Get { artifact }) => Ok(artifact.map(|a| a.image_ref)),
93            Some(DaemonResponse::Error { message }) => {
94                tracing::warn!(target: "sui-dockerfile-wrapper::daemon_client", hash, error = %message, "daemon reported an error servicing Get; falling through to remote");
95                self.remote.get_narinfo(hash).await
96            }
97            Some(other) => {
98                tracing::warn!(target: "sui-dockerfile-wrapper::daemon_client", hash, response = ?other, "unexpected daemon response to Get; falling through to remote");
99                self.remote.get_narinfo(hash).await
100            }
101            None => self.remote.get_narinfo(hash).await,
102        }
103    }
104
105    /// Every record verb is a straight delegation, so the remote's own composed
106    /// `put_narinfo` / `delete` — and therefore its reverse index — stay in
107    /// charge. This wrapper owns no storage and must own no index.
108    async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), CacheError> {
109        self.remote.put_narinfo_record(hash, content).await
110    }
111
112    async fn delete_narinfo_record(&self, hash: &str) -> Result<(), CacheError> {
113        self.remote.delete_narinfo_record(hash).await
114    }
115
116    async fn delete_nar_record(&self, nar_path: &str) -> Result<(), CacheError> {
117        self.remote.delete_nar_record(nar_path).await
118    }
119
120    fn nar_ref_index(&self) -> &dyn sui_cache::NarRefIndex {
121        self.remote.nar_ref_index()
122    }
123
124    /// Overridden rather than inherited, because the daemon warm-up has to
125    /// happen after the remote write. The **composed** `put_narinfo` is what is
126    /// delegated to, so the remote still records the reverse edge; this is a
127    /// proxy adding a side effect, not a backend re-implementing the write.
128    async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), CacheError> {
129        // The remote write is the one that must succeed for
130        // correctness — every other node's future local misses (and
131        // any node with no daemon at all) depend on it.
132        self.remote.put_narinfo(hash, content).await?;
133
134        // Best-effort local warm-up: informs this node's daemon so the
135        // very next local Get for this hash is already a local hit.
136        // A failure here is logged, never propagated — the write
137        // already succeeded where it must.
138        let request = DaemonRequest::Put {
139            content_hash: hash.to_string(),
140            artifact: CachedArtifact { image_ref: content.to_string() },
141        };
142        if let Some(DaemonResponse::Error { message }) = self.try_daemon_roundtrip(&request).await {
143            tracing::warn!(target: "sui-dockerfile-wrapper::daemon_client", hash, error = %message, "daemon reported an error servicing Put (remote write already succeeded)");
144        }
145        Ok(())
146    }
147
148    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, CacheError> {
149        self.remote.get_nar(path).await
150    }
151
152    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), CacheError> {
153        self.remote.put_nar(path, data).await
154    }
155
156    /// Every NAR verb is a straight delegation to `remote`, so the honest
157    /// residency is whatever `remote` reports — this wrapper adds no buffer of
158    /// its own and must not claim a bound the remote does not have.
159    fn nar_residency(&self) -> sui_cache::NarResidency {
160        self.remote.nar_residency()
161    }
162
163    /// Delegate the streaming verbs too, so a streaming remote is not silently
164    /// downgraded to the trait's buffering default by passing through here.
165    async fn get_nar_stream(
166        &self,
167        path: &str,
168    ) -> Result<Option<sui_cache::NarStream>, CacheError> {
169        self.remote.get_nar_stream(path).await
170    }
171
172    async fn put_nar_stream(
173        &self,
174        path: &str,
175        src: &dyn sui_cache::NarSource,
176    ) -> Result<(), CacheError> {
177        self.remote.put_nar_stream(path, src).await
178    }
179
180    /// Delegate the whole composed delete, so the remote's index consultation
181    /// happens once, on the side that owns the data.
182    async fn delete(&self, hash: &str) -> Result<(), CacheError> {
183        self.remote.delete(hash).await
184    }
185
186    async fn list_narinfos(&self) -> Result<Vec<String>, CacheError> {
187        self.remote.list_narinfos().await
188    }
189}
190
191/// Probe whether `path` looks like a live socket file. Used by callers
192/// deciding whether to construct a [`DaemonAwareCacheClient`] with
193/// `Some(path)` at all (a pure existence check — the client's own
194/// connect-with-timeout handles the "exists but not accepting" case,
195/// so this is a cheap early filter, not the sole gate).
196#[must_use]
197pub fn socket_looks_present(path: &Path) -> bool {
198    path.exists()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::cache::MockCacheBackend;
205    use sui_dockerfile_node_cache_daemon::{bind_unix_listener, WarmStatus};
206    use tokio::sync::watch;
207
208    /// Spins up a tiny fake daemon that answers with a single
209    /// pre-programmed response to every request it receives, so tests
210    /// can assert the client's daemon-reachable behavior without
211    /// depending on the real `sui-dockerfile-node-cache-daemon` server
212    /// logic (that crate's own tests cover the real daemon behavior).
213    fn spawn_fake_daemon(socket_path: &Path, response: DaemonResponse) -> watch::Sender<bool> {
214        let listener = bind_unix_listener(socket_path).unwrap();
215        let (tx, mut rx) = watch::channel(false);
216        tokio::spawn(async move {
217            loop {
218                tokio::select! {
219                    accepted = listener.accept() => {
220                        let Ok((mut stream, _)) = accepted else { continue };
221                        let response = response.clone();
222                        tokio::spawn(async move {
223                            let _req: Result<DaemonRequest, _> = read_message(&mut stream).await;
224                            let _ = write_message(&mut stream, &response).await;
225                        });
226                    }
227                    _ = rx.changed() => {
228                        if *rx.borrow() { break; }
229                    }
230                }
231            }
232        });
233        tx
234    }
235
236    #[tokio::test]
237    async fn daemon_reachable_and_hit_never_calls_remote() {
238        let dir = tempfile::tempdir().unwrap();
239        let socket_path = dir.path().join("daemon.sock");
240        let _shutdown = spawn_fake_daemon(
241            &socket_path,
242            DaemonResponse::Get { artifact: Some(CachedArtifact { image_ref: "img:from-daemon".to_string() }) },
243        );
244
245        let remote = Arc::new(MockCacheBackend::new());
246        let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
247        let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
248
249        let got = client.get_narinfo("h1").await.unwrap();
250        assert_eq!(got.as_deref(), Some("img:from-daemon"));
251        // The mock remote was never populated and never queried:
252        // confirmed indirectly — a direct query would return None,
253        // whereas the daemon's canned response was returned.
254        assert_eq!(remote.get_narinfo("h1").await.unwrap(), None);
255    }
256
257    #[tokio::test]
258    async fn daemon_reachable_and_miss_returns_none_without_querying_remote_again() {
259        let dir = tempfile::tempdir().unwrap();
260        let socket_path = dir.path().join("daemon.sock");
261        let _shutdown = spawn_fake_daemon(&socket_path, DaemonResponse::Get { artifact: None });
262
263        // Even though the remote WOULD have this hash, a reachable
264        // daemon's answer is authoritative — the client must not
265        // duplicate the remote lookup itself.
266        let remote = Arc::new(MockCacheBackend::new().with_entry("h2", "img:should-not-be-seen"));
267        let remote_dyn: Arc<dyn StorageBackend> = remote;
268        let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
269
270        let got = client.get_narinfo("h2").await.unwrap();
271        assert_eq!(got, None, "daemon's Get-miss answer must be trusted, not overridden by a second remote check");
272    }
273
274    #[tokio::test]
275    async fn daemon_unreachable_falls_through_to_remote_unchanged() {
276        let dir = tempfile::tempdir().unwrap();
277        // No daemon listening at this path at all.
278        let socket_path = dir.path().join("no-daemon-here.sock");
279
280        let remote = Arc::new(MockCacheBackend::new().with_entry("h3", "img:direct-remote"));
281        let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
282        let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn.clone());
283
284        let via_client = client.get_narinfo("h3").await.unwrap();
285        let via_remote_directly = remote_dyn.get_narinfo("h3").await.unwrap();
286        assert_eq!(via_client, via_remote_directly);
287        assert_eq!(via_client.as_deref(), Some("img:direct-remote"));
288    }
289
290    #[tokio::test]
291    async fn no_socket_path_configured_behaves_exactly_like_direct_remote() {
292        let remote = Arc::new(MockCacheBackend::new().with_entry("h4", "img:no-daemon-configured"));
293        let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
294        let client = DaemonAwareCacheClient::new(None, remote_dyn.clone());
295
296        let via_client = client.get_narinfo("h4").await.unwrap();
297        let via_remote_directly = remote_dyn.get_narinfo("h4").await.unwrap();
298        assert_eq!(via_client, via_remote_directly);
299    }
300
301    #[tokio::test]
302    async fn put_always_writes_through_to_remote_even_when_daemon_unreachable() {
303        let dir = tempfile::tempdir().unwrap();
304        let socket_path = dir.path().join("no-daemon-here.sock");
305
306        let remote = Arc::new(MockCacheBackend::new());
307        let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
308        let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
309
310        client.put_narinfo("h5", "img:backfilled").await.unwrap();
311        assert_eq!(remote.get_narinfo("h5").await.unwrap().as_deref(), Some("img:backfilled"));
312    }
313
314    #[tokio::test]
315    async fn put_writes_through_to_remote_and_warms_the_daemon() {
316        let dir = tempfile::tempdir().unwrap();
317        let socket_path = dir.path().join("daemon.sock");
318        let _shutdown =
319            spawn_fake_daemon(&socket_path, DaemonResponse::Put { ok: true });
320
321        let remote = Arc::new(MockCacheBackend::new());
322        let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
323        let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
324
325        client.put_narinfo("h6", "img:dual-write").await.unwrap();
326        assert_eq!(remote.get_narinfo("h6").await.unwrap().as_deref(), Some("img:dual-write"));
327    }
328
329    #[test]
330    fn socket_presence_check_is_a_pure_filesystem_check() {
331        let dir = tempfile::tempdir().unwrap();
332        assert!(!socket_looks_present(&dir.path().join("nope.sock")));
333    }
334
335    // Silence an "unused import" concern in case a future edit trims a
336    // test that referenced WarmStatus directly — kept as a smoke check
337    // that the re-export is reachable from this crate.
338    #[test]
339    fn warm_status_type_is_reachable_from_this_crate() {
340        let _ = WarmStatus::AlreadyLocal;
341    }
342}