diff --git a/Cargo.lock b/Cargo.lock index 1b3156bc4e..60f594156d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2866,7 +2866,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -3672,6 +3672,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -4236,6 +4245,30 @@ dependencies = [ "perry-hir", ] +[[package]] +name = "perry-container-compose" +version = "0.5.166" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "console", + "dashmap 6.1.0", + "dialoguer", + "hex", + "indexmap", + "md-5", + "proptest", + "rand 0.8.5", + "serde", + "serde_json", + "serde_yaml", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "perry-diagnostics" version = "0.5.166" @@ -4264,7 +4297,6 @@ version = "0.5.166" dependencies = [ "anyhow", "perry-diagnostics", - "perry-parser", "perry-types", "swc_common", "swc_ecma_ast", @@ -4360,6 +4392,7 @@ dependencies = [ "nanoid", "once_cell", "pbkdf2", + "perry-container-compose", "perry-runtime", "rand 0.8.5", "redis", @@ -4839,6 +4872,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.11.0", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "psm" version = "0.1.30" @@ -4899,6 +4951,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -5052,6 +5110,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -5096,7 +5163,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -5506,6 +5573,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -5773,6 +5852,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "servo_arc" version = "0.3.0" @@ -5810,6 +5902,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -6574,6 +6675,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.11.3" @@ -6583,7 +6693,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg", ] @@ -6953,6 +7063,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -7037,6 +7173,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -7110,6 +7252,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -7234,6 +7382,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -7252,6 +7406,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 34f2c6483b..e3747e4385 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/perry-ui-test", "crates/perry-ui-testkit", "crates/perry-doc-tests", + "crates/perry-container-compose", ] # Only build platform-independent crates by default. # Platform-specific UI crates (perry-ui-macos, perry-ui-ios, etc.) must be built @@ -49,6 +50,7 @@ default-members = [ "crates/perry-codegen-glance", "crates/perry-codegen-wear-tiles", "crates/perry-codegen-wasm", + "crates/perry-container-compose", ] # Aggressive release optimizations for small, fast binaries @@ -179,3 +181,4 @@ perry-codegen-glance = { path = "crates/perry-codegen-glance" } perry-codegen-wear-tiles = { path = "crates/perry-codegen-wear-tiles" } perry-codegen-wasm = { path = "crates/perry-codegen-wasm" } perry-ui-testkit = { path = "crates/perry-ui-testkit" } +perry-container-compose = { path = "crates/perry-container-compose" } diff --git a/crates/perry-codegen/src/lower_call.rs b/crates/perry-codegen/src/lower_call.rs index e9bf7fc284..1b79c9c3d1 100644 --- a/crates/perry-codegen/src/lower_call.rs +++ b/crates/perry-codegen/src/lower_call.rs @@ -4130,6 +4130,8 @@ fn lower_perry_ui_table_call( enum NativeArgKind { /// NaN-boxed f64 — pass as-is (objects, generic JSValues). F64, + /// NaN-boxed object → serialize to JSON string and pass raw i64 pointer. + Json, /// NaN-boxed string → extract raw i64 pointer via js_get_string_pointer_unified. /// Use for Rust signatures like `*const StringHeader`. StrPtr, @@ -4178,6 +4180,7 @@ struct NativeModSig { // Short aliases to keep the table compact without wildcard imports // (wildcard would clash with crate::types::* names like I64, DOUBLE). const NA_F64: NativeArgKind = NativeArgKind::F64; +const NA_JSON: NativeArgKind = NativeArgKind::Json; const NA_STR: NativeArgKind = NativeArgKind::StrPtr; const NA_PTR: NativeArgKind = NativeArgKind::PtrI64; const NA_JSV: NativeArgKind = NativeArgKind::JsvalI64; @@ -4695,6 +4698,83 @@ const NATIVE_MODULE_TABLE: &[NativeModSig] = &[ NativeModSig { module: "bcrypt", has_receiver: false, method: "compare", class_filter: None, runtime: "js_bcrypt_compare", args: &[NA_F64, NA_F64], ret: NR_PTR }, + + // ========== perry/container ========== + NativeModSig { module: "perry/container", has_receiver: false, method: "run", + class_filter: None, + runtime: "js_container_run", args: &[NA_JSON], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "create", + class_filter: None, + runtime: "js_container_create", args: &[NA_JSON], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "start", + class_filter: None, + runtime: "js_container_start", args: &[NA_STR], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "stop", + class_filter: None, + runtime: "js_container_stop", args: &[NA_STR, NA_F64], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "remove", + class_filter: None, + runtime: "js_container_remove", args: &[NA_STR, NA_F64], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "list", + class_filter: None, + runtime: "js_container_list", args: &[NA_F64], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "inspect", + class_filter: None, + runtime: "js_container_inspect", args: &[NA_STR], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "logs", + class_filter: None, + runtime: "js_container_logs", args: &[NA_STR, NA_F64], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "exec", + class_filter: None, + runtime: "js_container_exec", args: &[NA_STR, NA_JSON, NA_JSON, NA_STR], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "pullImage", + class_filter: None, + runtime: "js_container_pull_image", args: &[NA_STR], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "listImages", + class_filter: None, + runtime: "js_container_list_images", args: &[], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "removeImage", + class_filter: None, + runtime: "js_container_remove_image", args: &[NA_STR, NA_F64], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "getBackend", + class_filter: None, + runtime: "js_container_get_backend", args: &[], ret: NR_STR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "detectBackend", + class_filter: None, + runtime: "js_container_detect_backend", args: &[], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: false, method: "composeUp", + class_filter: None, + runtime: "js_container_compose_up", args: &[NA_JSON], ret: NR_PTR }, + + // ========== perry/container-compose / perry/compose ========== + NativeModSig { module: "perry/container-compose", has_receiver: false, method: "up", + class_filter: None, + runtime: "js_container_compose_up", args: &[NA_JSON], ret: NR_PTR }, + NativeModSig { module: "perry/compose", has_receiver: false, method: "up", + class_filter: None, + runtime: "js_container_compose_up", args: &[NA_JSON], ret: NR_PTR }, + + // ComposeHandle instance methods + NativeModSig { module: "perry/container", has_receiver: true, method: "down", + class_filter: Some("ComposeHandle"), + runtime: "js_container_compose_down", args: &[NA_F64], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: true, method: "ps", + class_filter: Some("ComposeHandle"), + runtime: "js_container_compose_ps", args: &[], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: true, method: "logs", + class_filter: Some("ComposeHandle"), + runtime: "js_container_compose_logs", args: &[NA_JSON], ret: NR_PTR }, + NativeModSig { module: "perry/container", has_receiver: true, method: "exec", + class_filter: Some("ComposeHandle"), + runtime: "js_container_compose_exec", args: &[NA_STR, NA_JSON, NA_JSON, NA_STR], ret: NR_PTR }, + + // ========== perry/workloads ========== + NativeModSig { module: "perry/workloads", has_receiver: false, method: "runGraph", + class_filter: None, + runtime: "js_workload_run_graph", args: &[NA_JSON, NA_JSON], ret: NR_PTR }, + NativeModSig { module: "perry/workloads", has_receiver: false, method: "inspectGraph", + class_filter: None, + runtime: "js_workload_inspect_graph", args: &[NA_JSON], ret: NR_PTR }, ]; /// Look up a native module method in the static dispatch table. @@ -4747,6 +4827,13 @@ fn lower_native_module_dispatch( llvm_args.push((DOUBLE, lowered)); arg_types.push(DOUBLE); } + NativeArgKind::Json => { + let blk = ctx.block(); + let json_box = blk.call(DOUBLE, "js_json_stringify", &[(DOUBLE, &lowered)]); + let ptr = blk.call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &json_box)]); + llvm_args.push((I64, ptr)); + arg_types.push(I64); + } NativeArgKind::StrPtr => { let blk = ctx.block(); let ptr = blk.call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &lowered)]); diff --git a/crates/perry-container-compose/Cargo.toml b/crates/perry-container-compose/Cargo.toml new file mode 100644 index 0000000000..500da9c12a --- /dev/null +++ b/crates/perry-container-compose/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "perry-container-compose" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Native Rust reimplementation of container-compose functionality" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +serde_yaml = "0.9" +tokio = { workspace = true, features = ["full"] } +async-trait = "0.1" +tracing = "0.1" +clap = { workspace = true, features = ["derive"] } +md-5 = "0.10" +thiserror.workspace = true +anyhow.workspace = true +indexmap = { version = "2.2", features = ["serde"] } +hex = "0.4" +rand = "0.8" +dashmap = "6.0" + +# Optional for installer +dialoguer = { workspace = true, optional = true } +console = { workspace = true, optional = true } +tracing-subscriber = "0.3.23" + +[dev-dependencies] +proptest = "1.4" + +[features] +default = [] +installer = ["dep:dialoguer", "dep:console"] + +[[test]] +name = "compose_engine_test" +path = "tests/functional/compose_engine_test.rs" +integration = [] +testing = [] diff --git a/crates/perry-container-compose/src/backend.rs b/crates/perry-container-compose/src/backend.rs new file mode 100644 index 0000000000..490887a137 --- /dev/null +++ b/crates/perry-container-compose/src/backend.rs @@ -0,0 +1,468 @@ +use async_trait::async_trait; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; +use crate::error::{ComposeError, BackendProbeResult}; +use crate::types::*; +use crate::installer::BackendInstaller; + +#[async_trait] +pub trait ContainerBackend: Send + Sync { + fn backend_name(&self) -> &str; + async fn check_available(&self) -> Result<(), ComposeError>; + async fn run(&self, spec: &ContainerSpec) -> Result; // Returns container ID + async fn create(&self, spec: &ContainerSpec) -> Result; + async fn start(&self, id: &str) -> Result<(), ComposeError>; + async fn stop(&self, id: &str, timeout: Option) -> Result<(), ComposeError>; + async fn remove(&self, id: &str, force: bool) -> Result<(), ComposeError>; + async fn list(&self, all: bool) -> Result, ComposeError>; + async fn inspect(&self, id: &str) -> Result; + async fn logs(&self, id: &str, tail: Option) -> Result; + async fn exec( + &self, + id: &str, + cmd: &[String], + env: Option<&HashMap>, + workdir: Option<&str>, + ) -> Result; + async fn build(&self, spec: &ComposeServiceBuild, image_name: &str) -> Result<(), ComposeError>; + async fn pull_image(&self, reference: &str) -> Result<(), ComposeError>; + async fn list_images(&self) -> Result, ComposeError>; + async fn remove_image(&self, reference: &str, force: bool) -> Result<(), ComposeError>; + async fn create_network(&self, name: &str, config: Option<&serde_json::Value>) -> Result<(), ComposeError>; + async fn remove_network(&self, name: &str) -> Result<(), ComposeError>; + async fn create_volume(&self, name: &str, config: Option<&serde_json::Value>) -> Result<(), ComposeError>; + async fn remove_volume(&self, name: &str) -> Result<(), ComposeError>; +} + +pub trait CliProtocol: Send + Sync { + fn name(&self) -> &str; + fn build_run_args(&self, spec: &ContainerSpec) -> Vec; + fn build_create_args(&self, spec: &ContainerSpec) -> Vec; + fn build_start_args(&self, id: &str) -> Vec; + fn build_stop_args(&self, id: &str, timeout: Option) -> Vec; + fn build_remove_args(&self, id: &str, force: bool) -> Vec; + fn build_list_args(&self, all: bool) -> Vec; + fn build_inspect_args(&self, id: &str) -> Vec; + fn build_logs_args(&self, id: &str, tail: Option) -> Vec; + fn build_exec_args( + &self, + id: &str, + cmd: &[String], + env: Option<&HashMap>, + workdir: Option<&str>, + ) -> Vec; + fn build_build_args(&self, spec: &ComposeServiceBuild, image_name: &str) -> Vec; + fn build_pull_args(&self, reference: &str) -> Vec; + fn build_list_images_args(&self) -> Vec; + fn build_remove_image_args(&self, reference: &str, force: bool) -> Vec; + fn build_create_network_args(&self, name: &str, config: Option<&serde_json::Value>) -> Vec; + fn build_remove_network_args(&self, name: &str) -> Vec; + fn build_create_volume_args(&self, name: &str, config: Option<&serde_json::Value>) -> Vec; + fn build_remove_volume_args(&self, name: &str) -> Vec; +} + +pub struct DockerProtocol; + +impl CliProtocol for DockerProtocol { + fn name(&self) -> &str { "docker" } + fn build_run_args(&self, spec: &ContainerSpec) -> Vec { + let mut args = vec!["run".to_string(), "-d".to_string()]; + if let Some(name) = &spec.name { + args.push("--name".to_string()); + args.push(name.clone()); + } + if let Some(ports) = &spec.ports { + for p in ports { + args.push("-p".to_string()); + args.push(p.clone()); + } + } + if let Some(volumes) = &spec.volumes { + for v in volumes { + args.push("-v".to_string()); + args.push(v.clone()); + } + } + if let Some(env) = &spec.env { + for (k, v) in env { + args.push("-e".to_string()); + args.push(format!("{}={}", k, v)); + } + } + if let Some(network) = &spec.network { + args.push("--network".to_string()); + args.push(network.clone()); + } + if spec.rm.unwrap_or(false) { + args.push("--rm".to_string()); + } + if let Some(entrypoint) = &spec.entrypoint { + args.push("--entrypoint".to_string()); + args.push(entrypoint[0].clone()); // Simplified + } + args.push(spec.image.clone()); + if let Some(cmd) = &spec.cmd { + args.extend(cmd.clone()); + } + args + } + fn build_create_args(&self, spec: &ContainerSpec) -> Vec { + let mut args = vec!["create".to_string()]; + if let Some(name) = &spec.name { + args.push("--name".to_string()); + args.push(name.clone()); + } + args.push(spec.image.clone()); + args + } + fn build_start_args(&self, id: &str) -> Vec { vec!["start".to_string(), id.to_string()] } + fn build_stop_args(&self, id: &str, timeout: Option) -> Vec { + let mut args = vec!["stop".to_string()]; + if let Some(t) = timeout { + args.push("-t".to_string()); + args.push(t.to_string()); + } + args.push(id.to_string()); + args + } + fn build_remove_args(&self, id: &str, force: bool) -> Vec { + let mut args = vec!["rm".to_string()]; + if force { args.push("-f".to_string()); } + args.push(id.to_string()); + args + } + fn build_list_args(&self, all: bool) -> Vec { + let mut args = vec!["ps".to_string(), "--format".to_string(), "json".to_string()]; + if all { args.push("-a".to_string()); } + args + } + fn build_inspect_args(&self, id: &str) -> Vec { + vec!["inspect".to_string(), id.to_string()] + } + fn build_logs_args(&self, id: &str, tail: Option) -> Vec { + let mut args = vec!["logs".to_string()]; + if let Some(n) = tail { + args.push("--tail".to_string()); + args.push(n.to_string()); + } + args.push(id.to_string()); + args + } + fn build_exec_args(&self, id: &str, cmd: &[String], env: Option<&HashMap>, workdir: Option<&str>) -> Vec { + let mut args = vec!["exec".to_string()]; + if let Some(e) = env { + for (k, v) in e { + args.push("-e".to_string()); + args.push(format!("{}={}", k, v)); + } + } + if let Some(w) = workdir { + args.push("-w".to_string()); + args.push(w.to_string()); + } + args.push(id.to_string()); + args.extend(cmd.iter().cloned()); + args + } + fn build_build_args(&self, spec: &ComposeServiceBuild, image_name: &str) -> Vec { + let mut args = vec!["build".to_string(), "-t".to_string(), image_name.to_string()]; + if let Some(f) = &spec.containerfile { + args.push("-f".to_string()); + args.push(f.clone()); + } + args.push(spec.context.clone()); + args + } + fn build_pull_args(&self, reference: &str) -> Vec { vec!["pull".to_string(), reference.to_string()] } + fn build_list_images_args(&self) -> Vec { vec!["images".to_string(), "--format".to_string(), "json".to_string()] } + fn build_remove_image_args(&self, reference: &str, force: bool) -> Vec { + let mut args = vec!["rmi".to_string()]; + if force { args.push("-f".to_string()); } + args.push(reference.to_string()); + args + } + fn build_create_network_args(&self, name: &str, _config: Option<&serde_json::Value>) -> Vec { + vec!["network".to_string(), "create".to_string(), name.to_string()] + } + fn build_remove_network_args(&self, name: &str) -> Vec { + vec!["network".to_string(), "rm".to_string(), name.to_string()] + } + fn build_create_volume_args(&self, name: &str, _config: Option<&serde_json::Value>) -> Vec { + vec!["volume".to_string(), "create".to_string(), name.to_string()] + } + fn build_remove_volume_args(&self, name: &str) -> Vec { + vec!["volume".to_string(), "rm".to_string(), name.to_string()] + } +} + +pub struct AppleContainerProtocol; +impl CliProtocol for AppleContainerProtocol { + fn name(&self) -> &str { "apple-container" } + fn build_run_args(&self, _spec: &ContainerSpec) -> Vec { vec![] } + fn build_create_args(&self, _spec: &ContainerSpec) -> Vec { vec![] } + fn build_start_args(&self, _id: &str) -> Vec { vec![] } + fn build_stop_args(&self, _id: &str, _timeout: Option) -> Vec { vec![] } + fn build_remove_args(&self, _id: &str, _force: bool) -> Vec { vec![] } + fn build_list_args(&self, _all: bool) -> Vec { vec![] } + fn build_inspect_args(&self, _id: &str) -> Vec { vec![] } + fn build_logs_args(&self, _id: &str, _tail: Option) -> Vec { vec![] } + fn build_exec_args(&self, _id: &str, _cmd: &[String], _env: Option<&HashMap>, _workdir: Option<&str>) -> Vec { vec![] } + fn build_build_args(&self, _spec: &ComposeServiceBuild, _image_name: &str) -> Vec { vec![] } + fn build_pull_args(&self, _reference: &str) -> Vec { vec![] } + fn build_list_images_args(&self) -> Vec { vec![] } + fn build_remove_image_args(&self, _reference: &str, _force: bool) -> Vec { vec![] } + fn build_create_network_args(&self, _name: &str, _config: Option<&serde_json::Value>) -> Vec { vec![] } + fn build_remove_network_args(&self, _name: &str) -> Vec { vec![] } + fn build_create_volume_args(&self, _name: &str, _config: Option<&serde_json::Value>) -> Vec { vec![] } + fn build_remove_volume_args(&self, _name: &str) -> Vec { vec![] } +} + +pub struct CliBackend { + pub name: String, + pub bin: PathBuf, + pub protocol: Box, +} + +#[async_trait] +impl ContainerBackend for CliBackend { + fn backend_name(&self) -> &str { &self.name } + async fn check_available(&self) -> Result<(), ComposeError> { + let output = tokio::process::Command::new(&self.bin) + .arg("--version") + .output() + .await + .map_err(|e| ComposeError::BackendNotAvailable { name: self.name.clone(), reason: e.to_string() })?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendNotAvailable { name: self.name.clone(), reason: "Version check failed".to_string() }) + } + } + async fn run(&self, spec: &ContainerSpec) -> Result { + let args = self.protocol.build_run_args(spec); + let output = tokio::process::Command::new(&self.bin) + .args(&args) + .output() + .await + .map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn create(&self, spec: &ContainerSpec) -> Result { + let args = self.protocol.build_create_args(spec); + let output = tokio::process::Command::new(&self.bin) + .args(&args) + .output() + .await + .map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn start(&self, id: &str) -> Result<(), ComposeError> { + let args = self.protocol.build_start_args(id); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn stop(&self, id: &str, timeout: Option) -> Result<(), ComposeError> { + let args = self.protocol.build_stop_args(id, timeout); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn remove(&self, id: &str, force: bool) -> Result<(), ComposeError> { + let args = self.protocol.build_remove_args(id, force); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn list(&self, all: bool) -> Result, ComposeError> { + let args = self.protocol.build_list_args(all); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { + Ok(vec![]) + } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn inspect(&self, id: &str) -> Result { + let args = self.protocol.build_inspect_args(id); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { + Ok(ContainerInfo { id: id.to_string(), name: id.to_string(), image: "".into(), status: "running".into(), ports: vec![], created: "".into() }) + } else { + Err(ComposeError::NotFound(id.to_string())) + } + } + async fn logs(&self, id: &str, tail: Option) -> Result { + let args = self.protocol.build_logs_args(id, tail); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + Ok(ContainerLogs { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } + async fn exec(&self, id: &str, cmd: &[String], env: Option<&HashMap>, workdir: Option<&str>) -> Result { + let args = self.protocol.build_exec_args(id, cmd, env, workdir); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + Ok(ContainerLogs { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } + async fn build(&self, spec: &ComposeServiceBuild, image_name: &str) -> Result<(), ComposeError> { + let args = self.protocol.build_build_args(spec, image_name); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn pull_image(&self, reference: &str) -> Result<(), ComposeError> { + let args = self.protocol.build_pull_args(reference); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn list_images(&self) -> Result, ComposeError> { + let args = self.protocol.build_list_images_args(); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(vec![]) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn remove_image(&self, reference: &str, force: bool) -> Result<(), ComposeError> { + let args = self.protocol.build_remove_image_args(reference, force); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn create_network(&self, name: &str, config: Option<&serde_json::Value>) -> Result<(), ComposeError> { + let args = self.protocol.build_create_network_args(name, config); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn remove_network(&self, name: &str) -> Result<(), ComposeError> { + let args = self.protocol.build_remove_network_args(name); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn create_volume(&self, name: &str, config: Option<&serde_json::Value>) -> Result<(), ComposeError> { + let args = self.protocol.build_create_volume_args(name, config); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } + async fn remove_volume(&self, name: &str) -> Result<(), ComposeError> { + let args = self.protocol.build_remove_volume_args(name); + let output = tokio::process::Command::new(&self.bin).args(&args).output().await.map_err(|e| ComposeError::Other(e.to_string()))?; + if output.status.success() { Ok(()) } else { + Err(ComposeError::BackendError { code: output.status.code().unwrap_or(-1), message: String::from_utf8_lossy(&output.stderr).to_string() }) + } + } +} + +pub enum BackendDriver { + AppleContainer { bin: PathBuf }, + Orbstack { bin: PathBuf }, + Colima { bin: PathBuf }, + RancherDesktop { bin: PathBuf }, + Lima { bin: PathBuf }, + Podman { bin: PathBuf }, + Nerdctl { bin: PathBuf }, + Docker { bin: PathBuf }, +} + +impl BackendDriver { + pub fn name(&self) -> &str { + match self { + BackendDriver::AppleContainer { .. } => "apple/container", + BackendDriver::Orbstack { .. } => "orbstack", + BackendDriver::Colima { .. } => "colima", + BackendDriver::RancherDesktop { .. } => "rancher-desktop", + BackendDriver::Lima { .. } => "lima", + BackendDriver::Podman { .. } => "podman", + BackendDriver::Nerdctl { .. } => "nerdctl", + BackendDriver::Docker { .. } => "docker", + } + } +} + +pub async fn detect_backend() -> Result, ComposeError> { + if let Ok(val) = std::env::var("PERRY_CONTAINER_BACKEND") { + let bin = PathBuf::from(&val); + let protocol: Box = if val.contains("apple") { Box::new(AppleContainerProtocol) } else { Box::new(DockerProtocol) }; + let backend = Arc::new(CliBackend { name: val.clone(), bin, protocol }); + backend.check_available().await?; + return Ok(backend); + } + + let candidates = platform_candidates(); + let mut probed = Vec::new(); + + for driver in candidates { + match probe_candidate(&driver).await { + Ok(backend) => return Ok(backend), + Err(e) => probed.push(BackendProbeResult { + name: driver.name().to_string(), + available: false, + reason: e.to_string(), + version: None, + mode: "local".into(), + isolation_level: IsolationLevel::Container, + }), + } + } + + if let Ok(backend) = BackendInstaller::run().await { + return Ok(backend); + } + + Err(ComposeError::NoBackendFound { probed }) +} + +fn platform_candidates() -> Vec { + vec![ + BackendDriver::Podman { bin: "podman".into() }, + BackendDriver::Docker { bin: "docker".into() }, + ] +} + +async fn probe_candidate(driver: &BackendDriver) -> Result, ComposeError> { + let bin = match driver { + BackendDriver::Podman { bin } => bin, + BackendDriver::Docker { bin } => bin, + _ => return Err(ComposeError::Other("Unsupported driver".into())), + }; + let protocol: Box = Box::new(DockerProtocol); + let backend = Arc::new(CliBackend { name: driver.name().to_string(), bin: bin.clone(), protocol }); + backend.check_available().await?; + Ok(backend) +} + +static GLOBAL_BACKEND: Mutex>> = Mutex::const_new(None); + +pub async fn get_global_backend_instance() -> Result, ComposeError> { + let mut lock = GLOBAL_BACKEND.lock().await; + if let Some(backend) = &*lock { + return Ok(backend.clone()); + } + let backend = detect_backend().await?; + *lock = Some(backend.clone()); + Ok(backend) +} diff --git a/crates/perry-container-compose/src/cli.rs b/crates/perry-container-compose/src/cli.rs new file mode 100644 index 0000000000..a19e7c0723 --- /dev/null +++ b/crates/perry-container-compose/src/cli.rs @@ -0,0 +1,36 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "perry-container-compose")] +#[command(about = "Native Rust reimplementation of container-compose", long_about = None)] +pub struct Cli { + #[arg(short, long, value_name = "FILE")] + pub file: Vec, + + #[arg(short, long)] + pub project_name: Option, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + Up { + #[arg(short, long)] + detach: bool, + }, + Down { + #[arg(short, long)] + volumes: bool, + }, + Ps, + Logs { + #[arg(short, long)] + follow: bool, + #[arg(long)] + tail: Option, + services: Vec, + }, +} diff --git a/crates/perry-container-compose/src/compose.rs b/crates/perry-container-compose/src/compose.rs new file mode 100644 index 0000000000..76b2859ca1 --- /dev/null +++ b/crates/perry-container-compose/src/compose.rs @@ -0,0 +1,141 @@ +use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; +use indexmap::IndexMap; +use crate::types::*; +use crate::service::Service; +use crate::backend::{ContainerBackend, get_global_backend_instance}; +use crate::error::ComposeError; +use crate::orchestrate::orchestrate_service; + +pub struct ComposeEngine { + pub backend: Arc, + pub services: IndexMap, +} + +impl ComposeEngine { + pub async fn new(spec: ComposeSpec) -> Result { + let backend = get_global_backend_instance().await?; + let services = spec.services.into_iter().map(|(k, v)| (k, Service::from(v))).collect(); + Ok(Self { backend, services }) + } + + pub async fn up(&self) -> Result<(), ComposeError> { + let order = self.services.keys().cloned().collect::>(); // Simplified for now + for name in order { + if let Some(service) = self.services.get(&name) { + orchestrate_service(&name, service, self.backend.as_ref()).await?; + } + } + Ok(()) + } + + pub fn resolve_startup_order(&self) -> Result, ComposeError> { + Ok(self.services.keys().cloned().collect()) + } +} + +pub fn resolve_startup_order(spec: &ComposeSpec) -> Result, ComposeError> { + let mut order = Vec::new(); + let mut visited = BTreeSet::new(); + let mut temp_visited = BTreeSet::new(); + + fn visit( + name: &str, + spec: &ComposeSpec, + order: &mut Vec, + visited: &mut BTreeSet, + temp_visited: &mut BTreeSet, + ) -> Result<(), ComposeError> { + if temp_visited.contains(name) { + return Err(ComposeError::DependencyCycle(name.to_string())); + } + if visited.contains(name) { + return Ok(()); + } + + temp_visited.insert(name.to_string()); + + if let Some(service) = spec.services.get(name) { + if let Some(depends_on) = &service.depends_on { + let deps = match depends_on { + DependsOn::List(l) => l.clone(), + DependsOn::Dict(d) => d.keys().cloned().collect(), + }; + for dep in deps { + visit(&dep, spec, order, visited, temp_visited)?; + } + } + } + + temp_visited.remove(name); + visited.insert(name.to_string()); + order.push(name.to_string()); + Ok(()) + } + + for name in spec.services.keys() { + visit(name, spec, &mut order, &mut visited, &mut temp_visited)?; + } + + Ok(order) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use crate::types::*; + use indexmap::IndexMap; + use std::collections::BTreeSet; + + proptest! { + // Feature: alloy-container, Property 7: Topological sort produces valid ordering + #[test] + fn prop_topological_sort_valid(services in any::>()) { + let mut spec = ComposeSpec { + name: None, + version: None, + services: IndexMap::new(), + networks: None, + volumes: None, + secrets: None, + configs: None, + }; + + let mut unique_names = BTreeSet::new(); + for s in services { + if unique_names.insert(s.clone()) && !s.is_empty() { + spec.services.insert(s, ComposeService { + image: Some("alpine".into()), + build: None, + command: None, + entrypoint: None, + environment: None, + env_file: None, + ports: None, + volumes: None, + networks: None, + depends_on: None, + healthcheck: None, + deploy: None, + logging: None, + container_name: None, + labels: None, + extra_hosts: None, + sysctls: None, + read_only: None, + isolation_level: None, + }); + } + } + + if spec.services.is_empty() { return Ok(()); } + + let order = resolve_startup_order(&spec).unwrap(); + assert_eq!(order.len(), spec.services.len()); + for name in spec.services.keys() { + assert!(order.contains(name)); + } + } + } +} diff --git a/crates/perry-container-compose/src/error.rs b/crates/perry-container-compose/src/error.rs new file mode 100644 index 0000000000..2d58f14204 --- /dev/null +++ b/crates/perry-container-compose/src/error.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use crate::types::IsolationLevel; + +#[derive(Debug, Error, Serialize, Deserialize, Clone)] +#[serde(tag = "type", content = "data")] +pub enum ComposeError { + #[error("Not found: {0}")] + NotFound(String), + + #[error("Backend error (code {code}): {message}")] + BackendError { code: i32, message: String }, + + #[error("Verification failed for image {image}: {reason}")] + VerificationFailed { image: String, reason: String }, + + #[error("Dependency cycle detected: {0}")] + DependencyCycle(String), + + #[error("Service {service} failed to start: {error}")] + ServiceStartupFailed { service: String, error: String }, + + #[error("Invalid configuration: {0}")] + InvalidConfig(String), + + #[error("No container backend found. Probed: {probed:?}")] + NoBackendFound { probed: Vec }, + + #[error("Backend {name} is not available: {reason}")] + BackendNotAvailable { name: String, reason: String }, + + #[error("Policy violation for node {node}: {reason}")] + PolicyViolation { node: String, reason: String }, + + #[error("Workload ref resolution failed for node {node_id}, projection {projection:?}: {reason}")] + WorkloadRefResolutionFailed { node_id: String, projection: String, reason: String }, + + #[error("Other error: {0}")] + Other(String), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BackendProbeResult { + pub name: String, + pub available: bool, + pub reason: String, + pub version: Option, + pub mode: String, // "local" | "remote" + pub isolation_level: IsolationLevel, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorResponse { + pub message: String, + pub code: i32, +} + +impl ComposeError { + pub fn to_json(&self) -> String { + let code = match self { + ComposeError::NotFound(_) => 404, + ComposeError::BackendError { code, .. } => *code, + ComposeError::VerificationFailed { .. } => 403, + ComposeError::DependencyCycle(_) => 422, + ComposeError::ServiceStartupFailed { .. } => 500, + ComposeError::InvalidConfig(_) => 400, + ComposeError::NoBackendFound { .. } => 503, + ComposeError::BackendNotAvailable { .. } => 503, + ComposeError::PolicyViolation { .. } => 403, + ComposeError::WorkloadRefResolutionFailed { .. } => 500, + ComposeError::Other(_) => 500, + }; + + let resp = ErrorResponse { + message: self.to_string(), + code, + }; + serde_json::to_string(&resp).unwrap_or_else(|_| r#"{"message":"Unknown error","code":500}"#.to_string()) + } +} diff --git a/crates/perry-container-compose/src/installer.rs b/crates/perry-container-compose/src/installer.rs new file mode 100644 index 0000000000..301c1c23aa --- /dev/null +++ b/crates/perry-container-compose/src/installer.rs @@ -0,0 +1,21 @@ +use crate::error::{ComposeError, BackendProbeResult}; +use crate::backend::{BackendDriver, ContainerBackend, detect_backend}; +use std::sync::Arc; + +pub struct BackendInstaller; + +impl BackendInstaller { + pub async fn run() -> Result, ComposeError> { + #[cfg(feature = "installer")] + { + if !console::Term::stderr().is_term() { + return Err(ComposeError::NoBackendFound { probed: vec![] }); + } + // Implementation of interactive installer + println!("Perry needs a container runtime to continue."); + // ... + } + + Err(ComposeError::NoBackendFound { probed: vec![] }) + } +} diff --git a/crates/perry-container-compose/src/lib.rs b/crates/perry-container-compose/src/lib.rs new file mode 100644 index 0000000000..d046bb09ae --- /dev/null +++ b/crates/perry-container-compose/src/lib.rs @@ -0,0 +1,23 @@ +pub mod types; +pub mod error; +pub mod backend; +pub mod service; +pub mod orchestrate; +pub mod compose; +pub mod yaml; +pub mod project; +pub mod cli; +pub mod installer; + +pub mod testing; + +pub use types::*; +pub use error::*; +pub use backend::*; +pub use service::*; +pub use orchestrate::*; +pub use compose::*; +pub use yaml::*; +pub use project::*; +pub use cli::*; +pub use installer::*; diff --git a/crates/perry-container-compose/src/main.rs b/crates/perry-container-compose/src/main.rs new file mode 100644 index 0000000000..417ab12971 --- /dev/null +++ b/crates/perry-container-compose/src/main.rs @@ -0,0 +1,30 @@ +use clap::Parser; +use perry_container_compose::cli::{Cli, Commands}; +use perry_container_compose::project::ComposeProject; +use perry_container_compose::compose::ComposeEngine; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + let cli = Cli::parse(); + + let project = ComposeProject::load(cli.file, cli.project_name)?; + let engine = ComposeEngine::new(project.spec).await?; + + match cli.command { + Commands::Up { .. } => { + engine.up().await?; + } + Commands::Down { .. } => { + // engine.down().await?; + } + Commands::Ps => { + // engine.ps().await?; + } + Commands::Logs { .. } => { + // engine.logs().await?; + } + } + + Ok(()) +} diff --git a/crates/perry-container-compose/src/orchestrate.rs b/crates/perry-container-compose/src/orchestrate.rs new file mode 100644 index 0000000000..5237fef088 --- /dev/null +++ b/crates/perry-container-compose/src/orchestrate.rs @@ -0,0 +1,57 @@ +use crate::service::Service; +use crate::backend::ContainerBackend; +use crate::error::ComposeError; +use crate::types::ContainerSpec; +use tracing::info; + +pub async fn orchestrate_service( + service_name: &str, + service: &Service, + backend: &dyn ContainerBackend, +) -> Result<(), ComposeError> { + let name = service.name(service_name); + + if service.is_running(backend, &name).await? { + info!(service = %name, "already running, skipping"); + return Ok(()); + } + + if service.exists(backend, &name).await? { + info!(service = %name, "exists but stopped, starting"); + backend.start(&name).await?; + } else { + if service.needs_build() { + if let Some(build) = &service.build { + info!(service = %name, "building image"); + backend.build(build, service_name).await?; + } + } + + info!(service = %name, "creating and running"); + let spec = ContainerSpec { + image: service.image.clone().unwrap_or_else(|| service_name.to_string()), + name: Some(name.clone()), + ports: service.ports.clone(), + volumes: service.volumes.clone(), + env: match &service.environment { + Some(crate::types::ListOrDict::Dict(d)) => { + let mut env = std::collections::HashMap::new(); + for (k, v) in d { + if let Some(val) = v { + env.insert(k.clone(), val.to_string()); + } + } + Some(env) + } + _ => None, + }, + cmd: None, + entrypoint: None, + network: None, + rm: Some(false), + }; + backend.run(&spec).await?; + } + + Ok(()) +} diff --git a/crates/perry-container-compose/src/project.rs b/crates/perry-container-compose/src/project.rs new file mode 100644 index 0000000000..665ac7154d --- /dev/null +++ b/crates/perry-container-compose/src/project.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; +use crate::types::ComposeSpec; +use crate::error::ComposeError; +use crate::yaml; + +pub struct ComposeProject { + pub name: String, + pub working_dir: PathBuf, + pub spec: ComposeSpec, +} + +impl ComposeProject { + pub fn load(files: Vec, name: Option) -> Result { + let mut merged_spec: Option = None; + + for file in files { + let spec = yaml::parse_file(&file)?; + if let Some(mut existing) = merged_spec { + existing.services.extend(spec.services); + // Simple merge for now + merged_spec = Some(existing); + } else { + merged_spec = Some(spec); + } + } + + let spec = merged_spec.ok_or_else(|| ComposeError::InvalidConfig("No compose files provided".into()))?; + let working_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let name = name.unwrap_or_else(|| { + working_dir.file_name().and_then(|n| n.to_str()).unwrap_or("default").to_string() + }); + + Ok(Self { name, working_dir, spec }) + } +} diff --git a/crates/perry-container-compose/src/service.rs b/crates/perry-container-compose/src/service.rs new file mode 100644 index 0000000000..1133ccfe51 --- /dev/null +++ b/crates/perry-container-compose/src/service.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use crate::types::*; +use crate::backend::ContainerBackend; +use crate::error::ComposeError; +use md5::{Md5, Digest}; +use rand::Rng; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Service { + pub image: Option, + pub container_name: Option, + pub ports: Option>, + pub environment: Option, + pub labels: Option, + pub volumes: Option>, + pub build: Option, +} + +impl Service { + pub fn name(&self, default: &str) -> String { + self.container_name.clone().unwrap_or_else(|| default.to_string()) + } + + pub fn generate_name(image: &str, service_name: &str) -> String { + let mut hasher = Md5::new(); + hasher.update(image.as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + let mut rng = rand::thread_rng(); + let suffix: u32 = rng.gen(); + format!("{}_{}_{}", service_name, &hash[0..8], suffix) + } + + pub async fn exists(&self, backend: &dyn ContainerBackend, name: &str) -> Result { + match backend.inspect(name).await { + Ok(_) => Ok(true), + Err(ComposeError::NotFound(_)) => Ok(false), + Err(e) => Err(e), + } + } + + pub async fn is_running(&self, backend: &dyn ContainerBackend, name: &str) -> Result { + match backend.inspect(name).await { + Ok(info) => Ok(info.status == "running"), + Err(ComposeError::NotFound(_)) => Ok(false), + Err(e) => Err(e), + } + } + + pub fn needs_build(&self) -> bool { + self.build.is_some() && self.image.is_none() + } +} + +impl From for Service { + fn from(cs: ComposeService) -> Self { + let build = match cs.build { + Some(ComposeServiceBuildOrString::Build(b)) => Some(b), + _ => None, + }; + + let ports = cs.ports.map(|ps| { + ps.into_iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect() + }); + + let volumes = cs.volumes.map(|vs| { + vs.into_iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect() + }); + + Service { + image: cs.image, + container_name: cs.container_name, + ports, + environment: cs.environment, + labels: cs.labels, + volumes, + build, + } + } +} diff --git a/crates/perry-container-compose/src/testing/mod.rs b/crates/perry-container-compose/src/testing/mod.rs new file mode 100644 index 0000000000..3294c8c4eb --- /dev/null +++ b/crates/perry-container-compose/src/testing/mod.rs @@ -0,0 +1,65 @@ +use async_trait::async_trait; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use crate::backend::ContainerBackend; +use crate::error::ComposeError; +use crate::types::*; + +#[derive(Debug, Clone)] +pub enum RecordedCall { + Run(ContainerSpec), + Inspect(String), + Start(String), + Stop(String, Option), + Remove(String, bool), +} + +pub struct MockBackend { + pub calls: Arc>>, +} + +impl MockBackend { + pub fn new() -> Self { + Self { + calls: Arc::new(Mutex::new(Vec::new())), + } + } +} + +#[async_trait] +impl ContainerBackend for MockBackend { + fn backend_name(&self) -> &str { "mock" } + async fn check_available(&self) -> Result<(), ComposeError> { Ok(()) } + async fn run(&self, spec: &ContainerSpec) -> Result { + self.calls.lock().unwrap().push(RecordedCall::Run(spec.clone())); + Ok("mock-id".into()) + } + async fn create(&self, _spec: &ContainerSpec) -> Result { Ok("mock-id".into()) } + async fn start(&self, id: &str) -> Result<(), ComposeError> { + self.calls.lock().unwrap().push(RecordedCall::Start(id.to_string())); + Ok(()) + } + async fn stop(&self, id: &str, timeout: Option) -> Result<(), ComposeError> { + self.calls.lock().unwrap().push(RecordedCall::Stop(id.to_string(), timeout)); + Ok(()) + } + async fn remove(&self, id: &str, force: bool) -> Result<(), ComposeError> { + self.calls.lock().unwrap().push(RecordedCall::Remove(id.to_string(), force)); + Ok(()) + } + async fn list(&self, _all: bool) -> Result, ComposeError> { Ok(vec![]) } + async fn inspect(&self, id: &str) -> Result { + self.calls.lock().unwrap().push(RecordedCall::Inspect(id.to_string())); + Err(ComposeError::NotFound(id.to_string())) + } + async fn logs(&self, _id: &str, _tail: Option) -> Result { Ok(ContainerLogs { stdout: "".into(), stderr: "".into() }) } + async fn exec(&self, _id: &str, _cmd: &[String], _env: Option<&HashMap>, _workdir: Option<&str>) -> Result { Ok(ContainerLogs { stdout: "".into(), stderr: "".into() }) } + async fn build(&self, _spec: &ComposeServiceBuild, _image_name: &str) -> Result<(), ComposeError> { Ok(()) } + async fn pull_image(&self, _reference: &str) -> Result<(), ComposeError> { Ok(()) } + async fn list_images(&self) -> Result, ComposeError> { Ok(vec![]) } + async fn remove_image(&self, _reference: &str, _force: bool) -> Result<(), ComposeError> { Ok(()) } + async fn create_network(&self, _name: &str, _config: Option<&serde_json::Value>) -> Result<(), ComposeError> { Ok(()) } + async fn remove_network(&self, _name: &str) -> Result<(), ComposeError> { Ok(()) } + async fn create_volume(&self, _name: &str, _config: Option<&serde_json::Value>) -> Result<(), ComposeError> { Ok(()) } + async fn remove_volume(&self, _name: &str) -> Result<(), ComposeError> { Ok(()) } +} diff --git a/crates/perry-container-compose/src/types.rs b/crates/perry-container-compose/src/types.rs new file mode 100644 index 0000000000..8cb09cd041 --- /dev/null +++ b/crates/perry-container-compose/src/types.rs @@ -0,0 +1,238 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use indexmap::IndexMap; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(untagged)] +pub enum ListOrDict { + Dict(HashMap>), + List(Vec), +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ContainerSpec { + pub image: String, + pub name: Option, + pub ports: Option>, + pub volumes: Option>, + pub env: Option>, + pub cmd: Option>, + pub entrypoint: Option>, + pub network: Option, + pub rm: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ContainerInfo { + pub id: String, + pub name: String, + pub image: String, + pub status: String, + pub ports: Vec, + pub created: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ContainerLogs { + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ImageInfo { + pub id: String, + pub repository: String, + pub tag: String, + pub size: u64, + pub created: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)] +pub enum IsolationLevel { + None, + Process, + Container, + MicroVm, + Wasm, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BackendInfo { + pub name: String, + pub available: bool, + pub reason: Option, + pub version: Option, + pub mode: String, // "local" | "remote" + pub isolation_level: IsolationLevel, +} + +// Compose Types +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeSpec { + pub name: Option, + pub version: Option, + pub services: IndexMap, + pub networks: Option>, + pub volumes: Option>, + pub secrets: Option>, + pub configs: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeService { + pub image: Option, + pub build: Option, + pub command: Option, + pub entrypoint: Option, + pub environment: Option, + pub env_file: Option, + pub ports: Option>, // Mix of string, number, or object + pub volumes: Option>, // Mix of string or object + pub networks: Option, + pub depends_on: Option, + pub healthcheck: Option, + pub deploy: Option, + pub logging: Option, + pub container_name: Option, + pub labels: Option, + pub extra_hosts: Option, + pub sysctls: Option, + pub read_only: Option, + pub isolation_level: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum ComposeServiceBuildOrString { + String(String), + Build(ComposeServiceBuild), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeServiceBuild { + pub context: String, + #[serde(alias = "dockerfile")] + pub containerfile: Option, + pub args: Option, + pub labels: Option, + pub target: Option, + pub network: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum CommandOrString { + String(String), + List(Vec), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum EnvFile { + String(String), + List(Vec), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum DependsOn { + List(Vec), + Dict(IndexMap), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeDependsOn { + pub condition: DependsOnCondition, + pub required: Option, + pub restart: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum DependsOnCondition { + ServiceStarted, + ServiceHealthy, + ServiceCompletedSuccessfully, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeNetwork { + pub name: Option, + pub driver: Option, + pub driver_opts: Option>, + pub ipam: Option, + pub external: Option, + pub internal: Option, + pub enable_ipv4: Option, + pub enable_ipv6: Option, + pub attachable: Option, + pub labels: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeNetworkIpam { + pub driver: Option, + pub config: Option>>, + pub options: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeVolume { + pub name: Option, + pub driver: Option, + pub driver_opts: Option>, + pub external: Option, + pub labels: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeSecret { + pub name: Option, + pub environment: Option, + pub file: Option, + pub external: Option, + pub labels: Option, + pub driver: Option, + pub driver_opts: Option>, + pub template_driver: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeConfig { + pub name: Option, + pub content: Option, + pub environment: Option, + pub file: Option, + pub external: Option, + pub labels: Option, + pub template_driver: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeHealthcheck { + pub test: CommandOrString, + pub interval: Option, + pub timeout: Option, + pub retries: Option, + pub start_period: Option, + pub start_interval: Option, + pub disable: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeDeployment { + pub mode: Option, + pub replicas: Option, + pub labels: Option, + pub resources: Option, + pub restart_policy: Option, + pub placement: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeLogging { + pub driver: Option, + pub options: Option>, +} diff --git a/crates/perry-container-compose/src/yaml.rs b/crates/perry-container-compose/src/yaml.rs new file mode 100644 index 0000000000..c63d400187 --- /dev/null +++ b/crates/perry-container-compose/src/yaml.rs @@ -0,0 +1,12 @@ +use crate::types::ComposeSpec; +use crate::error::ComposeError; +use std::path::Path; + +pub fn parse_str(yaml: &str) -> Result { + serde_yaml::from_str(yaml).map_err(|e| ComposeError::InvalidConfig(e.to_string())) +} + +pub fn parse_file(path: impl AsRef) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| ComposeError::Other(e.to_string()))?; + parse_str(&content) +} diff --git a/crates/perry-container-compose/tests/functional/compose_engine_test.rs b/crates/perry-container-compose/tests/functional/compose_engine_test.rs new file mode 100644 index 0000000000..e686784d32 --- /dev/null +++ b/crates/perry-container-compose/tests/functional/compose_engine_test.rs @@ -0,0 +1,59 @@ +use perry_container_compose::compose::ComposeEngine; +use perry_container_compose::types::*; +use perry_container_compose::testing::{MockBackend, RecordedCall}; +use perry_container_compose::service::Service; +use indexmap::IndexMap; +use std::sync::Arc; + +#[tokio::test] +async fn test_compose_up_basic() { + let mock = Arc::new(MockBackend::new()); + + let mut spec = ComposeSpec { + name: Some("test".into()), + version: None, + services: IndexMap::new(), + networks: None, + volumes: None, + secrets: None, + configs: None, + }; + + spec.services.insert("web".into(), ComposeService { + image: Some("nginx".into()), + build: None, + command: None, + entrypoint: None, + environment: None, + env_file: None, + ports: None, + volumes: None, + networks: None, + depends_on: None, + healthcheck: None, + deploy: None, + logging: None, + container_name: Some("web-container".into()), + labels: None, + extra_hosts: None, + sysctls: None, + read_only: None, + isolation_level: None, + }); + + let engine = ComposeEngine { + backend: mock.clone(), + services: spec.services.into_iter().map(|(k, v)| (k, Service::from(v))).collect(), + }; + + engine.up().await.unwrap(); + + let calls = mock.calls.lock().unwrap(); + // print calls for debugging + for (i, call) in calls.iter().enumerate() { + println!("{}: {:?}", i, call); + } + assert!(calls.len() >= 2); + let has_run = calls.iter().any(|c| matches!(c, RecordedCall::Run(_))); + assert!(has_run); +} diff --git a/crates/perry-hir/src/ir.rs b/crates/perry-hir/src/ir.rs index fd608fc842..ffb155d02f 100644 --- a/crates/perry-hir/src/ir.rs +++ b/crates/perry-hir/src/ir.rs @@ -98,6 +98,11 @@ pub const NATIVE_MODULES: &[&str] = &[ "worker_threads", // Perry threading primitives (parallelMap, spawn) "perry/thread", + // Perry container orchestration + "perry/container", + "perry/container-compose", + "perry/compose", + "perry/workloads", // SQLite "better-sqlite3", ]; @@ -127,6 +132,10 @@ const RUNTIME_ONLY_MODULES: &[&str] = &[ "perry/widget", "perry/i18n", "perry/thread", + "perry/container", + "perry/container-compose", + "perry/compose", + "perry/workloads", ]; /// Check if a native module import requires linking perry-stdlib. diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index d92acd8249..8d4cd62bab 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -18,6 +18,9 @@ full = ["http-server", "http-client", "database", "crypto", "compression", "emai # Minimal core - just what's needed for basic programs core = [] +# Container orchestration (perry/container, perry/workloads) +container = ["dep:perry-container-compose", "async-runtime"] + # HTTP server (hyper-based native framework) # Note: dashmap is now always-on (used by core handle registry), no longer listed here. http-server = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:bytes", "async-runtime"] @@ -83,6 +86,8 @@ perry-runtime = { workspace = true, features = ["stdlib"] } thiserror.workspace = true anyhow.workspace = true +perry-container-compose = { path = "../perry-container-compose", optional = true } + # Always needed serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/crates/perry-stdlib/src/container/capability.rs b/crates/perry-stdlib/src/container/capability.rs new file mode 100644 index 0000000000..dcf8263185 --- /dev/null +++ b/crates/perry-stdlib/src/container/capability.rs @@ -0,0 +1,9 @@ +use crate::container::types::{ContainerLogs, ContainerError}; + +pub async fn alloy_container_run_capability( + _name: &str, + _image: &str, + _cmd: &[&str], +) -> Result { + Err(ContainerError::Other("Not implemented".into())) +} diff --git a/crates/perry-stdlib/src/container/context.rs b/crates/perry-stdlib/src/container/context.rs new file mode 100644 index 0000000000..d2216c5b7c --- /dev/null +++ b/crates/perry-stdlib/src/container/context.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; +use tokio::sync::Mutex; +use perry_container_compose::backend::{ContainerBackend, get_global_backend_instance}; +use perry_container_compose::error::ComposeError; +use dashmap::DashMap; + +pub struct ContainerContext { + pub backend: Mutex>>, + pub handles: DashMap, +} + +pub enum HandleEntry { + Container(String), + Compose(u64), // placeholder +} + +impl ContainerContext { + pub fn global() -> &'static Self { + static INSTANCE: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { + ContainerContext { + backend: Mutex::new(None), + handles: DashMap::new(), + } + }); + &INSTANCE + } + + pub async fn get_backend(&self) -> Result, ComposeError> { + let mut lock = self.backend.lock().await; + if let Some(b) = &*lock { + return Ok(b.clone()); + } + let b = get_global_backend_instance().await?; + *lock = Some(b.clone()); + Ok(b) + } +} diff --git a/crates/perry-stdlib/src/container/mod.rs b/crates/perry-stdlib/src/container/mod.rs new file mode 100644 index 0000000000..aafc1cfdca --- /dev/null +++ b/crates/perry-stdlib/src/container/mod.rs @@ -0,0 +1,292 @@ +#[cfg(feature = "container")] +pub mod types; +#[cfg(feature = "container")] +pub mod context; +#[cfg(feature = "container")] +pub mod verification; +#[cfg(feature = "container")] +pub mod capability; +#[cfg(feature = "container")] +pub mod workload; + +use crate::common::{StringHeader, spawn_for_promise_deferred}; +use crate::core::Promise; +use perry_container_compose::backend::get_global_backend_instance; +use perry_container_compose::types::*; +use perry_container_compose::error::ComposeError; +use perry_container_compose::compose::ComposeEngine; +use std::sync::Arc; +use std::collections::HashMap; + +#[no_mangle] +pub unsafe extern "C" fn js_container_module_init() { + // Force backend detection + tokio::spawn(async { + let _ = get_global_backend_instance().await; + }); +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_run(spec_ptr: *const StringHeader) -> *mut Promise { + let spec_json = match StringHeader::read(spec_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid spec pointer"), + }; + + spawn_for_promise_deferred(async move { + let spec: ContainerSpec = serde_json::from_str(&spec_json).map_err(|e| e.to_string())?; + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + let id = backend.run(&spec).await.map_err(|e| e.to_string())?; + Ok(serde_json::to_string(&id).unwrap()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_create(spec_ptr: *const StringHeader) -> *mut Promise { + let spec_json = match StringHeader::read(spec_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid spec pointer"), + }; + + spawn_for_promise_deferred(async move { + let spec: ContainerSpec = serde_json::from_str(&spec_json).map_err(|e| e.to_string())?; + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + let id = backend.create(&spec).await.map_err(|e| e.to_string())?; + Ok(serde_json::to_string(&id).unwrap()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_start(id_ptr: *const StringHeader) -> *mut Promise { + let id = match StringHeader::read(id_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid ID pointer"), + }; + + spawn_for_promise_deferred(async move { + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + backend.start(&id).await.map_err(|e| e.to_string())?; + Ok("null".to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_stop(id_ptr: *const StringHeader, timeout: f64) -> *mut Promise { + let id = match StringHeader::read(id_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid ID pointer"), + }; + + spawn_for_promise_deferred(async move { + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + let t = if timeout >= 0.0 { Some(timeout as u32) } else { None }; + backend.stop(&id, t).await.map_err(|e| e.to_string())?; + Ok("null".to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_remove(id_ptr: *const StringHeader, force: f64) -> *mut Promise { + let id = match StringHeader::read(id_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid ID pointer"), + }; + + spawn_for_promise_deferred(async move { + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + backend.remove(&id, force > 0.0).await.map_err(|e| e.to_string())?; + Ok("null".to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_list(all: f64) -> *mut Promise { + spawn_for_promise_deferred(async move { + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + let list = backend.list(all > 0.0).await.map_err(|e| e.to_string())?; + Ok(serde_json::to_string(&list).unwrap()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_inspect(id_ptr: *const StringHeader) -> *mut Promise { + let id = match StringHeader::read(id_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid ID pointer"), + }; + + spawn_for_promise_deferred(async move { + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + let info = backend.inspect(&id).await.map_err(|e| e.to_string())?; + Ok(serde_json::to_string(&info).unwrap()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_logs(id_ptr: *const StringHeader, tail: f64) -> *mut Promise { + let id = match StringHeader::read(id_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid ID pointer"), + }; + + spawn_for_promise_deferred(async move { + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + let t = if tail >= 0.0 { Some(tail as u32) } else { None }; + let logs = backend.logs(&id, t).await.map_err(|e| e.to_string())?; + Ok(serde_json::to_string(&logs).unwrap()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_exec( + id_ptr: *const StringHeader, + cmd_ptr: *const StringHeader, + env_ptr: *const StringHeader, + workdir_ptr: *const StringHeader, +) -> *mut Promise { + let id = match StringHeader::read(id_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid ID pointer"), + }; + let cmd_json = match StringHeader::read(cmd_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid cmd pointer"), + }; + let env_json = StringHeader::read(env_ptr); + let workdir = StringHeader::read(workdir_ptr); + + spawn_for_promise_deferred(async move { + let cmd: Vec = serde_json::from_str(&cmd_json).map_err(|e| e.to_string())?; + let env: Option> = env_json.and_then(|j| serde_json::from_str(&j).ok()); + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + let logs = backend.exec(&id, &cmd, env.as_ref(), workdir.as_deref()).await.map_err(|e| e.to_string())?; + Ok(serde_json::to_string(&logs).unwrap()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_pull_image(ref_ptr: *const StringHeader) -> *mut Promise { + let reference = match StringHeader::read(ref_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid ref pointer"), + }; + + spawn_for_promise_deferred(async move { + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + backend.pull_image(&reference).await.map_err(|e| e.to_string())?; + Ok("null".to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_list_images() -> *mut Promise { + spawn_for_promise_deferred(async move { + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + let list = backend.list_images().await.map_err(|e| e.to_string())?; + Ok(serde_json::to_string(&list).unwrap()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_remove_image(ref_ptr: *const StringHeader, force: f64) -> *mut Promise { + let reference = match StringHeader::read(ref_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid ref pointer"), + }; + + spawn_for_promise_deferred(async move { + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + backend.remove_image(&reference, force > 0.0).await.map_err(|e| e.to_string())?; + Ok("null".to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_get_backend() -> *const StringHeader { + // Note: This needs to be sync but backend detection is async. + // In production we'd use the cached value or a placeholder. + "none".into() +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_detect_backend() -> *mut Promise { + spawn_for_promise_deferred(async move { + let backend = get_global_backend_instance().await.map_err(|e| e.to_string())?; + Ok(backend.backend_name().to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_compose_up(spec_ptr: *const StringHeader) -> *mut Promise { + let spec_json = match StringHeader::read(spec_ptr) { + Some(s) => s, + None => return Promise::reject("Invalid spec pointer"), + }; + + spawn_for_promise_deferred(async move { + let spec: ComposeSpec = serde_json::from_str(&spec_json).map_err(|e| e.to_string())?; + let engine = ComposeEngine::new(spec).await.map_err(|e| e.to_string())?; + engine.up().await.map_err(|e| e.to_string())?; + // For now, return a fixed handle ID. Real implementation would use a registry. + Ok("1".to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_compose_down(handle: f64, volumes: f64) -> *mut Promise { + spawn_for_promise_deferred(async move { + // Mock implementation + let _ = (handle, volumes); + Ok("null".to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_compose_ps(handle: f64) -> *mut Promise { + spawn_for_promise_deferred(async move { + let _ = handle; + Ok("[]".to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_compose_logs(handle: f64, opts_ptr: *const StringHeader) -> *mut Promise { + let _opts = StringHeader::read(opts_ptr); + spawn_for_promise_deferred(async move { + let _ = handle; + Ok(r#"{"stdout":"","stderr":""}"#.to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_container_compose_exec( + handle: f64, + service_ptr: *const StringHeader, + cmd_ptr: *const StringHeader, + opts_ptr: *const StringHeader, +) -> *mut Promise { + let _service = StringHeader::read(service_ptr); + let _cmd = StringHeader::read(cmd_ptr); + let _opts = StringHeader::read(opts_ptr); + spawn_for_promise_deferred(async move { + let _ = handle; + Ok(r#"{"stdout":"","stderr":""}"#.to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_workload_run_graph(graph_ptr: *const StringHeader, opts_ptr: *const StringHeader) -> *mut Promise { + let _graph = StringHeader::read(graph_ptr); + let _opts = StringHeader::read(opts_ptr); + spawn_for_promise_deferred(async move { + Ok("1".to_string()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_workload_inspect_graph(graph_ptr: *const StringHeader) -> *mut Promise { + let _graph = StringHeader::read(graph_ptr); + spawn_for_promise_deferred(async move { + Ok(r#"{"nodes":{},"healthy":true}"#.to_string()) + }) +} diff --git a/crates/perry-stdlib/src/container/types.rs b/crates/perry-stdlib/src/container/types.rs new file mode 100644 index 0000000000..7301d87dc3 --- /dev/null +++ b/crates/perry-stdlib/src/container/types.rs @@ -0,0 +1,2 @@ +pub use perry_container_compose::types::*; +pub use perry_container_compose::error::ComposeError as ContainerError; diff --git a/crates/perry-stdlib/src/container/verification.rs b/crates/perry-stdlib/src/container/verification.rs new file mode 100644 index 0000000000..5e08ab694e --- /dev/null +++ b/crates/perry-stdlib/src/container/verification.rs @@ -0,0 +1,13 @@ +use crate::container::types::ContainerError; + +pub async fn verify_image(reference: &str) -> Result { + // Mock verification for now + if std::env::var("PERRY_SKIP_IMAGE_VERIFY").is_ok() { + return Ok("sha256:digest".into()); + } + Ok("sha256:digest".into()) +} + +pub fn get_default_base_image() -> &'static str { + "cgr.dev/chainguard/alpine-base" +} diff --git a/crates/perry-stdlib/src/container/workload.rs b/crates/perry-stdlib/src/container/workload.rs new file mode 100644 index 0000000000..fbe7aa69dc --- /dev/null +++ b/crates/perry-stdlib/src/container/workload.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use crate::container::types::ContainerSpec; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorkloadGraph { + pub name: String, + pub nodes: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorkloadNode { + pub id: String, + pub spec: ContainerSpec, + pub depends_on: Vec, +} diff --git a/src/core/wit/perry-container.wit b/src/core/wit/perry-container.wit new file mode 100644 index 0000000000..23bc83e4aa --- /dev/null +++ b/src/core/wit/perry-container.wit @@ -0,0 +1,72 @@ +package perry:container; + +interface types { + record container-spec { + image: string, + name: option, + ports: option>, + volumes: option>, + env: option>>, + cmd: option>, + entrypoint: option>, + network: option, + rm: option, + } + + record container-info { + id: string, + name: string, + image: string, + status: string, + ports: list, + created: string, + } + + record container-logs { + stdout: string, + stderr: string, + } + + record image-info { + id: string, + repository: string, + tag: string, + size: u64, + created: string, + } + + record backend-info { + name: string, + available: bool, + reason: option, + version: option, + mode: string, + } +} + +world container { + import types; + + export run: func(spec: types.container-spec) -> s64; + export create: func(spec: types.container-spec) -> s64; + export start: func(id: string) -> s64; + export stop: func(id: string, timeout: option) -> s64; + export remove: func(id: string, force: option) -> s64; + export list: func(all: option) -> s64; + export inspect: func(id: string) -> s64; + export logs: func(id: string, tail: option) -> s64; + export exec: func(id: string, cmd: list, env: option>>, workdir: option) -> s64; + + export pull-image: func(reference: string) -> s64; + export list-images: func() -> s64; + export remove-image: func(reference: string, force: option) -> s64; + + export get-backend: func() -> string; + export detect-backend: func() -> s64; + + export compose-up: func(spec-json: string) -> s64; + export compose-down: func(handle: s64, volumes: option) -> s64; + export compose-ps: func(handle: s64) -> s64; + export compose-logs: func(handle: s64, opts-json: string) -> s64; + export compose-exec: func(handle: s64, service: string, cmd: list, opts-json: string) -> s64; +}