1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub struct RuntimeOverrides {
13 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub wasm: Option<WasmOverrides>,
16
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub docker: Option<DockerOverrides>,
20
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub native: Option<NativeOverrides>,
24}
25
26impl RuntimeOverrides {
27 pub fn new() -> Self {
29 Self::default()
30 }
31
32 pub fn with_wasm(mut self, wasm: WasmOverrides) -> Self {
34 self.wasm = Some(wasm);
35 self
36 }
37
38 pub fn with_docker(mut self, docker: DockerOverrides) -> Self {
40 self.docker = Some(docker);
41 self
42 }
43
44 pub fn with_native(mut self, native: NativeOverrides) -> Self {
46 self.native = Some(native);
47 self
48 }
49
50 pub fn is_empty(&self) -> bool {
52 self.wasm.is_none() && self.docker.is_none() && self.native.is_none()
53 }
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub struct WasmOverrides {
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub stack_size: Option<usize>,
63
64 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
66 pub wasi_capabilities: HashMap<String, bool>,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub fuel_limit: Option<u64>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub epoch_interruption: Option<bool>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub max_memory_pages: Option<u32>,
79
80 #[serde(default)]
82 pub debug_info: bool,
83}
84
85impl WasmOverrides {
86 pub fn new() -> Self {
88 Self::default()
89 }
90
91 pub fn with_stack_size(mut self, size: usize) -> Self {
93 self.stack_size = Some(size);
94 self
95 }
96
97 pub fn with_wasi_capability(mut self, capability: impl Into<String>, enabled: bool) -> Self {
99 self.wasi_capabilities.insert(capability.into(), enabled);
100 self
101 }
102
103 pub fn enable_capability(self, capability: impl Into<String>) -> Self {
105 self.with_wasi_capability(capability, true)
106 }
107
108 pub fn disable_capability(self, capability: impl Into<String>) -> Self {
110 self.with_wasi_capability(capability, false)
111 }
112
113 pub fn with_fuel_limit(mut self, limit: u64) -> Self {
115 self.fuel_limit = Some(limit);
116 self
117 }
118
119 pub fn with_epoch_interruption(mut self) -> Self {
121 self.epoch_interruption = Some(true);
122 self
123 }
124
125 pub fn with_max_memory_pages(mut self, pages: u32) -> Self {
127 self.max_memory_pages = Some(pages);
128 self
129 }
130
131 pub fn with_debug_info(mut self) -> Self {
133 self.debug_info = true;
134 self
135 }
136
137 pub fn is_capability_enabled(&self, capability: &str) -> Option<bool> {
139 self.wasi_capabilities.get(capability).copied()
140 }
141
142 pub fn stack_size_or_default(&self) -> usize {
144 self.stack_size.unwrap_or(1024 * 1024) }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub struct DockerOverrides {
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub image: Option<String>,
155
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub extra_args: Vec<String>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub entrypoint: Option<String>,
163
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub command: Option<Vec<String>>,
167
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub user: Option<String>,
171
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub gpus: Option<String>,
175
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub platform: Option<String>,
179
180 #[serde(default)]
182 pub privileged: bool,
183
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub security_opt: Vec<String>,
187
188 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
190 pub sysctls: HashMap<String, String>,
191
192 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
194 pub labels: HashMap<String, String>,
195
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub restart: Option<String>,
199
200 #[serde(default = "default_true")]
202 pub rm: bool,
203
204 #[serde(default)]
206 pub init: bool,
207
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub hostname: Option<String>,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub ipc: Option<String>,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub pid: Option<String>,
219
220 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub cap_add: Vec<String>,
223
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub cap_drop: Vec<String>,
227}
228
229fn default_true() -> bool {
230 true
231}
232
233impl Default for DockerOverrides {
234 fn default() -> Self {
235 Self {
236 image: None,
237 extra_args: Vec::new(),
238 entrypoint: None,
239 command: None,
240 user: None,
241 gpus: None,
242 platform: None,
243 privileged: false,
244 security_opt: Vec::new(),
245 sysctls: HashMap::new(),
246 labels: HashMap::new(),
247 restart: None,
248 rm: true, init: false,
250 hostname: None,
251 ipc: None,
252 pid: None,
253 cap_add: Vec::new(),
254 cap_drop: Vec::new(),
255 }
256 }
257}
258
259impl DockerOverrides {
260 pub fn new() -> Self {
262 Self::default()
263 }
264
265 pub fn with_image(mut self, image: impl Into<String>) -> Self {
267 self.image = Some(image.into());
268 self
269 }
270
271 pub fn with_extra_arg(mut self, arg: impl Into<String>) -> Self {
273 self.extra_args.push(arg.into());
274 self
275 }
276
277 pub fn with_entrypoint(mut self, entrypoint: impl Into<String>) -> Self {
279 self.entrypoint = Some(entrypoint.into());
280 self
281 }
282
283 pub fn with_command(mut self, command: Vec<String>) -> Self {
285 self.command = Some(command);
286 self
287 }
288
289 pub fn with_user(mut self, user: impl Into<String>) -> Self {
291 self.user = Some(user.into());
292 self
293 }
294
295 pub fn with_gpus(mut self, gpus: impl Into<String>) -> Self {
297 self.gpus = Some(gpus.into());
298 self
299 }
300
301 pub fn with_all_gpus(self) -> Self {
303 self.with_gpus("all")
304 }
305
306 pub fn with_platform(mut self, platform: impl Into<String>) -> Self {
308 self.platform = Some(platform.into());
309 self
310 }
311
312 pub fn privileged(mut self) -> Self {
314 self.privileged = true;
315 self
316 }
317
318 pub fn with_security_opt(mut self, opt: impl Into<String>) -> Self {
320 self.security_opt.push(opt.into());
321 self
322 }
323
324 pub fn with_no_new_privileges(self) -> Self {
326 self.with_security_opt("no-new-privileges")
327 }
328
329 pub fn with_sysctl(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
331 self.sysctls.insert(key.into(), value.into());
332 self
333 }
334
335 pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
337 self.labels.insert(key.into(), value.into());
338 self
339 }
340
341 pub fn with_restart(mut self, policy: impl Into<String>) -> Self {
343 self.restart = Some(policy.into());
344 self
345 }
346
347 pub fn keep_container(mut self) -> Self {
349 self.rm = false;
350 self
351 }
352
353 pub fn with_init(mut self) -> Self {
355 self.init = true;
356 self
357 }
358
359 pub fn with_hostname(mut self, hostname: impl Into<String>) -> Self {
361 self.hostname = Some(hostname.into());
362 self
363 }
364
365 pub fn add_capability(mut self, cap: impl Into<String>) -> Self {
367 self.cap_add.push(cap.into());
368 self
369 }
370
371 pub fn drop_capability(mut self, cap: impl Into<String>) -> Self {
373 self.cap_drop.push(cap.into());
374 self
375 }
376
377 pub fn drop_all_capabilities(self) -> Self {
379 self.drop_capability("ALL")
380 }
381
382 pub fn to_docker_args(&self) -> Vec<String> {
384 let mut args = Vec::new();
385
386 if self.rm {
387 args.push("--rm".to_string());
388 }
389
390 if self.init {
391 args.push("--init".to_string());
392 }
393
394 if self.privileged {
395 args.push("--privileged".to_string());
396 }
397
398 if let Some(ref user) = self.user {
399 args.push("--user".to_string());
400 args.push(user.clone());
401 }
402
403 if let Some(ref gpus) = self.gpus {
404 args.push("--gpus".to_string());
405 args.push(gpus.clone());
406 }
407
408 if let Some(ref platform) = self.platform {
409 args.push("--platform".to_string());
410 args.push(platform.clone());
411 }
412
413 if let Some(ref entrypoint) = self.entrypoint {
414 args.push("--entrypoint".to_string());
415 args.push(entrypoint.clone());
416 }
417
418 if let Some(ref hostname) = self.hostname {
419 args.push("--hostname".to_string());
420 args.push(hostname.clone());
421 }
422
423 if let Some(ref ipc) = self.ipc {
424 args.push("--ipc".to_string());
425 args.push(ipc.clone());
426 }
427
428 if let Some(ref pid) = self.pid {
429 args.push("--pid".to_string());
430 args.push(pid.clone());
431 }
432
433 if let Some(ref restart) = self.restart {
434 args.push("--restart".to_string());
435 args.push(restart.clone());
436 }
437
438 for opt in &self.security_opt {
439 args.push("--security-opt".to_string());
440 args.push(opt.clone());
441 }
442
443 for (key, value) in &self.sysctls {
444 args.push("--sysctl".to_string());
445 args.push(format!("{}={}", key, value));
446 }
447
448 for (key, value) in &self.labels {
449 args.push("--label".to_string());
450 args.push(format!("{}={}", key, value));
451 }
452
453 for cap in &self.cap_add {
454 args.push("--cap-add".to_string());
455 args.push(cap.clone());
456 }
457
458 for cap in &self.cap_drop {
459 args.push("--cap-drop".to_string());
460 args.push(cap.clone());
461 }
462
463 args.extend(self.extra_args.clone());
464
465 args
466 }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(rename_all = "snake_case")]
472pub struct NativeOverrides {
473 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub working_dir: Option<String>,
476
477 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub shell: Option<String>,
480
481 #[serde(default, skip_serializing_if = "Vec::is_empty")]
483 pub path_additions: Vec<String>,
484
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub run_as: Option<String>,
488
489 #[serde(default)]
491 pub clear_env: bool,
492
493 #[serde(default = "default_true")]
495 pub inherit_env: bool,
496}
497
498impl Default for NativeOverrides {
499 fn default() -> Self {
500 Self {
501 working_dir: None,
502 shell: None,
503 path_additions: Vec::new(),
504 run_as: None,
505 clear_env: false,
506 inherit_env: true, }
508 }
509}
510
511impl NativeOverrides {
512 pub fn new() -> Self {
514 Self::default()
515 }
516
517 pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {
519 self.working_dir = Some(dir.into());
520 self
521 }
522
523 pub fn with_shell(mut self, shell: impl Into<String>) -> Self {
525 self.shell = Some(shell.into());
526 self
527 }
528
529 pub fn with_path_addition(mut self, path: impl Into<String>) -> Self {
531 self.path_additions.push(path.into());
532 self
533 }
534
535 pub fn with_run_as(mut self, user: impl Into<String>) -> Self {
537 self.run_as = Some(user.into());
538 self
539 }
540
541 pub fn with_clear_env(mut self) -> Self {
543 self.clear_env = true;
544 self.inherit_env = false;
545 self
546 }
547
548 pub fn without_inherit_env(mut self) -> Self {
550 self.inherit_env = false;
551 self
552 }
553
554 pub fn shell_or_default(&self) -> &str {
556 self.shell.as_deref().unwrap_or_else(|| {
557 if cfg!(windows) {
558 "cmd.exe"
559 } else {
560 "/bin/sh"
561 }
562 })
563 }
564
565 pub fn build_path(&self, existing_path: Option<&str>) -> String {
567 let separator = if cfg!(windows) { ";" } else { ":" };
568 let additions = self.path_additions.join(separator);
569
570 match (additions.is_empty(), existing_path) {
571 (true, Some(p)) => p.to_string(),
572 (true, None) => String::new(),
573 (false, Some(p)) if self.inherit_env => format!("{}{}{}",additions, separator, p),
574 (false, _) => additions,
575 }
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn test_runtime_overrides_builder() {
585 let overrides = RuntimeOverrides::new()
586 .with_wasm(WasmOverrides::new().with_fuel_limit(1000))
587 .with_docker(DockerOverrides::new().with_image("python:3.11"));
588
589 assert!(overrides.wasm.is_some());
590 assert!(overrides.docker.is_some());
591 assert!(!overrides.is_empty());
592 }
593
594 #[test]
595 fn test_wasm_overrides() {
596 let wasm = WasmOverrides::new()
597 .with_stack_size(2 * 1024 * 1024)
598 .with_fuel_limit(100_000)
599 .enable_capability("filesystem")
600 .disable_capability("network")
601 .with_debug_info();
602
603 assert_eq!(wasm.stack_size, Some(2 * 1024 * 1024));
604 assert_eq!(wasm.fuel_limit, Some(100_000));
605 assert_eq!(wasm.is_capability_enabled("filesystem"), Some(true));
606 assert_eq!(wasm.is_capability_enabled("network"), Some(false));
607 assert!(wasm.debug_info);
608 }
609
610 #[test]
611 fn test_docker_overrides() {
612 let docker = DockerOverrides::new()
613 .with_image("python:3.11-slim")
614 .with_user("1000:1000")
615 .with_no_new_privileges()
616 .drop_all_capabilities()
617 .add_capability("NET_BIND_SERVICE")
618 .with_label("app", "skill-engine");
619
620 assert_eq!(docker.image, Some("python:3.11-slim".to_string()));
621 assert_eq!(docker.user, Some("1000:1000".to_string()));
622 assert!(docker.security_opt.contains(&"no-new-privileges".to_string()));
623 assert!(docker.cap_drop.contains(&"ALL".to_string()));
624 assert!(docker.cap_add.contains(&"NET_BIND_SERVICE".to_string()));
625 }
626
627 #[test]
628 fn test_docker_args() {
629 let docker = DockerOverrides::new()
630 .with_user("1000:1000")
631 .with_all_gpus()
632 .with_init()
633 .with_no_new_privileges();
634
635 let args = docker.to_docker_args();
636
637 assert!(args.contains(&"--rm".to_string()));
638 assert!(args.contains(&"--init".to_string()));
639 assert!(args.contains(&"--user".to_string()));
640 assert!(args.contains(&"--gpus".to_string()));
641 assert!(args.contains(&"--security-opt".to_string()));
642 }
643
644 #[test]
645 fn test_native_overrides() {
646 let native = NativeOverrides::new()
647 .with_working_dir("/app")
648 .with_shell("/bin/bash")
649 .with_path_addition("/custom/bin");
650
651 assert_eq!(native.working_dir, Some("/app".to_string()));
652 assert_eq!(native.shell_or_default(), "/bin/bash");
653 }
654
655 #[test]
656 fn test_native_path_building() {
657 let native = NativeOverrides::new()
658 .with_path_addition("/usr/local/bin")
659 .with_path_addition("/opt/bin");
660
661 let path = native.build_path(Some("/usr/bin"));
662 assert!(path.contains("/usr/local/bin"));
663 assert!(path.contains("/opt/bin"));
664 assert!(path.contains("/usr/bin"));
665 }
666
667 #[test]
668 fn test_runtime_overrides_serialization() {
669 let overrides = RuntimeOverrides::new()
670 .with_wasm(WasmOverrides::new().with_fuel_limit(1000))
671 .with_docker(DockerOverrides::new().with_image("python:3.11"));
672
673 let json = serde_json::to_string(&overrides).unwrap();
674 let deserialized: RuntimeOverrides = serde_json::from_str(&json).unwrap();
675
676 assert!(deserialized.wasm.is_some());
677 assert!(deserialized.docker.is_some());
678 }
679}