Skip to main content

husako_runtime_qjs/
lib.rs

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    /// Plugin module mappings: import specifier → absolute `.js` path.
42    pub plugin_modules: std::collections::HashMap<String, PathBuf>,
43}
44
45/// Extract a meaningful error message from rquickjs errors.
46/// For `Error::Exception`, retrieves the actual JS exception from the context.
47fn 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            // QuickJS OOM may produce "out of memory" or a generic exception
159            // when it can't even allocate the error message.
160            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            // SAFETY: reading the raw pointer value for identity-based cycle detection only
223            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            // SAFETY: reading the raw pointer value for identity-based cycle detection only
244            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    // --- Milestone 3: SDK builder tests (using generated k8s modules) ---
354
355    /// Create a temp dir with generated k8s module files and return (dir, options).
356    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    // --- Milestone 8: Dynamic resources ---
535
536    #[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        // .spec() should win over .resources()
594        assert_eq!(result[0]["spec"]["replicas"], 5);
595        assert!(result[0]["spec"]["template"].is_null());
596    }
597
598    // --- Milestone 8: Safety & Diagnostics ---
599
600    #[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    // --- Helm chart module tests ---
641
642    #[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}