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_cache::storage::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::storage::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    async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), CacheError> {
106        // The remote write is the one that must succeed for
107        // correctness — every other node's future local misses (and
108        // any node with no daemon at all) depend on it.
109        self.remote.put_narinfo(hash, content).await?;
110
111        // Best-effort local warm-up: informs this node's daemon so the
112        // very next local Get for this hash is already a local hit.
113        // A failure here is logged, never propagated — the write
114        // already succeeded where it must.
115        let request = DaemonRequest::Put {
116            content_hash: hash.to_string(),
117            artifact: CachedArtifact { image_ref: content.to_string() },
118        };
119        if let Some(DaemonResponse::Error { message }) = self.try_daemon_roundtrip(&request).await {
120            tracing::warn!(target: "sui-dockerfile-wrapper::daemon_client", hash, error = %message, "daemon reported an error servicing Put (remote write already succeeded)");
121        }
122        Ok(())
123    }
124
125    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, CacheError> {
126        self.remote.get_nar(path).await
127    }
128
129    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), CacheError> {
130        self.remote.put_nar(path, data).await
131    }
132
133    async fn delete(&self, hash: &str) -> Result<(), CacheError> {
134        self.remote.delete(hash).await
135    }
136
137    async fn list_narinfos(&self) -> Result<Vec<String>, CacheError> {
138        self.remote.list_narinfos().await
139    }
140}
141
142/// Probe whether `path` looks like a live socket file. Used by callers
143/// deciding whether to construct a [`DaemonAwareCacheClient`] with
144/// `Some(path)` at all (a pure existence check — the client's own
145/// connect-with-timeout handles the "exists but not accepting" case,
146/// so this is a cheap early filter, not the sole gate).
147#[must_use]
148pub fn socket_looks_present(path: &Path) -> bool {
149    path.exists()
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::cache::MockCacheBackend;
156    use sui_dockerfile_node_cache_daemon::{bind_unix_listener, WarmStatus};
157    use tokio::sync::watch;
158
159    /// Spins up a tiny fake daemon that answers with a single
160    /// pre-programmed response to every request it receives, so tests
161    /// can assert the client's daemon-reachable behavior without
162    /// depending on the real `sui-dockerfile-node-cache-daemon` server
163    /// logic (that crate's own tests cover the real daemon behavior).
164    fn spawn_fake_daemon(socket_path: &Path, response: DaemonResponse) -> watch::Sender<bool> {
165        let listener = bind_unix_listener(socket_path).unwrap();
166        let (tx, mut rx) = watch::channel(false);
167        tokio::spawn(async move {
168            loop {
169                tokio::select! {
170                    accepted = listener.accept() => {
171                        let Ok((mut stream, _)) = accepted else { continue };
172                        let response = response.clone();
173                        tokio::spawn(async move {
174                            let _req: Result<DaemonRequest, _> = read_message(&mut stream).await;
175                            let _ = write_message(&mut stream, &response).await;
176                        });
177                    }
178                    _ = rx.changed() => {
179                        if *rx.borrow() { break; }
180                    }
181                }
182            }
183        });
184        tx
185    }
186
187    #[tokio::test]
188    async fn daemon_reachable_and_hit_never_calls_remote() {
189        let dir = tempfile::tempdir().unwrap();
190        let socket_path = dir.path().join("daemon.sock");
191        let _shutdown = spawn_fake_daemon(
192            &socket_path,
193            DaemonResponse::Get { artifact: Some(CachedArtifact { image_ref: "img:from-daemon".to_string() }) },
194        );
195
196        let remote = Arc::new(MockCacheBackend::new());
197        let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
198        let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
199
200        let got = client.get_narinfo("h1").await.unwrap();
201        assert_eq!(got.as_deref(), Some("img:from-daemon"));
202        // The mock remote was never populated and never queried:
203        // confirmed indirectly — a direct query would return None,
204        // whereas the daemon's canned response was returned.
205        assert_eq!(remote.get_narinfo("h1").await.unwrap(), None);
206    }
207
208    #[tokio::test]
209    async fn daemon_reachable_and_miss_returns_none_without_querying_remote_again() {
210        let dir = tempfile::tempdir().unwrap();
211        let socket_path = dir.path().join("daemon.sock");
212        let _shutdown = spawn_fake_daemon(&socket_path, DaemonResponse::Get { artifact: None });
213
214        // Even though the remote WOULD have this hash, a reachable
215        // daemon's answer is authoritative — the client must not
216        // duplicate the remote lookup itself.
217        let remote = Arc::new(MockCacheBackend::new().with_entry("h2", "img:should-not-be-seen"));
218        let remote_dyn: Arc<dyn StorageBackend> = remote;
219        let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
220
221        let got = client.get_narinfo("h2").await.unwrap();
222        assert_eq!(got, None, "daemon's Get-miss answer must be trusted, not overridden by a second remote check");
223    }
224
225    #[tokio::test]
226    async fn daemon_unreachable_falls_through_to_remote_unchanged() {
227        let dir = tempfile::tempdir().unwrap();
228        // No daemon listening at this path at all.
229        let socket_path = dir.path().join("no-daemon-here.sock");
230
231        let remote = Arc::new(MockCacheBackend::new().with_entry("h3", "img:direct-remote"));
232        let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
233        let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn.clone());
234
235        let via_client = client.get_narinfo("h3").await.unwrap();
236        let via_remote_directly = remote_dyn.get_narinfo("h3").await.unwrap();
237        assert_eq!(via_client, via_remote_directly);
238        assert_eq!(via_client.as_deref(), Some("img:direct-remote"));
239    }
240
241    #[tokio::test]
242    async fn no_socket_path_configured_behaves_exactly_like_direct_remote() {
243        let remote = Arc::new(MockCacheBackend::new().with_entry("h4", "img:no-daemon-configured"));
244        let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
245        let client = DaemonAwareCacheClient::new(None, remote_dyn.clone());
246
247        let via_client = client.get_narinfo("h4").await.unwrap();
248        let via_remote_directly = remote_dyn.get_narinfo("h4").await.unwrap();
249        assert_eq!(via_client, via_remote_directly);
250    }
251
252    #[tokio::test]
253    async fn put_always_writes_through_to_remote_even_when_daemon_unreachable() {
254        let dir = tempfile::tempdir().unwrap();
255        let socket_path = dir.path().join("no-daemon-here.sock");
256
257        let remote = Arc::new(MockCacheBackend::new());
258        let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
259        let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
260
261        client.put_narinfo("h5", "img:backfilled").await.unwrap();
262        assert_eq!(remote.get_narinfo("h5").await.unwrap().as_deref(), Some("img:backfilled"));
263    }
264
265    #[tokio::test]
266    async fn put_writes_through_to_remote_and_warms_the_daemon() {
267        let dir = tempfile::tempdir().unwrap();
268        let socket_path = dir.path().join("daemon.sock");
269        let _shutdown =
270            spawn_fake_daemon(&socket_path, DaemonResponse::Put { ok: true });
271
272        let remote = Arc::new(MockCacheBackend::new());
273        let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
274        let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
275
276        client.put_narinfo("h6", "img:dual-write").await.unwrap();
277        assert_eq!(remote.get_narinfo("h6").await.unwrap().as_deref(), Some("img:dual-write"));
278    }
279
280    #[test]
281    fn socket_presence_check_is_a_pure_filesystem_check() {
282        let dir = tempfile::tempdir().unwrap();
283        assert!(!socket_looks_present(&dir.path().join("nope.sock")));
284    }
285
286    // Silence an "unused import" concern in case a future edit trims a
287    // test that referenced WarmStatus directly — kept as a smoke check
288    // that the re-export is reachable from this crate.
289    #[test]
290    fn warm_status_type_is_reachable_from_this_crate() {
291        let _ = WarmStatus::AlreadyLocal;
292    }
293}