Skip to main content

soma_infra/
docker_cleanup.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use soma_fleet::{HostId, HostRecord, TopologyRevision};
6use soma_ops::{MutationSendState, OperationId, OperationName, Timestamp};
7use tokio_util::sync::CancellationToken;
8
9use crate::{
10    ContainerReader, DockerTelemetryReader, ImageIdentity, ImageReader, InfraError, InfraResult,
11    MutationResult, NetworkReader, VolumeReader,
12};
13
14/// Closed Docker prune scope.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum DockerPruneTarget {
18    /// Stopped containers.
19    Containers,
20    /// Dangling images.
21    Images,
22    /// Unused volumes.
23    Volumes,
24    /// Unused networks.
25    Networks,
26    /// Build cache.
27    BuildCache,
28    /// Every supported prune scope in a fixed order.
29    All,
30}
31
32impl DockerPruneTarget {
33    /// Parses the canonical schema value.
34    pub fn parse(value: &str) -> InfraResult<Self> {
35        match value {
36            "containers" => Ok(Self::Containers),
37            "images" => Ok(Self::Images),
38            "volumes" => Ok(Self::Volumes),
39            "networks" => Ok(Self::Networks),
40            "buildcache" => Ok(Self::BuildCache),
41            "all" => Ok(Self::All),
42            _ => Err(InfraError::InvalidRequest {
43                domain: "docker-cleanup",
44                message: format!("unsupported prune target: {value}"),
45            }),
46        }
47    }
48
49    /// Returns the canonical label.
50    #[must_use]
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Containers => "containers",
54            Self::Images => "images",
55            Self::Volumes => "volumes",
56            Self::Networks => "networks",
57            Self::BuildCache => "buildcache",
58            Self::All => "all",
59        }
60    }
61
62    #[cfg(any(feature = "bollard-driver", test))]
63    pub(crate) fn expanded(self) -> &'static [Self] {
64        match self {
65            Self::All => &[
66                Self::Containers,
67                Self::Images,
68                Self::Volumes,
69                Self::Networks,
70                Self::BuildCache,
71            ],
72            Self::Containers => &[Self::Containers],
73            Self::Images => &[Self::Images],
74            Self::Volumes => &[Self::Volumes],
75            Self::Networks => &[Self::Networks],
76            Self::BuildCache => &[Self::BuildCache],
77        }
78    }
79}
80
81/// Stable identity bound into an image-removal plan.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct ImageRemovalFingerprint {
84    /// Requested reference.
85    pub reference: String,
86    /// Resolved local image identity.
87    pub identity: ImageIdentity,
88    /// Lowercase SHA-256 over the complete identity.
89    pub sha256: String,
90}
91
92impl ImageRemovalFingerprint {
93    /// Builds a deterministic fingerprint.
94    pub fn new(reference: impl Into<String>, mut identity: ImageIdentity) -> InfraResult<Self> {
95        let reference = validate_text("image reference", reference.into(), 256)?;
96        identity.repo_tags.sort();
97        identity.repo_tags.dedup();
98        identity.repo_digests.sort();
99        identity.repo_digests.dedup();
100        let material = serde_json::to_vec(&(reference.as_str(), &identity)).map_err(|error| {
101            InfraError::Parse {
102                domain: "docker-cleanup",
103                message: error.to_string(),
104            }
105        })?;
106        let sha256 = crate::mutation::sha256_hex(&material);
107        Ok(Self {
108            reference,
109            identity,
110            sha256,
111        })
112    }
113}
114
115/// Deterministic pre-prune inventory.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct DockerPruneFingerprint {
118    /// Target host.
119    pub host: HostId,
120    /// Exact topology revision.
121    pub topology_revision: TopologyRevision,
122    /// Requested prune target.
123    pub target: DockerPruneTarget,
124    /// Candidate container IDs.
125    pub containers: Vec<String>,
126    /// Candidate image IDs.
127    pub images: Vec<String>,
128    /// Visible volume names.
129    pub volumes: Vec<String>,
130    /// Visible network IDs or names.
131    pub networks: Vec<String>,
132    /// Current build-cache bytes when reported.
133    pub build_cache_bytes: u64,
134    /// Lowercase SHA-256 over the inventory.
135    pub sha256: String,
136}
137
138impl DockerPruneFingerprint {
139    pub(crate) fn finalize(mut self) -> InfraResult<Self> {
140        for values in [
141            &mut self.containers,
142            &mut self.images,
143            &mut self.volumes,
144            &mut self.networks,
145        ] {
146            values.sort();
147            values.dedup();
148        }
149        let material = serde_json::to_vec(&(
150            &self.host,
151            &self.topology_revision,
152            self.target,
153            &self.containers,
154            &self.images,
155            &self.volumes,
156            &self.networks,
157            self.build_cache_bytes,
158        ))
159        .map_err(|error| InfraError::Parse {
160            domain: "docker-cleanup",
161            message: error.to_string(),
162        })?;
163        self.sha256 = crate::mutation::sha256_hex(&material);
164        Ok(self)
165    }
166}
167
168/// Request to remove one exact local image identity.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct ImageRemovalRequest {
171    /// Operation identity.
172    pub operation_id: OperationId,
173    /// Canonical operation.
174    pub operation: OperationName,
175    /// Planned image fingerprint.
176    pub fingerprint: ImageRemovalFingerprint,
177    /// Explicit destructive confirmation field.
178    pub force: bool,
179    /// Absolute execution deadline.
180    pub deadline: Timestamp,
181}
182
183/// Request to prune one exact inventory.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct DockerPruneRequest {
186    /// Operation identity.
187    pub operation_id: OperationId,
188    /// Canonical operation.
189    pub operation: OperationName,
190    /// Planned prune fingerprint.
191    pub fingerprint: DockerPruneFingerprint,
192    /// Explicit destructive confirmation field.
193    pub force: bool,
194    /// Absolute execution deadline.
195    pub deadline: Timestamp,
196}
197
198/// Backend receipt for image removal.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct ImageRemovalReceipt {
201    /// Backend send state.
202    pub send_state: MutationSendState,
203    /// Deleted content IDs reported by Docker.
204    pub deleted: Vec<String>,
205    /// Untagged references reported by Docker.
206    pub untagged: Vec<String>,
207}
208
209/// Backend receipt for one prune scope.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct DockerPruneScopeReceipt {
212    /// Prune scope.
213    pub target: DockerPruneTarget,
214    /// Deleted object identities.
215    pub deleted: Vec<String>,
216    /// Reclaimed bytes reported by Docker.
217    pub space_reclaimed: u64,
218}
219
220/// Complete prune receipt.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct DockerPruneReceipt {
223    /// Backend send state.
224    pub send_state: MutationSendState,
225    /// Completed scopes in execution order.
226    pub scopes: Vec<DockerPruneScopeReceipt>,
227}
228
229/// Verified image-removal result.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct ImageRemovalOutcome {
232    /// Planned image identity.
233    pub before: ImageRemovalFingerprint,
234    /// Whether the image is absent after execution.
235    pub removed: bool,
236    /// Backend receipt.
237    pub receipt: ImageRemovalReceipt,
238}
239
240/// Verified prune result.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct DockerPruneOutcome {
243    /// Planned inventory.
244    pub before: DockerPruneFingerprint,
245    /// Post-prune inventory.
246    pub after: DockerPruneFingerprint,
247    /// Backend receipt.
248    pub receipt: DockerPruneReceipt,
249    /// Whether any identity or bytes were reported removed.
250    pub changed: bool,
251}
252
253/// Product-neutral Docker cleanup mutations.
254#[async_trait]
255pub trait DockerCleanupMutator: Send + Sync {
256    /// Removes one image.
257    async fn remove_image(
258        &self,
259        host: &HostRecord,
260        request: &ImageRemovalRequest,
261        cancellation: &CancellationToken,
262    ) -> MutationResult<ImageRemovalReceipt>;
263
264    /// Prunes one target scope.
265    async fn prune(
266        &self,
267        host: &HostRecord,
268        request: &DockerPruneRequest,
269        cancellation: &CancellationToken,
270    ) -> MutationResult<DockerPruneReceipt>;
271}
272
273/// Complete Docker cleanup client used by verification engines.
274pub trait DockerCleanupClient:
275    ImageReader
276    + ContainerReader
277    + NetworkReader
278    + VolumeReader
279    + DockerTelemetryReader
280    + DockerCleanupMutator
281{
282}
283
284impl<T> DockerCleanupClient for T where
285    T: ImageReader
286        + ContainerReader
287        + NetworkReader
288        + VolumeReader
289        + DockerTelemetryReader
290        + DockerCleanupMutator
291{
292}
293
294/// Host-bound cleanup client provider.
295#[async_trait]
296pub trait DockerCleanupClientProvider: Send + Sync {
297    /// Resolves one cleanup client for the exact host revision.
298    async fn cleanup_client(
299        &self,
300        host: &HostRecord,
301        cancellation: &CancellationToken,
302    ) -> InfraResult<Arc<dyn DockerCleanupClient>>;
303}
304
305fn validate_text(field: &'static str, value: String, max: usize) -> InfraResult<String> {
306    let count = value.chars().count();
307    if count == 0 || count > max || value.chars().any(char::is_control) {
308        Err(InfraError::InvalidRequest {
309            domain: "docker-cleanup",
310            message: format!("invalid {field}"),
311        })
312    } else {
313        Ok(value)
314    }
315}
316
317#[cfg(test)]
318#[path = "docker_cleanup_tests.rs"]
319mod tests;