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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ComposeProjectRef {
15 name: String,
16 config_file: PathBuf,
17}
18
19impl ComposeProjectRef {
20 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 #[must_use]
30 pub fn name(&self) -> &str {
31 &self.name
32 }
33
34 #[must_use]
36 pub fn config_file(&self) -> &Path {
37 &self.config_file
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ComposeProject {
44 pub host: HostId,
46 pub topology_revision: TopologyRevision,
48 pub name: String,
50 pub status: Option<String>,
52 pub config_files: Vec<PathBuf>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ComposeServiceStatus {
59 pub service: String,
61 pub container_name: Option<String>,
63 pub state: Option<String>,
65 pub health: Option<String>,
67 pub exit_code: Option<i64>,
69 pub image: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ComposeStatus {
76 pub host: HostId,
78 pub topology_revision: TopologyRevision,
80 pub project: String,
82 pub services: Vec<ComposeServiceStatus>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct ComposeServiceConfig {
89 pub image: Option<String>,
91 pub build_context: Option<String>,
93 pub profiles: Vec<String>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct ComposeConfig {
100 pub host: HostId,
102 pub topology_revision: TopologyRevision,
104 pub project: String,
106 pub services: BTreeMap<String, ComposeServiceConfig>,
108 pub networks: Vec<String>,
110 pub volumes: Vec<String>,
112}
113
114#[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 #[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 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 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 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 #[must_use]
180 pub const fn lines(&self) -> u32 {
181 self.lines
182 }
183
184 #[must_use]
186 pub fn since(&self) -> Option<&str> {
187 self.since.as_deref()
188 }
189
190 #[must_use]
192 pub fn service(&self) -> Option<&str> {
193 self.service.as_deref()
194 }
195
196 #[must_use]
198 pub const fn deadline(&self) -> Timestamp {
199 self.deadline
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct ComposeLogs {
206 pub host: HostId,
208 pub topology_revision: TopologyRevision,
210 pub project: String,
212 pub lines: Vec<String>,
214 pub truncated: bool,
216}
217
218#[async_trait]
220pub trait ComposeInspector: Send + Sync {
221 async fn list_projects(
223 &self,
224 host: &HostRecord,
225 deadline: Timestamp,
226 cancellation: &CancellationToken,
227 ) -> InfraResult<Vec<ComposeProject>>;
228
229 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 async fn config(
241 &self,
242 host: &HostRecord,
243 project: &ComposeProjectRef,
244 deadline: Timestamp,
245 cancellation: &CancellationToken,
246 ) -> InfraResult<ComposeConfig>;
247
248 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}