From af5f98f61cf572d6cae8e902c3bd010d491dcdb2 Mon Sep 17 00:00:00 2001 From: Keon Kim Date: Sun, 31 May 2026 15:10:45 +0900 Subject: [PATCH] rust-rewrite(core): add initial Rust workspace slice --- coolify-rs/.gitignore | 1 + coolify-rs/Cargo.lock | 152 ++++++++++++++++++ coolify-rs/Cargo.toml | 21 +++ coolify-rs/README.md | 10 ++ coolify-rs/benchmarks.md | 24 +++ .../crates/coolify-benchmarks/Cargo.toml | 9 ++ .../crates/coolify-benchmarks/src/main.rs | 40 +++++ coolify-rs/crates/coolify-core/Cargo.toml | 13 ++ coolify-rs/crates/coolify-core/src/lib.rs | 152 ++++++++++++++++++ coolify-rs/crates/coolify-docker/Cargo.toml | 11 ++ coolify-rs/crates/coolify-docker/src/lib.rs | 111 +++++++++++++ coolify-rs/crates/coolify-ssh/Cargo.toml | 10 ++ coolify-rs/crates/coolify-ssh/src/lib.rs | 131 +++++++++++++++ 13 files changed, 685 insertions(+) create mode 100644 coolify-rs/.gitignore create mode 100644 coolify-rs/Cargo.lock create mode 100644 coolify-rs/Cargo.toml create mode 100644 coolify-rs/README.md create mode 100644 coolify-rs/benchmarks.md create mode 100644 coolify-rs/crates/coolify-benchmarks/Cargo.toml create mode 100644 coolify-rs/crates/coolify-benchmarks/src/main.rs create mode 100644 coolify-rs/crates/coolify-core/Cargo.toml create mode 100644 coolify-rs/crates/coolify-core/src/lib.rs create mode 100644 coolify-rs/crates/coolify-docker/Cargo.toml create mode 100644 coolify-rs/crates/coolify-docker/src/lib.rs create mode 100644 coolify-rs/crates/coolify-ssh/Cargo.toml create mode 100644 coolify-rs/crates/coolify-ssh/src/lib.rs diff --git a/coolify-rs/.gitignore b/coolify-rs/.gitignore new file mode 100644 index 0000000000..b83d22266a --- /dev/null +++ b/coolify-rs/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/coolify-rs/Cargo.lock b/coolify-rs/Cargo.lock new file mode 100644 index 0000000000..ebb2501967 --- /dev/null +++ b/coolify-rs/Cargo.lock @@ -0,0 +1,152 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "coolify-benchmarks" +version = "0.1.0" +dependencies = [ + "coolify-docker", +] + +[[package]] +name = "coolify-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "coolify-docker" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "coolify-ssh" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/coolify-rs/Cargo.toml b/coolify-rs/Cargo.toml new file mode 100644 index 0000000000..9e5a3341d9 --- /dev/null +++ b/coolify-rs/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] +resolver = "3" +members = [ + "crates/coolify-core", + "crates/coolify-docker", + "crates/coolify-ssh", + "crates/coolify-benchmarks", +] + +[workspace.package] +edition = "2024" +rust-version = "1.91" +license = "Apache-2.0" + +[workspace.dependencies] +coolify-core = { path = "crates/coolify-core" } +coolify-docker = { path = "crates/coolify-docker" } +coolify-ssh = { path = "crates/coolify-ssh" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" diff --git a/coolify-rs/README.md b/coolify-rs/README.md new file mode 100644 index 0000000000..3db343d438 --- /dev/null +++ b/coolify-rs/README.md @@ -0,0 +1,10 @@ +# Coolify Rust Rewrite Slice + +This workspace is an initial Rust rewrite slice for the v4 backend migration. It is intentionally small and buildable: + +- `coolify-core`: domain models that mirror selected Laravel models. +- `coolify-ssh`: typed SSH pool configuration and command telemetry primitives. +- `coolify-docker`: zero-copy Docker manifest parsing for Engine API responses. +- `coolify-benchmarks`: reproducible parser benchmark harness for the Docker slice. + +The slice establishes ownership, validation, structured errors, and benchmark boundaries before larger API or queue rewrites are added. diff --git a/coolify-rs/benchmarks.md b/coolify-rs/benchmarks.md new file mode 100644 index 0000000000..0a3161eb60 --- /dev/null +++ b/coolify-rs/benchmarks.md @@ -0,0 +1,24 @@ +# Benchmark Notes + +Run from this directory: + +```bash +cargo run -p coolify-benchmarks --release +``` + +This first slice benchmarks Docker manifest parsing because large Docker Engine responses are a known migration pressure point. The parser uses `serde_json::from_slice` with borrowed string fields, so digest and media-type payloads are not copied into owned strings. + +Current PHP baseline from the bounty specification: + +- Docker API calls add roughly 35 ms overhead above the raw socket path. +- Target Rust overhead is under 10 ms above raw socket. + +The benchmark reports per-manifest parse latency so future PRs can compare parser overhead before wiring the full Unix socket client. + +Local validation on Apple Silicon with Rust 1.91.1: + +```text +docker_manifest_iterations=25000 +docker_manifest_total_ms=22 +docker_manifest_ns_per_parse=882 +``` diff --git a/coolify-rs/crates/coolify-benchmarks/Cargo.toml b/coolify-rs/crates/coolify-benchmarks/Cargo.toml new file mode 100644 index 0000000000..96f4f64ed7 --- /dev/null +++ b/coolify-rs/crates/coolify-benchmarks/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "coolify-benchmarks" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +coolify-docker.workspace = true diff --git a/coolify-rs/crates/coolify-benchmarks/src/main.rs b/coolify-rs/crates/coolify-benchmarks/src/main.rs new file mode 100644 index 0000000000..116726d4a4 --- /dev/null +++ b/coolify-rs/crates/coolify-benchmarks/src/main.rs @@ -0,0 +1,40 @@ +use coolify_docker::summarize_manifest; +use std::time::Instant; + +const MANIFEST: &[u8] = br#"{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config", + "size": 7023 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:layer-one", + "size": 32654 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:layer-two", + "size": 16724 + } + ] +}"#; + +fn main() { + let iterations = 25_000_u32; + let started = Instant::now(); + + for _ in 0..iterations { + summarize_manifest(MANIFEST).expect("benchmark fixture parses"); + } + + let elapsed = started.elapsed(); + let nanos_per_parse = elapsed.as_nanos() / u128::from(iterations); + + println!("docker_manifest_iterations={iterations}"); + println!("docker_manifest_total_ms={}", elapsed.as_millis()); + println!("docker_manifest_ns_per_parse={nanos_per_parse}"); +} diff --git a/coolify-rs/crates/coolify-core/Cargo.toml b/coolify-rs/crates/coolify-core/Cargo.toml new file mode 100644 index 0000000000..e808d4067b --- /dev/null +++ b/coolify-rs/crates/coolify-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "coolify-core" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/coolify-rs/crates/coolify-core/src/lib.rs b/coolify-rs/crates/coolify-core/src/lib.rs new file mode 100644 index 0000000000..fe7ce09cf2 --- /dev/null +++ b/coolify-rs/crates/coolify-core/src/lib.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ModelError { + #[error("server port must be between 1 and 65535")] + InvalidServerPort, + #[error("server address must not be empty")] + EmptyServerAddress, + #[error("deployment command must not be empty")] + EmptyDeploymentCommand, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProxyType { + Traefik, + Caddy, + None, +} + +/// Equivalent: app/Models/Server.php +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Server { + pub id: String, + pub name: String, + pub ip: String, + pub user: String, + pub port: u16, + pub team_id: String, + pub proxy_type: ProxyType, +} + +impl Server { + pub fn new( + id: impl Into, + name: impl Into, + ip: impl Into, + user: impl Into, + port: u16, + team_id: impl Into, + proxy_type: ProxyType, + ) -> Result { + let ip = ip.into(); + if ip.trim().is_empty() { + return Err(ModelError::EmptyServerAddress); + } + + if port == 0 { + return Err(ModelError::InvalidServerPort); + } + + Ok(Self { + id: id.into(), + name: name.into(), + ip, + user: user.into(), + port, + team_id: team_id.into(), + proxy_type, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeploymentPhase { + Queued, + Started, + Building, + Pushing, + Deploying, + HealthChecking, + Completed, + Failed, +} + +/// Equivalent: app/Jobs/ApplicationDeploymentJob.php +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeploymentJob { + pub id: String, + pub application_id: String, + pub server_id: String, + pub command: String, + pub phase: DeploymentPhase, +} + +impl DeploymentJob { + pub fn queued( + id: impl Into, + application_id: impl Into, + server_id: impl Into, + command: impl Into, + ) -> Result { + let command = command.into(); + if command.trim().is_empty() { + return Err(ModelError::EmptyDeploymentCommand); + } + + Ok(Self { + id: id.into(), + application_id: application_id.into(), + server_id: server_id.into(), + command, + phase: DeploymentPhase::Queued, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_rejects_empty_ip() { + let result = Server::new( + "srv_1", + "primary", + " ", + "root", + 22, + "team_1", + ProxyType::Traefik, + ); + + assert_eq!(result, Err(ModelError::EmptyServerAddress)); + } + + #[test] + fn server_serializes_proxy_type_in_snake_case() { + let server = Server::new( + "srv_1", + "primary", + "192.0.2.10", + "root", + 22, + "team_1", + ProxyType::Traefik, + ) + .expect("valid server"); + + let json = serde_json::to_string(&server).expect("server serializes"); + assert!(json.contains(r#""proxy_type":"traefik""#)); + } + + #[test] + fn deployment_job_requires_command() { + let result = DeploymentJob::queued("dep_1", "app_1", "srv_1", " "); + + assert_eq!(result, Err(ModelError::EmptyDeploymentCommand)); + } +} diff --git a/coolify-rs/crates/coolify-docker/Cargo.toml b/coolify-rs/crates/coolify-docker/Cargo.toml new file mode 100644 index 0000000000..631c94052c --- /dev/null +++ b/coolify-rs/crates/coolify-docker/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "coolify-docker" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/coolify-rs/crates/coolify-docker/src/lib.rs b/coolify-rs/crates/coolify-docker/src/lib.rs new file mode 100644 index 0000000000..e61147af3f --- /dev/null +++ b/coolify-rs/crates/coolify-docker/src/lib.rs @@ -0,0 +1,111 @@ +use serde::Deserialize; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DockerManifestError { + #[error("docker manifest json is invalid: {0}")] + InvalidJson(#[from] serde_json::Error), + #[error("docker manifest must include at least one layer")] + MissingLayers, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Descriptor<'a> { + pub media_type: &'a str, + pub digest: &'a str, + pub size: u64, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DockerManifest<'a> { + pub schema_version: u8, + pub media_type: &'a str, + pub config: Descriptor<'a>, + #[serde(borrow)] + pub layers: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestSummary<'a> { + pub media_type: &'a str, + pub config_digest: &'a str, + pub layer_count: usize, + pub total_layer_bytes: u64, +} + +pub fn parse_manifest(input: &[u8]) -> Result, DockerManifestError> { + let manifest: DockerManifest<'_> = serde_json::from_slice(input)?; + if manifest.layers.is_empty() { + return Err(DockerManifestError::MissingLayers); + } + + Ok(manifest) +} + +pub fn summarize_manifest(input: &[u8]) -> Result, DockerManifestError> { + let manifest = parse_manifest(input)?; + let total_layer_bytes = manifest.layers.iter().map(|layer| layer.size).sum(); + + Ok(ManifestSummary { + media_type: manifest.media_type, + config_digest: manifest.config.digest, + layer_count: manifest.layers.len(), + total_layer_bytes, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MANIFEST: &[u8] = br#"{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config", + "size": 7023 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:layer-one", + "size": 32654 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:layer-two", + "size": 16724 + } + ] + }"#; + + #[test] + fn summarizes_manifest_without_copying_string_payloads() { + let summary = summarize_manifest(MANIFEST).expect("manifest parses"); + + assert_eq!(summary.layer_count, 2); + assert_eq!(summary.config_digest, "sha256:config"); + assert_eq!(summary.total_layer_bytes, 49_378); + } + + #[test] + fn rejects_manifest_without_layers() { + let result = summarize_manifest( + br#"{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config", + "size": 7023 + }, + "layers": [] + }"#, + ); + + assert!(matches!(result, Err(DockerManifestError::MissingLayers))); + } +} diff --git a/coolify-rs/crates/coolify-ssh/Cargo.toml b/coolify-rs/crates/coolify-ssh/Cargo.toml new file mode 100644 index 0000000000..b1c11f61c2 --- /dev/null +++ b/coolify-rs/crates/coolify-ssh/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "coolify-ssh" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true diff --git a/coolify-rs/crates/coolify-ssh/src/lib.rs b/coolify-rs/crates/coolify-ssh/src/lib.rs new file mode 100644 index 0000000000..1e7c36b047 --- /dev/null +++ b/coolify-rs/crates/coolify-ssh/src/lib.rs @@ -0,0 +1,131 @@ +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SshConfigError { + #[error("pool must allow at least one connection")] + EmptyPool, + #[error("idle timeout must be longer than command timeout")] + IdleTimeoutTooShort, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshPoolConfig { + pub max_connections: usize, + pub idle_timeout_secs: u64, + pub command_timeout_secs: u64, +} + +impl Default for SshPoolConfig { + fn default() -> Self { + Self { + max_connections: 20, + idle_timeout_secs: 300, + command_timeout_secs: 30, + } + } +} + +impl SshPoolConfig { + pub fn validate(&self) -> Result<(), SshConfigError> { + if self.max_connections == 0 { + return Err(SshConfigError::EmptyPool); + } + + if self.idle_timeout_secs > self.command_timeout_secs { + return Ok(()); + } + + Err(SshConfigError::IdleTimeoutTooShort) + } + + pub fn command_timeout(&self) -> Duration { + Duration::from_secs(self.command_timeout_secs) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshCommandLog { + pub command: String, + pub exit_code: i32, + pub duration_ms: u128, + pub stdout_bytes: usize, + pub stderr_bytes: usize, +} + +impl SshCommandLog { + pub fn succeeded(&self) -> bool { + self.exit_code == 0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetryBackoff { + attempts: u8, + base_delay: Duration, +} + +impl RetryBackoff { + pub fn three_attempts() -> Self { + Self { + attempts: 3, + base_delay: Duration::from_secs(1), + } + } + + pub fn delays(&self) -> Vec { + (0..self.attempts) + .map(|attempt| self.base_delay * 2_u32.pow(u32::from(attempt))) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_pool_matches_coolify_timeout_targets() { + let config = SshPoolConfig::default(); + + assert_eq!(config.max_connections, 20); + assert_eq!(config.command_timeout(), Duration::from_secs(30)); + assert!(config.validate().is_ok()); + } + + #[test] + fn rejects_zero_sized_pool() { + let config = SshPoolConfig { + max_connections: 0, + ..SshPoolConfig::default() + }; + + assert_eq!(config.validate(), Err(SshConfigError::EmptyPool)); + } + + #[test] + fn rejects_idle_timeout_shorter_than_command_timeout() { + let config = SshPoolConfig { + idle_timeout_secs: 10, + command_timeout_secs: 30, + ..SshPoolConfig::default() + }; + + assert_eq!(config.validate(), Err(SshConfigError::IdleTimeoutTooShort)); + } + + #[test] + fn uses_required_exponential_backoff() { + let delays = RetryBackoff::three_attempts().delays(); + + assert_eq!( + delays, + [ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4) + ] + ); + } +}