crates/flotilla-resources/src/workflow_template.rs
Line | Count | Source |
1 | | use std::collections::{BTreeMap, BTreeSet}; |
2 | | |
3 | | use serde::{Deserialize, Serialize}; |
4 | | |
5 | | use crate::{resource::define_resource, status_patch::NoStatusPatch}; |
6 | | |
7 | | define_resource!(WorkflowTemplate, "workflowtemplates", WorkflowTemplateSpec, (), NoStatusPatch); |
8 | | |
9 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)] |
10 | | pub struct WorkflowTemplateSpec { |
11 | | #[builder(default)] |
12 | | #[serde(default)] |
13 | | pub inputs: Vec<InputDefinition>, |
14 | | pub tasks: Vec<TaskDefinition>, |
15 | | } |
16 | | |
17 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
18 | | pub struct InputDefinition { |
19 | | pub name: String, |
20 | | #[serde(default)] |
21 | | pub description: Option<String>, |
22 | | } |
23 | | |
24 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)] |
25 | | pub struct TaskDefinition { |
26 | | pub name: String, |
27 | | #[builder(default)] |
28 | | #[serde(default)] |
29 | | pub depends_on: Vec<String>, |
30 | | pub processes: Vec<ProcessDefinition>, |
31 | | } |
32 | | |
33 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)] |
34 | | pub struct ProcessDefinition { |
35 | | pub role: String, |
36 | | #[serde(flatten)] |
37 | | pub source: ProcessSource, |
38 | | #[builder(default)] |
39 | | #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] |
40 | | pub labels: BTreeMap<String, String>, |
41 | | } |
42 | | |
43 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
44 | | #[serde(untagged, deny_unknown_fields)] |
45 | | pub enum ProcessSource { |
46 | | Agent { |
47 | | selector: Selector, |
48 | | #[serde(default)] |
49 | | prompt: Option<String>, |
50 | | }, |
51 | | Tool { |
52 | | command: String, |
53 | | }, |
54 | | } |
55 | | |
56 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
57 | | pub struct Selector { |
58 | | pub capability: String, |
59 | | } |
60 | | |
61 | | #[derive(Debug, Clone, PartialEq, Eq)] |
62 | | pub enum ValidationError { |
63 | | DuplicateTaskName { name: String }, |
64 | | DuplicateRoleInTask { task: String, role: String }, |
65 | | ReservedLabelKey { task: String, role: String, key: String }, |
66 | | UnknownDependency { task: String, missing: String }, |
67 | | DependencyCycle { cycle: Vec<String> }, |
68 | | DuplicateInputName { name: String }, |
69 | | MalformedInterpolation { location: InterpolationLocation, text: String }, |
70 | | UnknownInputReference { location: InterpolationLocation, name: String }, |
71 | | UnknownWorkflowField { location: InterpolationLocation, name: String }, |
72 | | } |
73 | | |
74 | | #[derive(Debug, Clone, PartialEq, Eq)] |
75 | | pub struct InterpolationLocation { |
76 | | pub task: String, |
77 | | pub role: String, |
78 | | pub field: InterpolationField, |
79 | | } |
80 | | |
81 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
82 | | pub enum InterpolationField { |
83 | | Prompt, |
84 | | Command, |
85 | | } |
86 | | |
87 | | impl std::fmt::Display for InterpolationField { |
88 | 0 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
89 | 0 | match self { |
90 | 0 | InterpolationField::Prompt => f.write_str("prompt"), |
91 | 0 | InterpolationField::Command => f.write_str("command"), |
92 | | } |
93 | 0 | } |
94 | | } |
95 | | |
96 | | impl std::fmt::Display for InterpolationLocation { |
97 | 0 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
98 | 0 | write!(f, "task `{}` role `{}` {}", self.task, self.role, self.field) |
99 | 0 | } |
100 | | } |
101 | | |
102 | | impl std::fmt::Display for ValidationError { |
103 | 0 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
104 | 0 | match self { |
105 | 0 | ValidationError::DuplicateTaskName { name } => write!(f, "duplicate task name `{name}`"), |
106 | 0 | ValidationError::DuplicateRoleInTask { task, role } => write!(f, "duplicate role `{role}` in task `{task}`"), |
107 | 0 | ValidationError::ReservedLabelKey { task, role, key } => { |
108 | 0 | write!(f, "reserved label key `{key}` on task `{task}` role `{role}`") |
109 | | } |
110 | 0 | ValidationError::UnknownDependency { task, missing } => write!(f, "task `{task}` depends on unknown task `{missing}`"), |
111 | 0 | ValidationError::DependencyCycle { cycle } => write!(f, "dependency cycle: {}", cycle.join(" -> ")), |
112 | 0 | ValidationError::DuplicateInputName { name } => write!(f, "duplicate input name `{name}`"), |
113 | 0 | ValidationError::MalformedInterpolation { location, text } => { |
114 | 0 | write!(f, "malformed interpolation `{{{{{text}}}}}` at {location}") |
115 | | } |
116 | 0 | ValidationError::UnknownInputReference { location, name } => write!(f, "unknown input `{name}` at {location}"), |
117 | 0 | ValidationError::UnknownWorkflowField { location, name } => write!(f, "unknown workflow field `{name}` at {location}"), |
118 | | } |
119 | 0 | } |
120 | | } |
121 | | |
122 | | #[derive(Clone, Copy, PartialEq, Eq)] |
123 | | enum VisitState { |
124 | | Visiting, |
125 | | Visited, |
126 | | } |
127 | | |
128 | 31 | pub fn validate(spec: &WorkflowTemplateSpec) -> Result<(), Vec<ValidationError>> { |
129 | 31 | let mut errors = Vec::new(); |
130 | 31 | let declared_inputs = collect_inputs(spec, &mut errors); |
131 | 31 | let tasks_by_name = collect_tasks(spec, &mut errors); |
132 | | |
133 | 59 | for task in &spec.tasks31 { |
134 | 59 | validate_task(task, &declared_inputs, &tasks_by_name, &mut errors); |
135 | 59 | } |
136 | 31 | validate_cycles(&tasks_by_name, &mut errors); |
137 | | |
138 | 31 | if errors.is_empty() { |
139 | 13 | Ok(()) |
140 | | } else { |
141 | 18 | Err(errors) |
142 | | } |
143 | 31 | } |
144 | | |
145 | 31 | fn collect_inputs(spec: &WorkflowTemplateSpec, errors: &mut Vec<ValidationError>) -> BTreeSet<String> { |
146 | 31 | let mut declared_inputs = BTreeSet::new(); |
147 | 45 | for input in &spec.inputs31 { |
148 | 45 | if !declared_inputs.insert(input.name.clone()) { |
149 | 2 | push_error(errors, ValidationError::DuplicateInputName { name: input.name.clone() }); |
150 | 43 | } |
151 | | } |
152 | 31 | declared_inputs |
153 | 31 | } |
154 | | |
155 | 31 | fn collect_tasks<'a>(spec: &'a WorkflowTemplateSpec, errors: &mut Vec<ValidationError>) -> BTreeMap<String, &'a TaskDefinition> { |
156 | 31 | let mut tasks_by_name = BTreeMap::new(); |
157 | 59 | for task in &spec.tasks31 { |
158 | 59 | if tasks_by_name.insert(task.name.clone(), task).is_some() { |
159 | 2 | push_error(errors, ValidationError::DuplicateTaskName { name: task.name.clone() }); |
160 | 57 | } |
161 | | } |
162 | 31 | tasks_by_name |
163 | 31 | } |
164 | | |
165 | 59 | fn validate_task( |
166 | 59 | task: &TaskDefinition, |
167 | 59 | declared_inputs: &BTreeSet<String>, |
168 | 59 | tasks_by_name: &BTreeMap<String, &TaskDefinition>, |
169 | 59 | errors: &mut Vec<ValidationError>, |
170 | 59 | ) { |
171 | 59 | let mut roles = BTreeSet::new(); |
172 | 59 | for dependency28 in &task.depends_on { |
173 | 28 | if !tasks_by_name.contains_key(dependency) { |
174 | 3 | push_error(errors, ValidationError::UnknownDependency { task: task.name.clone(), missing: dependency.clone() }); |
175 | 25 | } |
176 | | } |
177 | | |
178 | 106 | for process in &task.processes59 { |
179 | 106 | if !roles.insert(process.role.clone()) { |
180 | 2 | push_error(errors, ValidationError::DuplicateRoleInTask { task: task.name.clone(), role: process.role.clone() }); |
181 | 104 | } |
182 | | |
183 | 106 | for key3 in process.labels.keys() { |
184 | 3 | if key.starts_with(crate::labels::RESERVED_PREFIX) { |
185 | 1 | push_error(errors, ValidationError::ReservedLabelKey { |
186 | 1 | task: task.name.clone(), |
187 | 1 | role: process.role.clone(), |
188 | 1 | key: key.clone(), |
189 | 1 | }); |
190 | 2 | } |
191 | | } |
192 | | |
193 | 106 | match &process.source { |
194 | 43 | ProcessSource::Agent { prompt, .. } => { |
195 | 43 | if let Some(prompt) = prompt { |
196 | 43 | validate_template_text( |
197 | 43 | prompt, |
198 | 43 | &InterpolationLocation { task: task.name.clone(), role: process.role.clone(), field: InterpolationField::Prompt }, |
199 | 43 | declared_inputs, |
200 | 43 | errors, |
201 | 43 | ); |
202 | 43 | }0 |
203 | | } |
204 | 63 | ProcessSource::Tool { command } => validate_template_text( |
205 | 63 | command, |
206 | 63 | &InterpolationLocation { task: task.name.clone(), role: process.role.clone(), field: InterpolationField::Command }, |
207 | 63 | declared_inputs, |
208 | 63 | errors, |
209 | | ), |
210 | | } |
211 | | } |
212 | 59 | } |
213 | | |
214 | 31 | fn validate_cycles(tasks_by_name: &BTreeMap<String, &TaskDefinition>, errors: &mut Vec<ValidationError>) { |
215 | 31 | let mut states = BTreeMap::new(); |
216 | 31 | let mut stack = Vec::new(); |
217 | | |
218 | 57 | for task_name in tasks_by_name31 .keys31 () { |
219 | 57 | visit_task(task_name, tasks_by_name, &mut states, &mut stack, errors); |
220 | 57 | } |
221 | 31 | } |
222 | | |
223 | 80 | fn visit_task( |
224 | 80 | task_name: &str, |
225 | 80 | tasks_by_name: &BTreeMap<String, &TaskDefinition>, |
226 | 80 | states: &mut BTreeMap<String, VisitState>, |
227 | 80 | stack: &mut Vec<String>, |
228 | 80 | errors: &mut Vec<ValidationError>, |
229 | 80 | ) { |
230 | 80 | match states.get(task_name) { |
231 | 23 | Some(VisitState::Visited) => return, |
232 | 57 | None => {} |
233 | 0 | Some(VisitState::Visiting) => unreachable!("cycle detection handles visiting dependencies before recursion"), |
234 | | } |
235 | | |
236 | 57 | states.insert(task_name.to_string(), VisitState::Visiting); |
237 | 57 | stack.push(task_name.to_string()); |
238 | | |
239 | 57 | if let Some(task) = tasks_by_name.get(task_name) { |
240 | 57 | let mut dependencies = task.depends_on.iter().map(String::as_str).collect::<Vec<_>>(); |
241 | 57 | dependencies.sort_unstable(); |
242 | 57 | for dependency28 in dependencies { |
243 | 28 | if !tasks_by_name.contains_key(dependency) { |
244 | 3 | continue; |
245 | 25 | } |
246 | | |
247 | 25 | if states.get(dependency) == Some(&VisitState::Visiting) { |
248 | 2 | if let Some(index) = stack.iter().position(|name| name == dependency) { |
249 | 2 | let mut cycle = stack[index..].to_vec(); |
250 | 2 | cycle.push(dependency.to_string()); |
251 | 2 | push_error(errors, ValidationError::DependencyCycle { cycle }); |
252 | 2 | }0 |
253 | 2 | continue; |
254 | 23 | } |
255 | | |
256 | 23 | visit_task(dependency, tasks_by_name, states, stack, errors); |
257 | | } |
258 | 0 | } |
259 | | |
260 | 57 | stack.pop(); |
261 | 57 | states.insert(task_name.to_string(), VisitState::Visited); |
262 | 80 | } |
263 | | |
264 | 106 | fn validate_template_text( |
265 | 106 | text: &str, |
266 | 106 | location: &InterpolationLocation, |
267 | 106 | declared_inputs: &BTreeSet<String>, |
268 | 106 | errors: &mut Vec<ValidationError>, |
269 | 106 | ) { |
270 | 106 | let mut search_from = 0; |
271 | 180 | while let Some(open_offset74 ) = text[search_from..].find("{{") { |
272 | 74 | let open = search_from + open_offset; |
273 | 74 | let token_start = open + 2; |
274 | 74 | match text[token_start..].find("}}") { |
275 | 74 | Some(close_offset) => { |
276 | 74 | let token_end = token_start + close_offset; |
277 | 74 | validate_token(&text[token_start..token_end], location, declared_inputs, errors); |
278 | 74 | search_from = token_end + 2; |
279 | 74 | } |
280 | | None => { |
281 | 0 | let token = &text[token_start..]; |
282 | 0 | if is_owned_token(token) { |
283 | 0 | push_error(errors, ValidationError::MalformedInterpolation { location: location.clone(), text: token.to_string() }); |
284 | 0 | } |
285 | 0 | break; |
286 | | } |
287 | | } |
288 | | } |
289 | 106 | } |
290 | | |
291 | 74 | fn validate_token(token: &str, location: &InterpolationLocation, declared_inputs: &BTreeSet<String>, errors: &mut Vec<ValidationError>) { |
292 | 74 | if !is_owned_token(token) { |
293 | 2 | return; |
294 | 72 | } |
295 | | |
296 | 72 | if token.chars().any(char::is_whitespace) { |
297 | 2 | push_error(errors, ValidationError::MalformedInterpolation { location: location.clone(), text: token.to_string() }); |
298 | 2 | return; |
299 | 70 | } |
300 | | |
301 | 70 | let segments = token.split('.').collect::<Vec<_>>(); |
302 | 142 | if segments.iter()70 .any70 (|segment| segment.is_empty() || !segment.chars().all(is_valid_segment_char)) { |
303 | 0 | push_error(errors, ValidationError::MalformedInterpolation { location: location.clone(), text: token.to_string() }); |
304 | 0 | return; |
305 | 70 | } |
306 | | |
307 | 70 | match segments.as_slice() { |
308 | 68 | ["inputs", input_name40 ] if !declared_inputs40 .contains40 (*input_name40 )2 => { |
309 | 2 | push_error(errors, ValidationError::UnknownInputReference { location: location.clone(), name: (*input_name).to_string() }); |
310 | 2 | } |
311 | 38 | ["inputs", _] => {} |
312 | 28 | ["workflow", "name"] | ["workflow", "namespace"11 ] => {}26 |
313 | 2 | ["workflow", field] => { |
314 | 2 | push_error(errors, ValidationError::UnknownWorkflowField { location: location.clone(), name: (*field).to_string() }) |
315 | | } |
316 | 2 | [prefix, ..] if *prefix == "inputs" || *prefix == "workflow" => { |
317 | 2 | push_error(errors, ValidationError::MalformedInterpolation { location: location.clone(), text: token.to_string() }) |
318 | | } |
319 | 0 | _ => {} |
320 | | } |
321 | 74 | } |
322 | | |
323 | 74 | fn is_owned_token(token: &str) -> bool { |
324 | 74 | matches!72 (token.split('.').next(), Some("inputs" | "workflow"32 )) |
325 | 74 | } |
326 | | |
327 | 911 | fn is_valid_segment_char(ch: char) -> bool { |
328 | 911 | ch.is_ascii_alphanumeric() || ch == '_'0 || ch == '-'0 |
329 | 911 | } |
330 | | |
331 | 20 | fn push_error(errors: &mut Vec<ValidationError>, error: ValidationError) { |
332 | 20 | if !errors.contains(&error) { |
333 | 20 | errors.push(error); |
334 | 20 | }0 |
335 | 20 | } |
336 | | |
337 | | #[cfg(test)] |
338 | | mod tests { |
339 | | use super::{ |
340 | | validate, InputDefinition, InterpolationField, InterpolationLocation, ProcessDefinition, ProcessSource, Selector, TaskDefinition, |
341 | | ValidationError, WorkflowTemplateSpec, |
342 | | }; |
343 | | |
344 | 9 | fn valid_spec() -> WorkflowTemplateSpec { |
345 | 9 | WorkflowTemplateSpec::builder() |
346 | 9 | .inputs(vec![InputDefinition { name: "feature".to_string(), description: None }]) |
347 | 9 | .tasks(vec![ |
348 | 9 | TaskDefinition::builder() |
349 | 9 | .name("implement".to_string()) |
350 | 9 | .processes(vec![ |
351 | 9 | ProcessDefinition::builder() |
352 | 9 | .role("coder".to_string()) |
353 | 9 | .source(ProcessSource::Agent { |
354 | 9 | selector: Selector { capability: "code".to_string() }, |
355 | 9 | prompt: Some("Implement {{inputs.feature}} for {{workflow.name}}".to_string()), |
356 | 9 | }) |
357 | 9 | .build(), |
358 | 9 | ProcessDefinition::builder() |
359 | 9 | .role("build".to_string()) |
360 | 9 | .source(ProcessSource::Tool { command: "cargo check".to_string() }) |
361 | 9 | .build(), |
362 | | ]) |
363 | 9 | .build(), |
364 | 9 | TaskDefinition::builder() |
365 | 9 | .name("review".to_string()) |
366 | 9 | .depends_on(vec!["implement".to_string()]) |
367 | 9 | .processes(vec![ProcessDefinition::builder() |
368 | 9 | .role("reviewer".to_string()) |
369 | 9 | .source(ProcessSource::Agent { |
370 | 9 | selector: Selector { capability: "code-review".to_string() }, |
371 | 9 | prompt: Some("Review {{workflow.namespace}}".to_string()), |
372 | 9 | }) |
373 | 9 | .build()]) |
374 | 9 | .build(), |
375 | | ]) |
376 | 9 | .build() |
377 | 9 | } |
378 | | |
379 | | #[test] |
380 | 1 | fn validate_rejects_duplicate_task_names() { |
381 | 1 | let mut spec = valid_spec(); |
382 | 1 | spec.tasks.push(spec.tasks[0].clone()); |
383 | | |
384 | 1 | let errors = validate(&spec).expect_err("duplicate task names should fail"); |
385 | 1 | assert!(errors.contains(&ValidationError::DuplicateTaskName { name: "implement".to_string() })); |
386 | 1 | } |
387 | | |
388 | | #[test] |
389 | 1 | fn validate_rejects_duplicate_role_names_within_task() { |
390 | 1 | let mut spec = valid_spec(); |
391 | 1 | spec.tasks[0].processes.push( |
392 | 1 | ProcessDefinition::builder() |
393 | 1 | .role("coder".to_string()) |
394 | 1 | .source(ProcessSource::Tool { command: "cargo test".to_string() }) |
395 | 1 | .build(), |
396 | | ); |
397 | | |
398 | 1 | let errors = validate(&spec).expect_err("duplicate role names should fail"); |
399 | 1 | assert!(errors.contains(&ValidationError::DuplicateRoleInTask { task: "implement".to_string(), role: "coder".to_string() })); |
400 | 1 | } |
401 | | |
402 | | #[test] |
403 | 1 | fn validate_rejects_unknown_dependencies() { |
404 | 1 | let mut spec = valid_spec(); |
405 | 1 | spec.tasks[1].depends_on = vec!["missing".to_string()]; |
406 | | |
407 | 1 | let errors = validate(&spec).expect_err("unknown dependencies should fail"); |
408 | 1 | assert!(errors.contains(&ValidationError::UnknownDependency { task: "review".to_string(), missing: "missing".to_string() })); |
409 | 1 | } |
410 | | |
411 | | #[test] |
412 | 1 | fn validate_rejects_cycles() { |
413 | 1 | let mut spec = valid_spec(); |
414 | 1 | spec.tasks[0].depends_on = vec!["review".to_string()]; |
415 | | |
416 | 1 | let errors = validate(&spec).expect_err("cycles should fail"); |
417 | 1 | assert!(errors.contains(&ValidationError::DependencyCycle { |
418 | 1 | cycle: vec!["implement".to_string(), "review".to_string(), "implement".to_string()], |
419 | 1 | })); |
420 | 1 | } |
421 | | |
422 | | #[test] |
423 | 1 | fn validate_rejects_duplicate_input_names() { |
424 | 1 | let mut spec = valid_spec(); |
425 | 1 | spec.inputs.push(InputDefinition { name: "feature".to_string(), description: Some("duplicate".to_string()) }); |
426 | | |
427 | 1 | let errors = validate(&spec).expect_err("duplicate inputs should fail"); |
428 | 1 | assert!(errors.contains(&ValidationError::DuplicateInputName { name: "feature".to_string() })); |
429 | 1 | } |
430 | | |
431 | | #[test] |
432 | 1 | fn validate_rejects_unknown_input_references() { |
433 | 1 | let mut spec = valid_spec(); |
434 | 1 | spec.tasks[0].processes[0].source = ProcessSource::Agent { |
435 | 1 | selector: Selector { capability: "code".to_string() }, |
436 | 1 | prompt: Some("Implement {{inputs.branch}}".to_string()), |
437 | 1 | }; |
438 | | |
439 | 1 | let errors = validate(&spec).expect_err("unknown input references should fail"); |
440 | 1 | assert!(errors.contains(&ValidationError::UnknownInputReference { |
441 | 1 | location: InterpolationLocation { task: "implement".to_string(), role: "coder".to_string(), field: InterpolationField::Prompt }, |
442 | 1 | name: "branch".to_string(), |
443 | 1 | })); |
444 | 1 | } |
445 | | |
446 | | #[test] |
447 | 1 | fn validate_rejects_unknown_workflow_fields() { |
448 | 1 | let mut spec = valid_spec(); |
449 | 1 | spec.tasks[0].processes[0].source = ProcessSource::Agent { |
450 | 1 | selector: Selector { capability: "code".to_string() }, |
451 | 1 | prompt: Some("Implement {{workflow.uid}}".to_string()), |
452 | 1 | }; |
453 | | |
454 | 1 | let errors = validate(&spec).expect_err("unknown workflow fields should fail"); |
455 | 1 | assert!(errors.contains(&ValidationError::UnknownWorkflowField { |
456 | 1 | location: InterpolationLocation { task: "implement".to_string(), role: "coder".to_string(), field: InterpolationField::Prompt }, |
457 | 1 | name: "uid".to_string(), |
458 | 1 | })); |
459 | 1 | } |
460 | | |
461 | | #[test] |
462 | 1 | fn validate_rejects_malformed_owned_interpolations() { |
463 | 1 | let mut spec = valid_spec(); |
464 | 1 | spec.tasks[0].processes[0].source = ProcessSource::Agent { |
465 | 1 | selector: Selector { capability: "code".to_string() }, |
466 | 1 | prompt: Some("Implement {{inputs.feature }} and {{workflow.name.extra}}".to_string()), |
467 | 1 | }; |
468 | | |
469 | 1 | let errors = validate(&spec).expect_err("malformed owned interpolation should fail"); |
470 | 1 | assert!(errors.contains(&ValidationError::MalformedInterpolation { |
471 | 1 | location: InterpolationLocation { task: "implement".to_string(), role: "coder".to_string(), field: InterpolationField::Prompt }, |
472 | 1 | text: "inputs.feature ".to_string(), |
473 | 1 | })); |
474 | 1 | assert!(errors.contains(&ValidationError::MalformedInterpolation { |
475 | 1 | location: InterpolationLocation { task: "implement".to_string(), role: "coder".to_string(), field: InterpolationField::Prompt }, |
476 | 1 | text: "workflow.name.extra".to_string(), |
477 | 1 | })); |
478 | 1 | } |
479 | | |
480 | | #[test] |
481 | 1 | fn validate_allows_foreign_interpolations() { |
482 | 1 | let mut spec = valid_spec(); |
483 | 1 | spec.tasks[0].processes[1].source = |
484 | 1 | ProcessSource::Tool { command: "kubectl get pod -o go-template='{{.metadata.name}}'".to_string() }; |
485 | | |
486 | 1 | assert!(validate(&spec).is_ok(), "foreign interpolations should pass through"); |
487 | 1 | } |
488 | | } |