Skip to main content

soma_infra/
compose.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use soma_fleet::{HostId, HostRecord, TopologyRevision};
7use soma_ops::Timestamp;
8use tokio_util::sync::CancellationToken;
9
10use crate::InfraResult;
11
12/// Validated reference to one Compose project configuration.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ComposeProjectRef {
15    name: String,
16    config_file: PathBuf,
17}
18
19impl ComposeProjectRef {
20    /// Creates a project reference with an absolute normalized config path.
21    pub fn new(name: impl Into<String>, config_file: impl Into<PathBuf>) -> InfraResult<Self> {
22        let name = name.into();
23        crate::compose_parse::validate_project_name(&name)?;
24        let config_file = crate::compose_parse::validate_absolute_path(config_file.into())?;
25        Ok(Self { name, config_file })
26    }
27
28    /// Returns the project name.
29    #[must_use]
30    pub fn name(&self) -> &str {
31        &self.name
32    }
33
34    /// Returns the Compose config path.
35    #[must_use]
36    pub fn config_file(&self) -> &Path {
37        &self.config_file
38    }
39}
40
41/// Project row returned by `docker compose ls`.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ComposeProject {
44    /// Target host.
45    pub host: HostId,
46    /// Exact topology revision.
47    pub topology_revision: TopologyRevision,
48    /// Project name.
49    pub name: String,
50    /// Engine-reported status text.
51    pub status: Option<String>,
52    /// Referenced config files.
53    pub config_files: Vec<PathBuf>,
54}
55
56/// Service row returned by `docker compose ps`.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ComposeServiceStatus {
59    /// Compose service name.
60    pub service: String,
61    /// Container name, when reported.
62    pub container_name: Option<String>,
63    /// Runtime state.
64    pub state: Option<String>,
65    /// Health state.
66    pub health: Option<String>,
67    /// Container exit code.
68    pub exit_code: Option<i64>,
69    /// Image reference.
70    pub image: Option<String>,
71}
72
73/// Typed status for one Compose project.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ComposeStatus {
76    /// Target host.
77    pub host: HostId,
78    /// Exact topology revision.
79    pub topology_revision: TopologyRevision,
80    /// Project name.
81    pub project: String,
82    /// Service status rows.
83    pub services: Vec<ComposeServiceStatus>,
84}
85
86/// Selected read-only service configuration.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct ComposeServiceConfig {
89    /// Image reference, when configured.
90    pub image: Option<String>,
91    /// Build context, when represented as a string or object context.
92    pub build_context: Option<String>,
93    /// Enabled profiles.
94    pub profiles: Vec<String>,
95}
96
97/// Typed read-only Compose configuration summary.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct ComposeConfig {
100    /// Target host.
101    pub host: HostId,
102    /// Exact topology revision.
103    pub topology_revision: TopologyRevision,
104    /// Project name.
105    pub project: String,
106    /// Service configurations keyed by service name.
107    pub services: BTreeMap<String, ComposeServiceConfig>,
108    /// Declared network names.
109    pub networks: Vec<String>,
110    /// Declared volume names.
111    pub volumes: Vec<String>,
112}
113
114/// Bounded Compose log request.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct ComposeLogRequest {
117    lines: u32,
118    since: Option<String>,
119    service: Option<String>,
120    deadline: Timestamp,
121}
122
123impl ComposeLogRequest {
124    /// Creates a request for the last 100 lines.
125    #[must_use]
126    pub const fn new(deadline: Timestamp) -> Self {
127        Self {
128            lines: 100,
129            since: None,
130            service: None,
131            deadline,
132        }
133    }
134
135    /// Sets the maximum requested line count.
136    pub fn with_lines(mut self, lines: u32) -> InfraResult<Self> {
137        if lines == 0 || lines > 5000 {
138            return Err(crate::InfraError::InvalidRequest {
139                domain: "compose",
140                message: "log line count must be 1-5000".into(),
141            });
142        }
143        self.lines = lines;
144        Ok(self)
145    }
146
147    /// Sets a Compose-compatible since expression.
148    pub fn with_since(mut self, since: impl Into<String>) -> InfraResult<Self> {
149        let since = since.into();
150        let option_like = since.starts_with("--")
151            || (since.starts_with('-')
152                && !since[1..]
153                    .chars()
154                    .next()
155                    .is_some_and(|character| character.is_ascii_digit()));
156        if since.is_empty()
157            || option_like
158            || since.chars().count() > 128
159            || since.chars().any(char::is_control)
160        {
161            return Err(crate::InfraError::InvalidRequest {
162                domain: "compose",
163                message: "invalid Compose log since expression".into(),
164            });
165        }
166        self.since = Some(since);
167        Ok(self)
168    }
169
170    /// Restricts logs to one validated service.
171    pub fn with_service(mut self, service: impl Into<String>) -> InfraResult<Self> {
172        let service = service.into();
173        validate_log_service(&service)?;
174        self.service = Some(service);
175        Ok(self)
176    }
177
178    /// Returns the line count.
179    #[must_use]
180    pub const fn lines(&self) -> u32 {
181        self.lines
182    }
183
184    /// Returns the optional since expression.
185    #[must_use]
186    pub fn since(&self) -> Option<&str> {
187        self.since.as_deref()
188    }
189
190    /// Returns the optional service.
191    #[must_use]
192    pub fn service(&self) -> Option<&str> {
193        self.service.as_deref()
194    }
195
196    /// Returns the absolute deadline.
197    #[must_use]
198    pub const fn deadline(&self) -> Timestamp {
199        self.deadline
200    }
201}
202
203/// Bounded Compose log result.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct ComposeLogs {
206    /// Target host.
207    pub host: HostId,
208    /// Exact topology revision.
209    pub topology_revision: TopologyRevision,
210    /// Project name.
211    pub project: String,
212    /// Returned log lines.
213    pub lines: Vec<String>,
214    /// Whether the byte ceiling truncated output.
215    pub truncated: bool,
216}
217
218/// Product-neutral Compose inspection engine.
219#[async_trait]
220pub trait ComposeInspector: Send + Sync {
221    /// Lists Compose projects visible on one host.
222    async fn list_projects(
223        &self,
224        host: &HostRecord,
225        deadline: Timestamp,
226        cancellation: &CancellationToken,
227    ) -> InfraResult<Vec<ComposeProject>>;
228
229    /// Returns status for one project, optionally restricted to a service.
230    async fn status(
231        &self,
232        host: &HostRecord,
233        project: &ComposeProjectRef,
234        service: Option<&str>,
235        deadline: Timestamp,
236        cancellation: &CancellationToken,
237    ) -> InfraResult<ComposeStatus>;
238
239    /// Returns selected normalized project configuration.
240    async fn config(
241        &self,
242        host: &HostRecord,
243        project: &ComposeProjectRef,
244        deadline: Timestamp,
245        cancellation: &CancellationToken,
246    ) -> InfraResult<ComposeConfig>;
247
248    /// Reads bounded project logs.
249    async fn logs(
250        &self,
251        host: &HostRecord,
252        project: &ComposeProjectRef,
253        request: &ComposeLogRequest,
254        cancellation: &CancellationToken,
255    ) -> InfraResult<ComposeLogs>;
256}
257
258fn validate_log_service(value: &str) -> InfraResult<()> {
259    let mut chars = value.chars();
260    if value.is_empty()
261        || value.len() > 256
262        || !chars
263            .next()
264            .is_some_and(|character| character.is_ascii_alphanumeric())
265        || !chars.all(|character| {
266            character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.')
267        })
268    {
269        Err(crate::InfraError::InvalidRequest {
270            domain: "compose",
271            message: format!("invalid service name: {value:?}"),
272        })
273    } else {
274        Ok(())
275    }
276}