sui_dockerfile_node_cache_daemon/protocol.rs
1//! Typed request/response protocol for the node-local cache daemon.
2//!
3//! Deliberately **not** REST/`format!()`-built URLs: every message is a
4//! `serde`-serializable Rust enum. The wire framing is a small
5//! length-prefixed envelope (`[len: u32 LE][body: JSON bytes]`) over
6//! whatever `AsyncRead`/`AsyncWrite` transport the caller supplies (a
7//! `UnixStream` in production, an in-memory duplex pipe in tests) — the
8//! same "transport-agnostic frame, JSON/CBOR body" shape `sui-protocol`
9//! already established for the graph daemon, kept independent here so
10//! this crate has zero dependency on that daemon's graph-specific wire
11//! types.
12//!
13//! JSON (not bincode) was chosen for the body encoding: this crate adds
14//! no new workspace dependency (`serde_json` is already pulled in by
15//! every consumer of this protocol), the messages are small and
16//! infrequent (one per cache lookup, not a hot inner loop), and JSON's
17//! self-describing shape makes `sui-dockerfile-node-cache-daemon
18//! --version`-style debugging trivial with `nc`/`socat` during
19//! incident response.
20
21use serde::{de::DeserializeOwned, Deserialize, Serialize};
22use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
23
24use crate::DaemonError;
25
26/// Maximum body length a single frame may declare. 8 MiB is generously
27/// larger than any `CachedArtifact` this daemon stores (an image
28/// reference string), catching a desynchronized or hostile peer before
29/// it forces a runaway allocation.
30pub const MAX_MESSAGE_BYTES: u32 = 8 * 1024 * 1024;
31
32/// The cache payload: today, an image reference string — the same
33/// shape [`sui_cache::storage::StorageBackend::get_narinfo`] already
34/// returns for a Dockerfile-graph node hit. Kept as its own typed
35/// struct (rather than a bare `String`) so a future phase can widen it
36/// (e.g. add a `size_bytes` or `built_at`) without an incompatible wire
37/// break at the enum level.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct CachedArtifact {
40 pub image_ref: String,
41}
42
43/// Outcome of a `Warm` request — never blocks the caller on the
44/// network fetch; reports only what could be determined synchronously.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum WarmStatus {
47 /// The content hash was already present in the local L0 store —
48 /// nothing to do.
49 AlreadyLocal,
50 /// Not present locally; a background fetch-and-persist from the
51 /// remote tier has been scheduled (fire-and-forget).
52 FetchScheduled,
53}
54
55/// A request from a client (the daemon-aware wrapper client, or any
56/// other same-node caller) to the node cache daemon.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub enum DaemonRequest {
59 /// Look up a content hash. Checks the local L0 store first; on a
60 /// local miss, the daemon reaches into the remote tier itself and
61 /// persists the result locally before responding — the caller
62 /// never needs to know which tier actually served it.
63 Get { content_hash: String },
64 /// Persist an artifact under a content hash in the local L0 store
65 /// only (fast local write-back). Callers that also need the
66 /// shared remote tier updated (today: `sui-dockerfile-wrapper`'s
67 /// cache back-fill on a miss) write to the remote tier themselves
68 /// — this daemon does not implicitly forward `Put`s upstream.
69 Put { content_hash: String, artifact: CachedArtifact },
70 /// Ask the daemon to proactively ensure a content hash is present
71 /// locally, without blocking on the fetch. Fire-and-forget;
72 /// callers that need to know the artifact is ready should follow
73 /// up with a `Get`.
74 Warm { content_hash: String },
75}
76
77/// A response from the node cache daemon.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub enum DaemonResponse {
80 Get { artifact: Option<CachedArtifact> },
81 Put { ok: bool },
82 Warm { status: WarmStatus },
83 /// The daemon itself failed to service the request (e.g. the
84 /// remote tier errored on a local-miss fetch). Distinct from a
85 /// clean `Get { artifact: None }`, which just means "not cached
86 /// anywhere" — this means "couldn't even determine that".
87 Error { message: String },
88}
89
90/// Write one length-prefixed JSON message to `w`.
91///
92/// # Errors
93///
94/// Returns [`DaemonError::Io`] on a transport failure or
95/// [`DaemonError::Protocol`] if the message serializes to a body
96/// larger than [`MAX_MESSAGE_BYTES`].
97pub async fn write_message<W, T>(w: &mut W, message: &T) -> Result<(), DaemonError>
98where
99 W: AsyncWrite + Unpin,
100 T: Serialize,
101{
102 let body = serde_json::to_vec(message).map_err(|e| DaemonError::Protocol(e.to_string()))?;
103 let len: u32 = body
104 .len()
105 .try_into()
106 .map_err(|_| DaemonError::Protocol("message body too large to frame".to_string()))?;
107 if len > MAX_MESSAGE_BYTES {
108 return Err(DaemonError::Protocol(format!(
109 "message body {len} bytes exceeds cap {MAX_MESSAGE_BYTES}"
110 )));
111 }
112 w.write_all(&len.to_le_bytes()).await?;
113 w.write_all(&body).await?;
114 w.flush().await?;
115 Ok(())
116}
117
118/// Read one length-prefixed JSON message from `r`.
119///
120/// # Errors
121///
122/// Returns [`DaemonError::Io`] on a transport failure (including a
123/// clean EOF before any bytes arrive) or [`DaemonError::Protocol`] if
124/// the declared length exceeds [`MAX_MESSAGE_BYTES`] or the body fails
125/// to deserialize.
126pub async fn read_message<R, T>(r: &mut R) -> Result<T, DaemonError>
127where
128 R: AsyncRead + Unpin,
129 T: DeserializeOwned,
130{
131 let mut len_buf = [0u8; 4];
132 r.read_exact(&mut len_buf).await?;
133 let len = u32::from_le_bytes(len_buf);
134 if len > MAX_MESSAGE_BYTES {
135 return Err(DaemonError::Protocol(format!(
136 "declared message length {len} exceeds cap {MAX_MESSAGE_BYTES}"
137 )));
138 }
139 let mut body = vec![0u8; len as usize];
140 r.read_exact(&mut body).await?;
141 serde_json::from_slice(&body).map_err(|e| DaemonError::Protocol(e.to_string()))
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use tokio::io::duplex;
148
149 #[tokio::test]
150 async fn request_round_trips_over_a_duplex_pipe() {
151 let (mut a, mut b) = duplex(4096);
152 let req = DaemonRequest::Get { content_hash: "abc123".to_string() };
153 write_message(&mut a, &req).await.unwrap();
154 let got: DaemonRequest = read_message(&mut b).await.unwrap();
155 assert_eq!(got, req);
156 }
157
158 #[tokio::test]
159 async fn response_round_trips_over_a_duplex_pipe() {
160 let (mut a, mut b) = duplex(4096);
161 let resp = DaemonResponse::Get {
162 artifact: Some(CachedArtifact { image_ref: "example/image:cached".to_string() }),
163 };
164 write_message(&mut a, &resp).await.unwrap();
165 let got: DaemonResponse = read_message(&mut b).await.unwrap();
166 assert_eq!(got, resp);
167 }
168
169 #[test]
170 fn cached_artifact_json_roundtrip() {
171 let artifact = CachedArtifact { image_ref: "example/image:v1".to_string() };
172 let json = serde_json::to_string(&artifact).unwrap();
173 let parsed: CachedArtifact = serde_json::from_str(&json).unwrap();
174 assert_eq!(parsed, artifact);
175 }
176}