saluki_env/workload/helpers/containerd/
mod.rs1use std::{
2 path::{Path, PathBuf},
3 time::Duration,
4};
5
6use containerd_protos::services::{
7 containers::v1::{containers_client::ContainersClient, Container, ListContainersRequest},
8 events::v1::{events_client::EventsClient, SubscribeRequest},
9 namespaces::v1::{namespaces_client::NamespacesClient, ListNamespacesRequest, Namespace},
10 tasks::v1::{tasks_client::TasksClient, ListPidsRequest},
11};
12use futures::{Stream, StreamExt as _, TryStreamExt as _};
13use hyper_util::rt::TokioIo;
14use saluki_error::{generic_error, GenericError};
15use snafu::{ResultExt as _, Snafu};
16use tokio::net::UnixStream;
17use tonic::{
18 transport::{Channel, Endpoint},
19 IntoRequest, Request,
20};
21use tower::service_fn;
22
23use crate::features::ContainerdDetector;
24
25pub mod events;
26use self::events::{decode_envelope_to_event, ContainerdEvent, ContainerdTopic};
27
28const MAX_LIST_CONTAINERS_RESPONSE_SIZE: usize = 16 * 1024 * 1024;
29
30pub struct ContainerdConfiguration {
32 pub connection_timeout: Duration,
34
35 pub query_timeout: Duration,
37}
38
39#[derive(Debug, Snafu)]
41#[snafu(context(suffix(false)))]
42pub enum ClientError {
43 #[snafu(display("failed to make gRPC request: {}", source))]
45 Response { source: tonic::Status },
46
47 #[snafu(display("invalid containerd event response: {}", reason))]
49 InvalidEvent { reason: String },
50}
51
52impl ClientError {
53 pub fn as_response_error(&self) -> Option<tonic::Status> {
55 match self {
56 ClientError::Response { source } => Some(source.clone()),
57 _ => None,
58 }
59 }
60}
61
62#[derive(Clone)]
64pub struct ContainerdClient {
65 channel: Channel,
66 query_timeout: Duration,
67}
68
69impl ContainerdClient {
70 pub async fn new(
79 socket_path: Option<PathBuf>, containerd_config: &ContainerdConfiguration,
80 ) -> Result<Self, GenericError> {
81 let detected_socket_path = ContainerdDetector::detect_grpc_socket_path(socket_path)
82 .ok_or(generic_error!(
83 "failed to detect containerd socket path; not available at default path and not specified in configuration (`cri_socket_path`)"
84 ))?;
85
86 if !path_exists(&detected_socket_path).await {
87 return Err(generic_error!(
88 "Detected containerd socket path ({}) but path does not exist, or process lacks permissions.",
89 detected_socket_path.to_string_lossy()
90 ));
91 }
92
93 let channel = Endpoint::try_from("https://[::]")
94 .unwrap()
95 .connect_timeout(containerd_config.connection_timeout)
96 .connect_with_connector(service_fn(move |_| {
97 let socket_path = detected_socket_path.clone();
98 async move { UnixStream::connect(socket_path).await.map(TokioIo::new) }
99 }))
100 .await?;
101
102 Ok(Self {
103 channel,
104 query_timeout: containerd_config.query_timeout,
105 })
106 }
107
108 pub async fn list_namespaces(&self) -> Result<Vec<Namespace>, ClientError> {
114 let request = create_timed_request(ListNamespacesRequest::default(), self.query_timeout);
115
116 let mut client = NamespacesClient::new(self.channel.clone());
117 let namespaces = client.list(request).await.context(Response)?.into_inner();
118
119 Ok(namespaces.namespaces)
120 }
121
122 pub async fn list_containers(&self, namespace: &Namespace) -> Result<Vec<Container>, ClientError> {
128 let request = ListContainersRequest::default();
129 let request = create_timed_namespaced_request(request, namespace, self.query_timeout);
130
131 let client = ContainersClient::new(self.channel.clone());
132 let response = client
133 .max_decoding_message_size(MAX_LIST_CONTAINERS_RESPONSE_SIZE)
134 .list(request)
135 .await
136 .context(Response)?
137 .into_inner();
138
139 Ok(response.containers)
140 }
141
142 pub async fn watch_events(
150 &self, topics: &[ContainerdTopic], namespace: &Namespace,
151 ) -> Result<impl Stream<Item = Result<ContainerdEvent, ClientError>> + Unpin, ClientError> {
152 let mut filters = Vec::new();
155 for topic in topics {
156 filters.push(format!(
157 "topic==\"{}\",namespace=={}",
158 topic.as_topic_str(),
159 namespace.name
160 ));
161 }
162
163 let request = SubscribeRequest { filters };
166
167 let mut client = EventsClient::new(self.channel.clone());
168 let response = client.subscribe(request).await.context(Response)?.into_inner();
169
170 Ok(response
171 .map_err(|source| ClientError::Response { source })
172 .filter_map(|result| async move {
173 result
178 .and_then(|envelope| {
179 decode_envelope_to_event(envelope).map_err(|_| ClientError::InvalidEvent {
180 reason: "failed to decode envelope payload".to_string(),
181 })
182 })
183 .transpose()
184 })
185 .boxed())
186 }
187
188 pub async fn list_pids_for_container(
194 &self, namespace: &Namespace, container_id: String,
195 ) -> Result<Vec<u32>, ClientError> {
196 let request = ListPidsRequest { container_id };
197 let request = create_timed_namespaced_request(request, namespace, self.query_timeout);
198
199 let mut client = TasksClient::new(self.channel.clone());
200 let response = client.list_pids(request).await.context(Response)?.into_inner();
201
202 Ok(response.processes.into_iter().map(|p| p.pid).collect())
203 }
204}
205
206fn create_timed_request<R>(req: R, timeout: Duration) -> Request<R>
207where
208 R: IntoRequest<R>,
209{
210 let mut req = req.into_request();
211 req.set_timeout(timeout);
212 req
213}
214
215fn create_timed_namespaced_request<R>(req: R, ns: &Namespace, timeout: Duration) -> Request<R>
216where
217 R: IntoRequest<R>,
218{
219 let mut req = create_timed_request(req, timeout);
220 let md = req.metadata_mut();
221 md.insert("containerd-namespace", ns.name.parse().unwrap());
222 req
223}
224
225async fn path_exists(path: &Path) -> bool {
226 tokio::fs::metadata(path).await.is_ok()
227}
228
229#[cfg(test)]
230mod tests {
231 use std::time::Duration;
232
233 use super::*;
234
235 #[test]
236 fn create_timed_request_sets_grpc_timeout() {
237 let request = create_timed_request(ListNamespacesRequest::default(), Duration::from_secs(3));
238
239 assert_eq!(
240 Some("3000000u"),
241 request.metadata().get("grpc-timeout").map(|v| v.to_str().unwrap())
242 );
243 }
244
245 #[test]
246 fn create_timed_namespaced_request_sets_namespace_and_grpc_timeout() {
247 let namespace = Namespace {
248 name: "k8s.io".to_string(),
249 labels: Default::default(),
250 };
251 let request =
252 create_timed_namespaced_request(ListContainersRequest::default(), &namespace, Duration::from_secs(4));
253
254 assert_eq!(
255 Some("4000000u"),
256 request.metadata().get("grpc-timeout").map(|v| v.to_str().unwrap())
257 );
258 assert_eq!(
259 Some("k8s.io"),
260 request
261 .metadata()
262 .get("containerd-namespace")
263 .map(|v| v.to_str().unwrap())
264 );
265 }
266}