Skip to main content

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}