sui_dockerfile_wrapper/
daemon_client.rs1use 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
48const CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
54
55pub struct DaemonAwareCacheClient {
59 socket_path: Option<PathBuf>,
60 remote: Arc<dyn StorageBackend>,
61}
62
63impl DaemonAwareCacheClient {
64 #[must_use]
69 pub fn new(socket_path: Option<PathBuf>, remote: Arc<dyn StorageBackend>) -> Self {
70 Self { socket_path, remote }
71 }
72
73 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 self.remote.put_narinfo(hash, content).await?;
110
111 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#[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 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 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 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 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 #[test]
290 fn warm_status_type_is_reachable_from_this_crate() {
291 let _ = WarmStatus::AlreadyLocal;
292 }
293}