Coverage Report

Created: 2026-04-05 07:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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
}