crates/flotilla-protocol/src/environment.rs
Line | Count | Source |
1 | | use std::{fmt, path::PathBuf}; |
2 | | |
3 | | use serde::{Deserialize, Serialize}; |
4 | | |
5 | | use crate::qualified_path::HostId; |
6 | | |
7 | | /// Filesystem-safe identifier for a sandbox environment. |
8 | | #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] |
9 | | #[serde(transparent)] |
10 | | pub struct EnvironmentId(String); |
11 | | |
12 | | impl EnvironmentId { |
13 | 361 | pub fn new(id: impl Into<String>) -> Self { |
14 | 361 | Self(id.into()) |
15 | 361 | } |
16 | | |
17 | 14 | pub fn as_str(&self) -> &str { |
18 | 14 | &self.0 |
19 | 14 | } |
20 | | } |
21 | | |
22 | | impl fmt::Display for EnvironmentId { |
23 | 38 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
24 | 38 | f.write_str(&self.0) |
25 | 38 | } |
26 | | } |
27 | | |
28 | | /// Specification for how to provision a sandbox environment. |
29 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
30 | | pub struct EnvironmentSpec { |
31 | | pub image: ImageSource, |
32 | | pub token_env_vars: Vec<String>, |
33 | | } |
34 | | |
35 | | /// Source from which to obtain a container image. |
36 | | /// |
37 | | /// In YAML config, written as a map with one key: |
38 | | /// ```yaml |
39 | | /// image: |
40 | | /// dockerfile: .flotilla/Dockerfile.dev-env |
41 | | /// ``` |
42 | | /// or: |
43 | | /// ```yaml |
44 | | /// image: |
45 | | /// registry: ubuntu:24.04 |
46 | | /// ``` |
47 | | /// Source from which to obtain a container image. |
48 | | /// |
49 | | /// In YAML config, written as a map with one key: |
50 | | /// ```yaml |
51 | | /// image: |
52 | | /// dockerfile: .flotilla/Dockerfile.dev-env |
53 | | /// ``` |
54 | | /// or: |
55 | | /// ```yaml |
56 | | /// image: |
57 | | /// registry: ubuntu:24.04 |
58 | | /// ``` |
59 | | /// |
60 | | /// Custom serde impls because serde_yml uses YAML tags (`!dockerfile path`) for |
61 | | /// externally-tagged enums, which is unfriendly for hand-written config files. |
62 | | /// These impls produce plain map keys (`dockerfile: path`) instead. |
63 | | #[derive(Debug, Clone, PartialEq, Eq)] |
64 | | pub enum ImageSource { |
65 | | Dockerfile(PathBuf), |
66 | | Registry(String), |
67 | | } |
68 | | |
69 | | impl Serialize for ImageSource { |
70 | 2 | fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { |
71 | | use serde::ser::SerializeMap; |
72 | 2 | let mut map = serializer.serialize_map(Some(1))?0 ; |
73 | 2 | match self { |
74 | 0 | ImageSource::Dockerfile(path) => map.serialize_entry("dockerfile", path)?, |
75 | 2 | ImageSource::Registry(image) => map.serialize_entry("registry", image)?0 , |
76 | | } |
77 | 2 | map.end() |
78 | 2 | } |
79 | | } |
80 | | |
81 | | impl<'de> Deserialize<'de> for ImageSource { |
82 | 4 | fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { |
83 | | use std::collections::HashMap; |
84 | 4 | let map: HashMap<String, String> = HashMap::deserialize(deserializer)?0 ; |
85 | 4 | if let Some(path2 ) = map.get("dockerfile") { |
86 | 2 | Ok(ImageSource::Dockerfile(PathBuf::from(path))) |
87 | 2 | } else if let Some(image) = map.get("registry") { |
88 | 2 | Ok(ImageSource::Registry(image.clone())) |
89 | | } else { |
90 | 0 | Err(serde::de::Error::custom("expected 'dockerfile' or 'registry' key in image")) |
91 | | } |
92 | 4 | } |
93 | | } |
94 | | |
95 | | /// Identifier for a built container image. |
96 | | #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] |
97 | | #[serde(transparent)] |
98 | | pub struct ImageId(String); |
99 | | |
100 | | impl ImageId { |
101 | 40 | pub fn new(id: impl Into<String>) -> Self { |
102 | 40 | Self(id.into()) |
103 | 40 | } |
104 | | |
105 | 7 | pub fn as_str(&self) -> &str { |
106 | 7 | &self.0 |
107 | 7 | } |
108 | | } |
109 | | |
110 | | impl fmt::Display for ImageId { |
111 | 0 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
112 | 0 | f.write_str(&self.0) |
113 | 0 | } |
114 | | } |
115 | | |
116 | | /// Lifecycle status of a sandbox environment. |
117 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
118 | | pub enum EnvironmentStatus { |
119 | | Building, |
120 | | Starting, |
121 | | Running, |
122 | | Stopped, |
123 | | Failed(String), |
124 | | } |
125 | | |
126 | | /// Kind of managed environment visible in protocol summaries. |
127 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
128 | | #[serde(rename_all = "snake_case")] |
129 | | pub enum EnvironmentKind { |
130 | | Direct, |
131 | | Provisioned, |
132 | | } |
133 | | |
134 | | /// Runtime information about a visible managed environment. |
135 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] |
136 | | #[serde(tag = "kind", rename_all = "snake_case")] |
137 | | pub enum EnvironmentInfo { |
138 | | Direct { |
139 | | id: EnvironmentId, |
140 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
141 | | host_id: Option<HostId>, |
142 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
143 | | display_name: Option<String>, |
144 | | status: EnvironmentStatus, |
145 | | }, |
146 | | Provisioned { |
147 | | id: EnvironmentId, |
148 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
149 | | display_name: Option<String>, |
150 | | image: ImageId, |
151 | | status: EnvironmentStatus, |
152 | | }, |
153 | | } |
154 | | |
155 | | impl EnvironmentInfo { |
156 | 0 | pub fn kind(&self) -> EnvironmentKind { |
157 | 0 | match self { |
158 | 0 | Self::Direct { .. } => EnvironmentKind::Direct, |
159 | 0 | Self::Provisioned { .. } => EnvironmentKind::Provisioned, |
160 | | } |
161 | 0 | } |
162 | | } |
163 | | |
164 | | #[derive(Debug, Deserialize)] |
165 | | #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] |
166 | | enum EnvironmentInfoTagged { |
167 | | Direct { |
168 | | id: EnvironmentId, |
169 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
170 | | host_id: Option<HostId>, |
171 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
172 | | display_name: Option<String>, |
173 | | status: EnvironmentStatus, |
174 | | }, |
175 | | Provisioned { |
176 | | id: EnvironmentId, |
177 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
178 | | display_name: Option<String>, |
179 | | image: ImageId, |
180 | | status: EnvironmentStatus, |
181 | | }, |
182 | | } |
183 | | |
184 | | #[derive(Debug, Deserialize)] |
185 | | #[serde(deny_unknown_fields)] |
186 | | struct LegacyProvisionedEnvironmentInfo { |
187 | | id: EnvironmentId, |
188 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
189 | | display_name: Option<String>, |
190 | | image: ImageId, |
191 | | status: EnvironmentStatus, |
192 | | } |
193 | | |
194 | | #[derive(Debug, Deserialize)] |
195 | | #[serde(untagged)] |
196 | | enum EnvironmentInfoRepr { |
197 | | Tagged(EnvironmentInfoTagged), |
198 | | LegacyProvisioned(LegacyProvisionedEnvironmentInfo), |
199 | | } |
200 | | |
201 | | impl From<EnvironmentInfoTagged> for EnvironmentInfo { |
202 | 11 | fn from(value: EnvironmentInfoTagged) -> Self { |
203 | 11 | match value { |
204 | 7 | EnvironmentInfoTagged::Direct { id, host_id, display_name, status } => Self::Direct { id, host_id, display_name, status }, |
205 | 4 | EnvironmentInfoTagged::Provisioned { id, display_name, image, status } => Self::Provisioned { id, display_name, image, status }, |
206 | | } |
207 | 11 | } |
208 | | } |
209 | | |
210 | | impl From<LegacyProvisionedEnvironmentInfo> for EnvironmentInfo { |
211 | 1 | fn from(value: LegacyProvisionedEnvironmentInfo) -> Self { |
212 | 1 | Self::Provisioned { id: value.id, display_name: value.display_name, image: value.image, status: value.status } |
213 | 1 | } |
214 | | } |
215 | | |
216 | | impl<'de> Deserialize<'de> for EnvironmentInfo { |
217 | 14 | fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { |
218 | 14 | match EnvironmentInfoRepr::deserialize(deserializer)?2 { |
219 | 11 | EnvironmentInfoRepr::Tagged(value) => Ok(value.into()), |
220 | 1 | EnvironmentInfoRepr::LegacyProvisioned(value) => Ok(value.into()), |
221 | | } |
222 | 14 | } |
223 | | } |
224 | | |
225 | | #[cfg(test)] |
226 | | mod tests { |
227 | | use super::*; |
228 | | use crate::test_helpers::assert_roundtrip; |
229 | | |
230 | | #[test] |
231 | 1 | fn parse_environment_yaml_dockerfile() { |
232 | 1 | let yaml = r#" |
233 | 1 | image: |
234 | 1 | dockerfile: .flotilla/Dockerfile.dev-env |
235 | 1 | token_env_vars: |
236 | 1 | - GITHUB_TOKEN |
237 | 1 | "#; |
238 | 1 | let spec: EnvironmentSpec = serde_yml::from_str(yaml).expect("should parse dockerfile variant"); |
239 | 1 | assert_eq!(spec.image, ImageSource::Dockerfile(PathBuf::from(".flotilla/Dockerfile.dev-env"))); |
240 | 1 | assert_eq!(spec.token_env_vars, vec!["GITHUB_TOKEN"]); |
241 | 1 | } |
242 | | |
243 | | #[test] |
244 | 1 | fn parse_environment_yaml_registry() { |
245 | 1 | let yaml = r#" |
246 | 1 | image: |
247 | 1 | registry: ubuntu:24.04 |
248 | 1 | token_env_vars: [] |
249 | 1 | "#; |
250 | 1 | let spec: EnvironmentSpec = serde_yml::from_str(yaml).expect("should parse registry variant"); |
251 | 1 | assert_eq!(spec.image, ImageSource::Registry("ubuntu:24.04".into())); |
252 | 1 | assert!(spec.token_env_vars.is_empty()); |
253 | 1 | } |
254 | | |
255 | | #[test] |
256 | 1 | fn parse_environment_yaml_no_tokens() { |
257 | 1 | let yaml = r#" |
258 | 1 | image: |
259 | 1 | dockerfile: Dockerfile |
260 | 1 | token_env_vars: [] |
261 | 1 | "#; |
262 | 1 | let spec: EnvironmentSpec = serde_yml::from_str(yaml).expect("should parse with empty tokens"); |
263 | 1 | assert_eq!(spec.image, ImageSource::Dockerfile(PathBuf::from("Dockerfile"))); |
264 | 1 | } |
265 | | |
266 | | #[test] |
267 | 1 | fn environment_info_roundtrips_direct_environment_without_image() { |
268 | 1 | let info = EnvironmentInfo::Direct { |
269 | 1 | id: EnvironmentId::new("env-direct"), |
270 | 1 | display_name: Some("ssh-dev".into()), |
271 | 1 | host_id: None, |
272 | 1 | status: EnvironmentStatus::Running, |
273 | 1 | }; |
274 | | |
275 | 1 | assert_roundtrip(&info); |
276 | 1 | } |
277 | | |
278 | | #[test] |
279 | 1 | fn environment_info_defaults_optional_display_metadata_and_image_for_direct_environments() { |
280 | 1 | let info: EnvironmentInfo = serde_json::from_str(r#"{"kind":"direct","id":"env-direct","status":"Running"}"#) |
281 | 1 | .expect("should deserialize direct environment without image"); |
282 | | |
283 | 1 | assert_eq!(info, EnvironmentInfo::Direct { |
284 | 1 | id: EnvironmentId::new("env-direct"), |
285 | 1 | display_name: None, |
286 | 1 | host_id: None, |
287 | 1 | status: EnvironmentStatus::Running, |
288 | 1 | }); |
289 | 1 | } |
290 | | |
291 | | #[test] |
292 | 1 | fn environment_info_roundtrips_provisioned_environment_with_image() { |
293 | 1 | let info = EnvironmentInfo::Provisioned { |
294 | 1 | id: EnvironmentId::new("env-provisioned"), |
295 | 1 | display_name: None, |
296 | 1 | image: ImageId::new("ubuntu:24.04"), |
297 | 1 | status: EnvironmentStatus::Stopped, |
298 | 1 | }; |
299 | | |
300 | 1 | assert_roundtrip(&info); |
301 | 1 | } |
302 | | |
303 | | #[test] |
304 | 1 | fn environment_info_requires_image_for_provisioned_environments() { |
305 | 1 | serde_json::from_str::<EnvironmentInfo>(r#"{"kind":"provisioned","id":"env-provisioned","status":"Stopped"}"#) |
306 | 1 | .expect_err("provisioned environments should require an image"); |
307 | 1 | } |
308 | | |
309 | | #[test] |
310 | 1 | fn environment_info_rejects_images_for_direct_environments() { |
311 | 1 | serde_json::from_str::<EnvironmentInfo>(r#"{"kind":"direct","id":"env-direct","image":"ubuntu:24.04","status":"Running"}"#) |
312 | 1 | .expect_err("direct environments should not accept an image"); |
313 | 1 | } |
314 | | |
315 | | #[test] |
316 | 1 | fn environment_info_deserializes_legacy_provisioned_shape_without_kind() { |
317 | 1 | let info: EnvironmentInfo = serde_json::from_str(r#"{"id":"env-provisioned","image":"ubuntu:24.04","status":"Stopped"}"#) |
318 | 1 | .expect("legacy provisioned environments without kind should still deserialize"); |
319 | | |
320 | 1 | assert_eq!(info, EnvironmentInfo::Provisioned { |
321 | 1 | id: EnvironmentId::new("env-provisioned"), |
322 | 1 | display_name: None, |
323 | 1 | image: ImageId::new("ubuntu:24.04"), |
324 | 1 | status: EnvironmentStatus::Stopped, |
325 | 1 | }); |
326 | 1 | } |
327 | | } |