Skip to main content

peat_schema/validation/
model.rs

1//! Model deployment validators
2//!
3//! Validates ModelDeployment and ModelDeploymentStatus messages for Peat Protocol.
4
5use super::{ValidationError, ValidationResult};
6use crate::model::v1::{
7    DeploymentPolicy, DeploymentPriority, DeploymentState, ModelDeployment, ModelDeploymentStatus,
8    ModelType,
9};
10
11/// Validate a ModelDeployment message
12///
13/// Validates:
14/// - deployment_id is present
15/// - model_id is present
16/// - model_version is present
17/// - model_type is specified (not unspecified)
18/// - model_url is present and well-formed
19/// - checksum_sha256 is present and valid length
20/// - file_size_bytes is non-zero
21/// - At least one target_platform is specified
22/// - deployment_policy is specified
23/// - priority is specified
24/// - deployed_at timestamp is present
25/// - deployed_by is present
26pub fn validate_model_deployment(deployment: &ModelDeployment) -> ValidationResult<()> {
27    // Check required string fields
28    if deployment.deployment_id.is_empty() {
29        return Err(ValidationError::MissingField("deployment_id".to_string()));
30    }
31
32    if deployment.model_id.is_empty() {
33        return Err(ValidationError::MissingField("model_id".to_string()));
34    }
35
36    if deployment.model_version.is_empty() {
37        return Err(ValidationError::MissingField("model_version".to_string()));
38    }
39
40    // Model type must be specified
41    if deployment.model_type == ModelType::Unspecified as i32 {
42        return Err(ValidationError::InvalidValue(
43            "model_type must be specified".to_string(),
44        ));
45    }
46
47    // Model URL must be present
48    if deployment.model_url.is_empty() {
49        return Err(ValidationError::MissingField("model_url".to_string()));
50    }
51
52    // Validate URL scheme (basic check for https://, s3://, or similar)
53    if !deployment.model_url.contains("://") {
54        return Err(ValidationError::InvalidValue(
55            "model_url must be a valid URL with scheme".to_string(),
56        ));
57    }
58
59    // Checksum must be present and 64 characters (SHA256 hex)
60    if deployment.checksum_sha256.is_empty() {
61        return Err(ValidationError::MissingField("checksum_sha256".to_string()));
62    }
63
64    if deployment.checksum_sha256.len() != 64 {
65        return Err(ValidationError::InvalidValue(format!(
66            "checksum_sha256 must be 64 hex characters, got {}",
67            deployment.checksum_sha256.len()
68        )));
69    }
70
71    // Validate checksum is valid hex
72    if !deployment
73        .checksum_sha256
74        .chars()
75        .all(|c| c.is_ascii_hexdigit())
76    {
77        return Err(ValidationError::InvalidValue(
78            "checksum_sha256 must contain only hex characters".to_string(),
79        ));
80    }
81
82    // File size must be non-zero
83    if deployment.file_size_bytes == 0 {
84        return Err(ValidationError::InvalidValue(
85            "file_size_bytes must be non-zero".to_string(),
86        ));
87    }
88
89    // At least one target platform is required
90    if deployment.target_platforms.is_empty() {
91        return Err(ValidationError::MissingField(
92            "target_platforms (at least one required)".to_string(),
93        ));
94    }
95
96    // Deployment policy must be specified
97    if deployment.deployment_policy == DeploymentPolicy::Unspecified as i32 {
98        return Err(ValidationError::InvalidValue(
99            "deployment_policy must be specified".to_string(),
100        ));
101    }
102
103    // Priority must be specified
104    if deployment.priority == DeploymentPriority::Unspecified as i32 {
105        return Err(ValidationError::InvalidValue(
106            "priority must be specified".to_string(),
107        ));
108    }
109
110    // deployed_at timestamp is required
111    if deployment.deployed_at.is_none() {
112        return Err(ValidationError::MissingField("deployed_at".to_string()));
113    }
114
115    // deployed_by is required
116    if deployment.deployed_by.is_empty() {
117        return Err(ValidationError::MissingField("deployed_by".to_string()));
118    }
119
120    Ok(())
121}
122
123/// Validate a ModelDeploymentStatus message
124///
125/// Validates:
126/// - deployment_id is present
127/// - platform_id is present
128/// - state is specified (not unspecified)
129/// - progress_percent is in valid range (0-100)
130/// - updated_at timestamp is present
131/// - If state is FAILED, error_message is present
132/// - If state is COMPLETE or VERIFYING, downloaded_hash is present and valid
133pub fn validate_model_deployment_status(status: &ModelDeploymentStatus) -> ValidationResult<()> {
134    // Check required fields
135    if status.deployment_id.is_empty() {
136        return Err(ValidationError::MissingField("deployment_id".to_string()));
137    }
138
139    if status.platform_id.is_empty() {
140        return Err(ValidationError::MissingField("platform_id".to_string()));
141    }
142
143    // State must be specified
144    if status.state == DeploymentState::Unspecified as i32 {
145        return Err(ValidationError::InvalidValue(
146            "state must be specified".to_string(),
147        ));
148    }
149
150    // Progress must be 0-100
151    if status.progress_percent > 100 {
152        return Err(ValidationError::InvalidValue(format!(
153            "progress_percent {} must be between 0 and 100",
154            status.progress_percent
155        )));
156    }
157
158    // updated_at is required
159    if status.updated_at.is_none() {
160        return Err(ValidationError::MissingField("updated_at".to_string()));
161    }
162
163    // If state is FAILED, error_message must be present
164    if status.state == DeploymentState::Failed as i32 && status.error_message.is_empty() {
165        return Err(ValidationError::MissingField(
166            "error_message (required when state is FAILED)".to_string(),
167        ));
168    }
169
170    // If state is COMPLETE or VERIFYING, downloaded_hash should be present
171    if (status.state == DeploymentState::Complete as i32
172        || status.state == DeploymentState::Verifying as i32)
173        && status.downloaded_hash.is_empty()
174    {
175        return Err(ValidationError::MissingField(
176            "downloaded_hash (required for COMPLETE or VERIFYING state)".to_string(),
177        ));
178    }
179
180    // Validate downloaded_hash format if present
181    if !status.downloaded_hash.is_empty() {
182        if status.downloaded_hash.len() != 64 {
183            return Err(ValidationError::InvalidValue(format!(
184                "downloaded_hash must be 64 hex characters, got {}",
185                status.downloaded_hash.len()
186            )));
187        }
188
189        if !status
190            .downloaded_hash
191            .chars()
192            .all(|c| c.is_ascii_hexdigit())
193        {
194            return Err(ValidationError::InvalidValue(
195                "downloaded_hash must contain only hex characters".to_string(),
196            ));
197        }
198    }
199
200    Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::common::v1::Timestamp;
207
208    fn valid_model_deployment() -> ModelDeployment {
209        ModelDeployment {
210            deployment_id: "deploy-2025-001".to_string(),
211            model_id: "yolov8-poi-v2.1".to_string(),
212            model_version: "2.1.0".to_string(),
213            model_type: ModelType::Detector as i32,
214            model_url: "https://models.example.com/yolov8-poi-v2.1.onnx".to_string(),
215            checksum_sha256: "a".repeat(64), // Valid SHA256 hex
216            file_size_bytes: 45_000_000,
217            target_platforms: vec!["Alpha-3".to_string(), "Bravo-1".to_string()],
218            deployment_policy: DeploymentPolicy::Rolling as i32,
219            priority: DeploymentPriority::Normal as i32,
220            deployed_at: Some(Timestamp {
221                seconds: 1702000000,
222                nanos: 0,
223            }),
224            deployed_by: "MLOps-Pipeline".to_string(),
225            rollback_model_id: String::new(),
226            metadata: None,
227        }
228    }
229
230    fn valid_deployment_status() -> ModelDeploymentStatus {
231        ModelDeploymentStatus {
232            deployment_id: "deploy-2025-001".to_string(),
233            platform_id: "Alpha-3".to_string(),
234            state: DeploymentState::Downloading as i32,
235            progress_percent: 45,
236            error_message: String::new(),
237            updated_at: Some(Timestamp {
238                seconds: 1702000100,
239                nanos: 0,
240            }),
241            downloaded_hash: String::new(),
242            previous_version: "2.0.0".to_string(),
243        }
244    }
245
246    #[test]
247    fn test_valid_model_deployment() {
248        let deployment = valid_model_deployment();
249        assert!(validate_model_deployment(&deployment).is_ok());
250    }
251
252    #[test]
253    fn test_missing_deployment_id() {
254        let mut deployment = valid_model_deployment();
255        deployment.deployment_id = String::new();
256        let err = validate_model_deployment(&deployment).unwrap_err();
257        assert!(matches!(err, ValidationError::MissingField(f) if f == "deployment_id"));
258    }
259
260    #[test]
261    fn test_missing_model_id() {
262        let mut deployment = valid_model_deployment();
263        deployment.model_id = String::new();
264        let err = validate_model_deployment(&deployment).unwrap_err();
265        assert!(matches!(err, ValidationError::MissingField(f) if f == "model_id"));
266    }
267
268    #[test]
269    fn test_unspecified_model_type() {
270        let mut deployment = valid_model_deployment();
271        deployment.model_type = ModelType::Unspecified as i32;
272        let err = validate_model_deployment(&deployment).unwrap_err();
273        assert!(matches!(err, ValidationError::InvalidValue(_)));
274    }
275
276    #[test]
277    fn test_invalid_model_url() {
278        let mut deployment = valid_model_deployment();
279        deployment.model_url = "not-a-valid-url".to_string();
280        let err = validate_model_deployment(&deployment).unwrap_err();
281        assert!(matches!(err, ValidationError::InvalidValue(_)));
282    }
283
284    #[test]
285    fn test_invalid_checksum_length() {
286        let mut deployment = valid_model_deployment();
287        deployment.checksum_sha256 = "abc123".to_string(); // Too short
288        let err = validate_model_deployment(&deployment).unwrap_err();
289        assert!(matches!(err, ValidationError::InvalidValue(_)));
290    }
291
292    #[test]
293    fn test_invalid_checksum_chars() {
294        let mut deployment = valid_model_deployment();
295        deployment.checksum_sha256 = "g".repeat(64); // 'g' is not hex
296        let err = validate_model_deployment(&deployment).unwrap_err();
297        assert!(matches!(err, ValidationError::InvalidValue(_)));
298    }
299
300    #[test]
301    fn test_zero_file_size() {
302        let mut deployment = valid_model_deployment();
303        deployment.file_size_bytes = 0;
304        let err = validate_model_deployment(&deployment).unwrap_err();
305        assert!(matches!(err, ValidationError::InvalidValue(_)));
306    }
307
308    #[test]
309    fn test_empty_target_platforms() {
310        let mut deployment = valid_model_deployment();
311        deployment.target_platforms = vec![];
312        let err = validate_model_deployment(&deployment).unwrap_err();
313        assert!(matches!(err, ValidationError::MissingField(_)));
314    }
315
316    #[test]
317    fn test_unspecified_deployment_policy() {
318        let mut deployment = valid_model_deployment();
319        deployment.deployment_policy = DeploymentPolicy::Unspecified as i32;
320        let err = validate_model_deployment(&deployment).unwrap_err();
321        assert!(matches!(err, ValidationError::InvalidValue(_)));
322    }
323
324    #[test]
325    fn test_missing_deployed_at() {
326        let mut deployment = valid_model_deployment();
327        deployment.deployed_at = None;
328        let err = validate_model_deployment(&deployment).unwrap_err();
329        assert!(matches!(err, ValidationError::MissingField(f) if f == "deployed_at"));
330    }
331
332    #[test]
333    fn test_valid_deployment_status() {
334        let status = valid_deployment_status();
335        assert!(validate_model_deployment_status(&status).is_ok());
336    }
337
338    #[test]
339    fn test_status_missing_deployment_id() {
340        let mut status = valid_deployment_status();
341        status.deployment_id = String::new();
342        let err = validate_model_deployment_status(&status).unwrap_err();
343        assert!(matches!(err, ValidationError::MissingField(f) if f == "deployment_id"));
344    }
345
346    #[test]
347    fn test_missing_platform_id() {
348        let mut status = valid_deployment_status();
349        status.platform_id = String::new();
350        let err = validate_model_deployment_status(&status).unwrap_err();
351        assert!(matches!(err, ValidationError::MissingField(f) if f == "platform_id"));
352    }
353
354    #[test]
355    fn test_unspecified_state() {
356        let mut status = valid_deployment_status();
357        status.state = DeploymentState::Unspecified as i32;
358        let err = validate_model_deployment_status(&status).unwrap_err();
359        assert!(matches!(err, ValidationError::InvalidValue(_)));
360    }
361
362    #[test]
363    fn test_invalid_progress_percent() {
364        let mut status = valid_deployment_status();
365        status.progress_percent = 150; // > 100
366        let err = validate_model_deployment_status(&status).unwrap_err();
367        assert!(matches!(err, ValidationError::InvalidValue(_)));
368    }
369
370    #[test]
371    fn test_missing_updated_at() {
372        let mut status = valid_deployment_status();
373        status.updated_at = None;
374        let err = validate_model_deployment_status(&status).unwrap_err();
375        assert!(matches!(err, ValidationError::MissingField(f) if f == "updated_at"));
376    }
377
378    #[test]
379    fn test_failed_state_requires_error_message() {
380        let mut status = valid_deployment_status();
381        status.state = DeploymentState::Failed as i32;
382        status.error_message = String::new();
383        let err = validate_model_deployment_status(&status).unwrap_err();
384        assert!(matches!(err, ValidationError::MissingField(_)));
385    }
386
387    #[test]
388    fn test_complete_state_requires_hash() {
389        let mut status = valid_deployment_status();
390        status.state = DeploymentState::Complete as i32;
391        status.downloaded_hash = String::new();
392        let err = validate_model_deployment_status(&status).unwrap_err();
393        assert!(matches!(err, ValidationError::MissingField(_)));
394    }
395
396    #[test]
397    fn test_valid_complete_status() {
398        let mut status = valid_deployment_status();
399        status.state = DeploymentState::Complete as i32;
400        status.downloaded_hash = "a".repeat(64);
401        status.progress_percent = 100;
402        assert!(validate_model_deployment_status(&status).is_ok());
403    }
404
405    #[test]
406    fn test_invalid_downloaded_hash_length() {
407        let mut status = valid_deployment_status();
408        status.state = DeploymentState::Complete as i32;
409        status.downloaded_hash = "abc123".to_string(); // Too short
410        let err = validate_model_deployment_status(&status).unwrap_err();
411        assert!(matches!(err, ValidationError::InvalidValue(_)));
412    }
413}