1mod loader;
2mod resolver;
3
4use std::cell::{Cell, RefCell};
5use std::collections::HashSet;
6use std::path::PathBuf;
7use std::rc::Rc;
8use std::time::{Duration, Instant};
9
10use rquickjs::loader::{BuiltinLoader, BuiltinResolver};
11use rquickjs::{Context, Ctx, Error, Function, Module, Runtime, Value};
12
13use loader::HusakoFileLoader;
14use resolver::{HusakoFileResolver, HusakoK8sResolver, PluginResolver};
15
16#[derive(Debug, thiserror::Error)]
17pub enum RuntimeError {
18 #[error("runtime init failed: {0}")]
19 Init(String),
20 #[error("execution failed: {0}")]
21 Execution(String),
22 #[error("build() was not called")]
23 BuildNotCalled,
24 #[error("build() was called {0} times (expected exactly 1)")]
25 BuildCalledMultiple(u32),
26 #[error("strict JSON violation at {path}: {message}")]
27 StrictJson { path: String, message: String },
28 #[error("execution timed out after {0}ms")]
29 Timeout(u64),
30 #[error("heap memory limit exceeded ({0}MB)")]
31 MemoryLimit(usize),
32}
33
34pub struct ExecuteOptions {
35 pub entry_path: PathBuf,
36 pub project_root: PathBuf,
37 pub allow_outside_root: bool,
38 pub timeout_ms: Option<u64>,
39 pub max_heap_mb: Option<usize>,
40 pub generated_types_dir: Option<PathBuf>,
41 pub plugin_modules: std::collections::HashMap<String, PathBuf>,
43}
44
45fn execution_error(ctx: &Ctx<'_>, err: Error) -> RuntimeError {
48 if matches!(err, Error::Exception) {
49 let caught = ctx.catch();
50 if let Some(exc) = caught.as_exception() {
51 let msg = exc.message().unwrap_or_default();
52 let stack = exc.stack().unwrap_or_default();
53 if stack.is_empty() {
54 return RuntimeError::Execution(msg);
55 }
56 return RuntimeError::Execution(format!("{msg}\n{stack}"));
57 }
58 if let Ok(s) = caught.get::<String>() {
59 return RuntimeError::Execution(s);
60 }
61 }
62 RuntimeError::Execution(err.to_string())
63}
64
65pub fn execute(
66 js_source: &str,
67 options: &ExecuteOptions,
68) -> Result<serde_json::Value, RuntimeError> {
69 let rt = Runtime::new().map_err(|e| RuntimeError::Init(e.to_string()))?;
70
71 let timed_out = Rc::new(Cell::new(false));
72 if let Some(ms) = options.timeout_ms {
73 let flag = timed_out.clone();
74 let deadline = Instant::now() + Duration::from_millis(ms);
75 rt.set_interrupt_handler(Some(Box::new(move || {
76 if Instant::now() > deadline {
77 flag.set(true);
78 true
79 } else {
80 false
81 }
82 })));
83 }
84
85 if let Some(mb) = options.max_heap_mb {
86 rt.set_memory_limit(mb * 1024 * 1024);
87 }
88
89 let ctx = Context::full(&rt).map_err(|e| RuntimeError::Init(e.to_string()))?;
90
91 let resolver = (
92 BuiltinResolver::default()
93 .with_module("husako")
94 .with_module("husako/_base"),
95 PluginResolver::new(options.plugin_modules.clone()),
96 HusakoK8sResolver::new(options.generated_types_dir.clone()),
97 HusakoFileResolver::new(
98 &options.project_root,
99 options.allow_outside_root,
100 &options.entry_path,
101 ),
102 );
103 let loader = (
104 BuiltinLoader::default()
105 .with_module("husako", husako_sdk::HUSAKO_MODULE)
106 .with_module("husako/_base", husako_sdk::HUSAKO_BASE),
107 HusakoFileLoader::new(),
108 );
109 rt.set_loader(resolver, loader);
110
111 let result: Rc<RefCell<Option<serde_json::Value>>> = Rc::new(RefCell::new(None));
112 let call_count: Rc<RefCell<u32>> = Rc::new(RefCell::new(0));
113 let capture_error: Rc<RefCell<Option<RuntimeError>>> = Rc::new(RefCell::new(None));
114
115 let eval_result: Result<(), RuntimeError> = ctx.with(|ctx| {
116 let result_clone = result.clone();
117 let count_clone = call_count.clone();
118 let error_clone = capture_error.clone();
119
120 let build_fn = Function::new(ctx.clone(), move |val: Value<'_>| {
121 let mut count = count_clone.borrow_mut();
122 *count += 1;
123 if *count > 1 {
124 return;
125 }
126
127 match validate_and_convert(&val, "$") {
128 Ok(json) => {
129 *result_clone.borrow_mut() = Some(json);
130 }
131 Err(e) => {
132 *error_clone.borrow_mut() = Some(e);
133 }
134 }
135 })
136 .map_err(|e| RuntimeError::Init(e.to_string()))?;
137
138 ctx.globals()
139 .set("__husako_build", build_fn)
140 .map_err(|e| RuntimeError::Init(e.to_string()))?;
141
142 let promise = Module::evaluate(ctx.clone(), "main", js_source)
143 .map_err(|e| execution_error(&ctx, e))?;
144
145 promise
146 .finish::<()>()
147 .map_err(|e| execution_error(&ctx, e))?;
148
149 Ok(())
150 });
151
152 if let Err(err) = eval_result {
153 if timed_out.get() {
154 return Err(RuntimeError::Timeout(options.timeout_ms.unwrap()));
155 }
156 if let Some(mb) = options.max_heap_mb {
157 let msg = err.to_string();
158 if msg.contains("out of memory") || msg.contains("Exception generated by QuickJS") {
161 return Err(RuntimeError::MemoryLimit(mb));
162 }
163 }
164 return Err(err);
165 }
166
167 if let Some(err) = capture_error.borrow_mut().take() {
168 return Err(err);
169 }
170
171 let count = *call_count.borrow();
172 match count {
173 0 => Err(RuntimeError::BuildNotCalled),
174 1 => result
175 .borrow_mut()
176 .take()
177 .ok_or_else(|| RuntimeError::Execution("build() captured no value".into())),
178 n => Err(RuntimeError::BuildCalledMultiple(n)),
179 }
180}
181
182fn validate_and_convert(val: &Value<'_>, path: &str) -> Result<serde_json::Value, RuntimeError> {
183 let mut visited = HashSet::new();
184 convert_value(val, path, &mut visited)
185}
186
187fn convert_value(
188 val: &Value<'_>,
189 path: &str,
190 visited: &mut HashSet<usize>,
191) -> Result<serde_json::Value, RuntimeError> {
192 use rquickjs::Type;
193
194 match val.type_of() {
195 Type::Null => Ok(serde_json::Value::Null),
196 Type::Bool => {
197 let b = val.as_bool().unwrap();
198 Ok(serde_json::Value::Bool(b))
199 }
200 Type::Int => {
201 let n = val.as_int().unwrap();
202 Ok(serde_json::json!(n))
203 }
204 Type::Float => {
205 let n = val.as_float().unwrap();
206 if !n.is_finite() {
207 return Err(RuntimeError::StrictJson {
208 path: path.to_string(),
209 message: format!("non-finite number: {n}"),
210 });
211 }
212 Ok(serde_json::json!(n))
213 }
214 Type::String => {
215 let s: String = val
216 .get()
217 .map_err(|e| RuntimeError::Execution(e.to_string()))?;
218 Ok(serde_json::Value::String(s))
219 }
220 Type::Array => {
221 let arr = val.as_array().unwrap();
222 let ptr = unsafe { val.as_raw().u.ptr as usize };
224 if !visited.insert(ptr) {
225 return Err(RuntimeError::StrictJson {
226 path: path.to_string(),
227 message: "cyclic reference detected".into(),
228 });
229 }
230 let mut vec = Vec::with_capacity(arr.len());
231 for i in 0..arr.len() {
232 let item: Value = arr
233 .get(i)
234 .map_err(|e| RuntimeError::Execution(e.to_string()))?;
235 let item_path = format!("{path}[{i}]");
236 vec.push(convert_value(&item, &item_path, visited)?);
237 }
238 visited.remove(&ptr);
239 Ok(serde_json::Value::Array(vec))
240 }
241 Type::Object => {
242 let obj = val.as_object().unwrap();
243 let ptr = unsafe { val.as_raw().u.ptr as usize };
245 if !visited.insert(ptr) {
246 return Err(RuntimeError::StrictJson {
247 path: path.to_string(),
248 message: "cyclic reference detected".into(),
249 });
250 }
251 let mut map = serde_json::Map::new();
252 for result in obj.props::<String, Value>() {
253 let (key, value) = result.map_err(|e| RuntimeError::Execution(e.to_string()))?;
254 let prop_path = format!("{path}.{key}");
255 map.insert(key, convert_value(&value, &prop_path, visited)?);
256 }
257 visited.remove(&ptr);
258 Ok(serde_json::Value::Object(map))
259 }
260 Type::Undefined => Err(RuntimeError::StrictJson {
261 path: path.to_string(),
262 message: "undefined is not valid JSON".into(),
263 }),
264 Type::Function | Type::Constructor => Err(RuntimeError::StrictJson {
265 path: path.to_string(),
266 message: "function is not valid JSON".into(),
267 }),
268 Type::Symbol => Err(RuntimeError::StrictJson {
269 path: path.to_string(),
270 message: "symbol is not valid JSON".into(),
271 }),
272 Type::BigInt => Err(RuntimeError::StrictJson {
273 path: path.to_string(),
274 message: "bigint is not valid JSON".into(),
275 }),
276 other => Err(RuntimeError::StrictJson {
277 path: path.to_string(),
278 message: format!("{other:?} is not valid JSON"),
279 }),
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn test_options() -> ExecuteOptions {
288 ExecuteOptions {
289 entry_path: PathBuf::from("/tmp/test.ts"),
290 project_root: PathBuf::from("/tmp"),
291 allow_outside_root: false,
292 timeout_ms: None,
293 max_heap_mb: None,
294 generated_types_dir: None,
295 plugin_modules: std::collections::HashMap::new(),
296 }
297 }
298
299 #[test]
300 fn basic_build() {
301 let js = r#"
302 import { build } from "husako";
303 build([{ _render() { return { apiVersion: "v1", kind: "Namespace" }; } }]);
304 "#;
305 let result = execute(js, &test_options()).unwrap();
306 assert!(result.is_array());
307 assert_eq!(result[0]["kind"], "Namespace");
308 }
309
310 #[test]
311 fn no_build_call() {
312 let js = r#"
313 import { build } from "husako";
314 const x = 42;
315 "#;
316 let err = execute(js, &test_options()).unwrap_err();
317 assert!(matches!(err, RuntimeError::BuildNotCalled));
318 }
319
320 #[test]
321 fn double_build_call() {
322 let js = r#"
323 import { build } from "husako";
324 build([]);
325 build([]);
326 "#;
327 let err = execute(js, &test_options()).unwrap_err();
328 assert!(matches!(err, RuntimeError::BuildCalledMultiple(2)));
329 }
330
331 #[test]
332 fn strict_json_undefined() {
333 let js = r#"
334 import { build } from "husako";
335 build({ _render() { return { a: undefined }; } });
336 "#;
337 let err = execute(js, &test_options()).unwrap_err();
338 assert!(matches!(err, RuntimeError::StrictJson { .. }));
339 assert!(err.to_string().contains("undefined"));
340 }
341
342 #[test]
343 fn strict_json_function() {
344 let js = r#"
345 import { build } from "husako";
346 build({ _render() { return { fn: () => {} }; } });
347 "#;
348 let err = execute(js, &test_options()).unwrap_err();
349 assert!(matches!(err, RuntimeError::StrictJson { .. }));
350 assert!(err.to_string().contains("function"));
351 }
352
353 fn test_options_with_k8s() -> (tempfile::TempDir, ExecuteOptions) {
357 let dir = tempfile::tempdir().unwrap();
358 let types_dir = dir.path().join("k8s/apps");
359 std::fs::create_dir_all(&types_dir).unwrap();
360 std::fs::write(
361 types_dir.join("v1.js"),
362 r#"import { _ResourceBuilder } from "husako/_base";
363export class Deployment extends _ResourceBuilder {
364 constructor() { super("apps/v1", "Deployment"); }
365}
366"#,
367 )
368 .unwrap();
369
370 let core_dir = dir.path().join("k8s/core");
371 std::fs::create_dir_all(&core_dir).unwrap();
372 std::fs::write(
373 core_dir.join("v1.js"),
374 r#"import { _ResourceBuilder } from "husako/_base";
375export class Namespace extends _ResourceBuilder {
376 constructor() { super("v1", "Namespace"); }
377}
378export class Service extends _ResourceBuilder {
379 constructor() { super("v1", "Service"); }
380}
381export class ConfigMap extends _ResourceBuilder {
382 constructor() { super("v1", "ConfigMap"); }
383}
384"#,
385 )
386 .unwrap();
387
388 let opts = ExecuteOptions {
389 entry_path: PathBuf::from("/tmp/test.ts"),
390 project_root: PathBuf::from("/tmp"),
391 allow_outside_root: false,
392 timeout_ms: None,
393 max_heap_mb: None,
394 generated_types_dir: Some(dir.path().to_path_buf()),
395 plugin_modules: std::collections::HashMap::new(),
396 };
397 (dir, opts)
398 }
399
400 #[test]
401 fn deployment_builder_basic() {
402 let (_dir, opts) = test_options_with_k8s();
403 let js = r#"
404 import { build, name } from "husako";
405 import { Deployment } from "k8s/apps/v1";
406 const d = new Deployment().metadata(name("test"));
407 build([d]);
408 "#;
409 let result = execute(js, &opts).unwrap();
410 assert_eq!(result[0]["apiVersion"], "apps/v1");
411 assert_eq!(result[0]["kind"], "Deployment");
412 assert_eq!(result[0]["metadata"]["name"], "test");
413 }
414
415 #[test]
416 fn namespace_builder() {
417 let (_dir, opts) = test_options_with_k8s();
418 let js = r#"
419 import { build, name } from "husako";
420 import { Namespace } from "k8s/core/v1";
421 const ns = new Namespace().metadata(name("my-ns"));
422 build([ns]);
423 "#;
424 let result = execute(js, &opts).unwrap();
425 assert_eq!(result[0]["apiVersion"], "v1");
426 assert_eq!(result[0]["kind"], "Namespace");
427 assert_eq!(result[0]["metadata"]["name"], "my-ns");
428 }
429
430 #[test]
431 fn metadata_fragment_immutability() {
432 let (_dir, opts) = test_options_with_k8s();
433 let js = r#"
434 import { build, label } from "husako";
435 import { Deployment } from "k8s/apps/v1";
436 const base = label("env", "dev");
437 const a = base.label("team", "a");
438 const b = base.label("team", "b");
439 const da = new Deployment().metadata(a);
440 const db = new Deployment().metadata(b);
441 build([da, db]);
442 "#;
443 let result = execute(js, &opts).unwrap();
444 let a_labels = &result[0]["metadata"]["labels"];
445 let b_labels = &result[1]["metadata"]["labels"];
446 assert_eq!(a_labels["env"], "dev");
447 assert_eq!(a_labels["team"], "a");
448 assert_eq!(b_labels["env"], "dev");
449 assert_eq!(b_labels["team"], "b");
450 }
451
452 #[test]
453 fn merge_metadata_labels() {
454 let (_dir, opts) = test_options_with_k8s();
455 let js = r#"
456 import { build, name, label, merge } from "husako";
457 import { Deployment } from "k8s/apps/v1";
458 const m = merge([name("test"), label("a", "1"), label("b", "2")]);
459 const d = new Deployment().metadata(m);
460 build([d]);
461 "#;
462 let result = execute(js, &opts).unwrap();
463 assert_eq!(result[0]["metadata"]["name"], "test");
464 assert_eq!(result[0]["metadata"]["labels"]["a"], "1");
465 assert_eq!(result[0]["metadata"]["labels"]["b"], "2");
466 }
467
468 #[test]
469 fn cpu_normalization() {
470 let (_dir, opts) = test_options_with_k8s();
471 let js = r#"
472 import { build, cpu, requests } from "husako";
473 import { Deployment } from "k8s/apps/v1";
474 const d1 = new Deployment().resources(requests(cpu(1)));
475 const d2 = new Deployment().resources(requests(cpu(0.5)));
476 const d3 = new Deployment().resources(requests(cpu("250m")));
477 build([d1, d2, d3]);
478 "#;
479 let result = execute(js, &opts).unwrap();
480 assert_eq!(
481 result[0]["spec"]["template"]["spec"]["containers"][0]["resources"]["requests"]["cpu"],
482 "1"
483 );
484 assert_eq!(
485 result[1]["spec"]["template"]["spec"]["containers"][0]["resources"]["requests"]["cpu"],
486 "500m"
487 );
488 assert_eq!(
489 result[2]["spec"]["template"]["spec"]["containers"][0]["resources"]["requests"]["cpu"],
490 "250m"
491 );
492 }
493
494 #[test]
495 fn memory_normalization() {
496 let (_dir, opts) = test_options_with_k8s();
497 let js = r#"
498 import { build, memory, requests } from "husako";
499 import { Deployment } from "k8s/apps/v1";
500 const d1 = new Deployment().resources(requests(memory(4)));
501 const d2 = new Deployment().resources(requests(memory("512Mi")));
502 build([d1, d2]);
503 "#;
504 let result = execute(js, &opts).unwrap();
505 assert_eq!(
506 result[0]["spec"]["template"]["spec"]["containers"][0]["resources"]["requests"]["memory"],
507 "4Gi"
508 );
509 assert_eq!(
510 result[1]["spec"]["template"]["spec"]["containers"][0]["resources"]["requests"]["memory"],
511 "512Mi"
512 );
513 }
514
515 #[test]
516 fn resources_requests_and_limits() {
517 let (_dir, opts) = test_options_with_k8s();
518 let js = r#"
519 import { build, cpu, memory, requests, limits } from "husako";
520 import { Deployment } from "k8s/apps/v1";
521 const d = new Deployment().resources(
522 requests(cpu(1).memory("2Gi")).limits(cpu("500m").memory(1))
523 );
524 build([d]);
525 "#;
526 let result = execute(js, &opts).unwrap();
527 let res = &result[0]["spec"]["template"]["spec"]["containers"][0]["resources"];
528 assert_eq!(res["requests"]["cpu"], "1");
529 assert_eq!(res["requests"]["memory"], "2Gi");
530 assert_eq!(res["limits"]["cpu"], "500m");
531 assert_eq!(res["limits"]["memory"], "1Gi");
532 }
533
534 #[test]
537 fn k8s_import_without_generate_fails() {
538 let js = r#"
539 import { build } from "husako";
540 import { Deployment } from "k8s/apps/v1";
541 build([new Deployment()]);
542 "#;
543 let err = execute(js, &test_options()).unwrap_err();
544 assert!(err.to_string().contains("husako generate"));
545 }
546
547 #[test]
548 fn spec_generic_setter() {
549 let (_dir, opts) = test_options_with_k8s();
550 let js = r#"
551 import { build, name } from "husako";
552 import { Deployment } from "k8s/apps/v1";
553 const d = new Deployment()
554 .metadata(name("test"))
555 .spec({ replicas: 3, selector: { matchLabels: { app: "test" } } });
556 build([d]);
557 "#;
558 let result = execute(js, &opts).unwrap();
559 assert_eq!(result[0]["spec"]["replicas"], 3);
560 assert_eq!(result[0]["spec"]["selector"]["matchLabels"]["app"], "test");
561 }
562
563 #[test]
564 fn set_generic_top_level() {
565 let (_dir, opts) = test_options_with_k8s();
566 let js = r#"
567 import { build, name } from "husako";
568 import { ConfigMap } from "k8s/core/v1";
569 const cm = new ConfigMap()
570 .metadata(name("my-config"))
571 .set("data", { key1: "val1", key2: "val2" });
572 build([cm]);
573 "#;
574 let result = execute(js, &opts).unwrap();
575 assert_eq!(result[0]["kind"], "ConfigMap");
576 assert_eq!(result[0]["data"]["key1"], "val1");
577 assert_eq!(result[0]["data"]["key2"], "val2");
578 }
579
580 #[test]
581 fn spec_overrides_resources() {
582 let (_dir, opts) = test_options_with_k8s();
583 let js = r#"
584 import { build, name, cpu, requests } from "husako";
585 import { Deployment } from "k8s/apps/v1";
586 const d = new Deployment()
587 .metadata(name("test"))
588 .resources(requests(cpu(1)))
589 .spec({ replicas: 5 });
590 build([d]);
591 "#;
592 let result = execute(js, &opts).unwrap();
593 assert_eq!(result[0]["spec"]["replicas"], 5);
595 assert!(result[0]["spec"]["template"].is_null());
596 }
597
598 #[test]
601 fn timeout_infinite_loop() {
602 let js = r#"
603 import { build } from "husako";
604 while(true) {}
605 build([]);
606 "#;
607 let mut opts = test_options();
608 opts.timeout_ms = Some(100);
609 let err = execute(js, &opts).unwrap_err();
610 assert!(matches!(err, RuntimeError::Timeout(100)));
611 }
612
613 #[test]
614 fn memory_limit_exceeded() {
615 let js = r#"
616 import { build } from "husako";
617 const arr = [];
618 for (let i = 0; i < 10000000; i++) { arr.push(new Array(1000)); }
619 build([]);
620 "#;
621 let mut opts = test_options();
622 opts.max_heap_mb = Some(1);
623 let err = execute(js, &opts).unwrap_err();
624 assert!(matches!(err, RuntimeError::MemoryLimit(1)));
625 }
626
627 #[test]
628 fn limits_do_not_interfere_with_normal_execution() {
629 let js = r#"
630 import { build } from "husako";
631 build([{ _render() { return { apiVersion: "v1", kind: "Namespace" }; } }]);
632 "#;
633 let mut opts = test_options();
634 opts.timeout_ms = Some(5000);
635 opts.max_heap_mb = Some(256);
636 let result = execute(js, &opts).unwrap();
637 assert_eq!(result[0]["kind"], "Namespace");
638 }
639
640 #[test]
643 fn helm_import_without_generate_fails() {
644 let js = r#"
645 import { build } from "husako";
646 import { values } from "helm/my-chart";
647 build([]);
648 "#;
649 let err = execute(js, &test_options()).unwrap_err();
650 assert!(err.to_string().contains("husako generate"));
651 }
652
653 #[test]
654 fn helm_import_with_generated_module() {
655 let dir = tempfile::tempdir().unwrap();
656 let helm_dir = dir.path().join("helm");
657 std::fs::create_dir_all(&helm_dir).unwrap();
658 std::fs::write(
659 helm_dir.join("my-chart.js"),
660 r#"import { _SchemaBuilder } from "husako/_base";
661export class Values extends _SchemaBuilder {
662 replicaCount(v) { return this._set("replicaCount", v); }
663}
664export function values() { return new Values(); }
665"#,
666 )
667 .unwrap();
668
669 let opts = ExecuteOptions {
670 entry_path: PathBuf::from("/tmp/test.ts"),
671 project_root: PathBuf::from("/tmp"),
672 allow_outside_root: false,
673 timeout_ms: None,
674 max_heap_mb: None,
675 generated_types_dir: Some(dir.path().to_path_buf()),
676 plugin_modules: std::collections::HashMap::new(),
677 };
678
679 let js = r#"
680 import { build } from "husako";
681 import { values } from "helm/my-chart";
682 const v = values().replicaCount(3);
683 build([{ _render() { return v._toJSON(); } }]);
684 "#;
685
686 let result = execute(js, &opts).unwrap();
687 assert_eq!(result[0]["replicaCount"], 3);
688 }
689}