Skip to main content

sz_rust_operator/
crd.rs

1//! SzRustApp CRD — 定义 sz-rust 应用的 Kubernetes 自定义资源
2//!
3//! ## CRD 定义
4//!
5//! ```yaml
6//! apiVersion: sz-rust.dev/v1
7//! kind: SzRustApp
8//! metadata:
9//!   name: my-app
10//! spec:
11//!   image: ghcr.io/ljclz/sz-rust:latest
12//!   replicas: 3
13//!   port: 8080
14//!   env:
15//!     DATABASE_URL: postgres://...
16//!     REDIS_URL: redis://...
17//! status:
18//!   ready: true
19//!   replicas: 3
20//! ```
21
22use kube::core::crd::CustomResourceExt;
23use kube::CustomResource;
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26
27// ============================================================================
28// SzRustApp CRD
29// ============================================================================
30
31/// SzRustApp 自定义资源 — 描述一个 sz-rust 应用部署
32///
33/// Operator watch 此资源,根据 spec 创建/更新/删除对应的 Deployment + Service。
34#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, JsonSchema)]
35#[kube(
36    group = "sz-rust.dev",
37    version = "v1",
38    kind = "SzRustApp",
39    namespaced,
40    status = "SzRustAppStatus",
41    shortname = "szapp"
42)]
43pub struct SzRustAppSpec {
44    /// 容器镜像地址
45    pub image: String,
46
47    /// 期望副本数(默认 1)
48    #[serde(default = "default_replicas")]
49    pub replicas: i32,
50
51    /// 服务端口(默认 8080)
52    #[serde(default = "default_port")]
53    pub port: u16,
54
55    /// 环境变量
56    #[serde(default)]
57    pub env: std::collections::BTreeMap<String, String>,
58
59    /// 资源限制
60    #[serde(default)]
61    pub resources: Option<ResourceRequirements>,
62
63    /// 数据库配置
64    #[serde(default)]
65    pub database: Option<DatabaseConfig>,
66
67    /// Redis 配置
68    #[serde(default)]
69    pub redis: Option<RedisConfig>,
70}
71
72/// SzRustApp 状态
73#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, Default)]
74pub struct SzRustAppStatus {
75    /// 是否就绪
76    pub ready: bool,
77
78    /// 当前运行副本数
79    pub replicas: i32,
80
81    /// 条件列表
82    #[serde(default)]
83    pub conditions: Vec<SzRustAppCondition>,
84}
85
86/// 资源需求
87#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, Default)]
88pub struct ResourceRequirements {
89    /// CPU 请求(如 "100m")
90    #[serde(default)]
91    pub cpu_request: Option<String>,
92    /// CPU 限制(如 "500m")
93    #[serde(default)]
94    pub cpu_limit: Option<String>,
95    /// 内存请求(如 "128Mi")
96    #[serde(default)]
97    pub memory_request: Option<String>,
98    /// 内存限制(如 "512Mi")
99    #[serde(default)]
100    pub memory_limit: Option<String>,
101}
102
103/// 数据库配置
104#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)]
105pub struct DatabaseConfig {
106    /// 数据库连接 URL
107    pub url: String,
108    /// 最大连接数
109    #[serde(default = "default_max_connections")]
110    pub max_connections: u32,
111}
112
113/// Redis 配置
114#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)]
115pub struct RedisConfig {
116    /// Redis 连接 URL
117    pub url: String,
118    /// 是否启用集群模式
119    #[serde(default)]
120    pub cluster: bool,
121}
122
123/// 条件状态
124#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)]
125pub struct SzRustAppCondition {
126    /// 条件类型(如 "Ready"、"Available")
127    pub type_: String,
128    /// 条件状态("True"、"False"、"Unknown")
129    pub status: String,
130    /// 上次更新时间
131    #[serde(default)]
132    pub last_transition_time: Option<String>,
133    /// 原因
134    #[serde(default)]
135    pub reason: Option<String>,
136    /// 消息
137    #[serde(default)]
138    pub message: Option<String>,
139}
140
141fn default_replicas() -> i32 {
142    1
143}
144
145fn default_port() -> u16 {
146    8080
147}
148
149fn default_max_connections() -> u32 {
150    10
151}
152
153impl SzRustAppSpec {
154    /// 创建新的 Spec
155    pub fn new(image: impl Into<String>) -> Self {
156        Self {
157            image: image.into(),
158            replicas: default_replicas(),
159            port: default_port(),
160            env: std::collections::BTreeMap::new(),
161            resources: None,
162            database: None,
163            redis: None,
164        }
165    }
166
167    /// 设置副本数
168    pub fn with_replicas(mut self, replicas: i32) -> Self {
169        self.replicas = replicas;
170        self
171    }
172
173    /// 设置端口
174    pub fn with_port(mut self, port: u16) -> Self {
175        self.port = port;
176        self
177    }
178
179    /// 添加环境变量
180    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
181        self.env.insert(key.into(), value.into());
182        self
183    }
184}
185
186// ============================================================================
187// CRD 生成
188// ============================================================================
189
190/// 生成 SzRustApp CRD 的 YAML 定义
191///
192/// 用于 `kubectl apply -f` 安装 CRD。
193pub fn generate_crd_yaml() -> String {
194    let crd = SzRustApp::crd();
195    serde_yaml::to_string(&crd).unwrap_or_else(|e| format!("# CRD 序列化失败: {e}"))
196}
197
198// ============================================================================
199// 单元测试
200// ============================================================================
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_spec_new() {
208        let spec = SzRustAppSpec::new("ghcr.io/ljclz/sz-rust:latest");
209        assert_eq!(spec.image, "ghcr.io/ljclz/sz-rust:latest");
210        assert_eq!(spec.replicas, 1);
211        assert_eq!(spec.port, 8080);
212        assert!(spec.env.is_empty());
213        assert!(spec.resources.is_none());
214        assert!(spec.database.is_none());
215        assert!(spec.redis.is_none());
216    }
217
218    #[test]
219    fn test_spec_builder() {
220        let spec = SzRustAppSpec::new("my-image:v1")
221            .with_replicas(3)
222            .with_port(9090)
223            .with_env("DATABASE_URL", "postgres://localhost/mydb")
224            .with_env("REDIS_URL", "redis://localhost:6379");
225
226        assert_eq!(spec.replicas, 3);
227        assert_eq!(spec.port, 9090);
228        assert_eq!(
229            spec.env.get("DATABASE_URL").unwrap(),
230            "postgres://localhost/mydb"
231        );
232        assert_eq!(spec.env.get("REDIS_URL").unwrap(), "redis://localhost:6379");
233    }
234
235    #[test]
236    fn test_spec_serialization() {
237        let spec = SzRustAppSpec::new("test:latest").with_replicas(2);
238        let json = serde_json::to_string(&spec).unwrap();
239        let decoded: SzRustAppSpec = serde_json::from_str(&json).unwrap();
240        assert_eq!(decoded.image, "test:latest");
241        assert_eq!(decoded.replicas, 2);
242    }
243
244    #[test]
245    fn test_spec_with_database() {
246        let spec = SzRustAppSpec::new("test:latest");
247        let mut spec = spec;
248        spec.database = Some(DatabaseConfig {
249            url: "postgres://localhost/db".to_string(),
250            max_connections: 20,
251        });
252        assert!(spec.database.is_some());
253        let db = spec.database.unwrap();
254        assert_eq!(db.url, "postgres://localhost/db");
255        assert_eq!(db.max_connections, 20);
256    }
257
258    #[test]
259    fn test_spec_with_redis() {
260        let spec = SzRustAppSpec::new("test:latest");
261        let mut spec = spec;
262        spec.redis = Some(RedisConfig {
263            url: "redis://localhost:6379".to_string(),
264            cluster: true,
265        });
266        assert!(spec.redis.is_some());
267        let redis = spec.redis.unwrap();
268        assert_eq!(redis.url, "redis://localhost:6379");
269        assert!(redis.cluster);
270    }
271
272    #[test]
273    fn test_status_default() {
274        let status = SzRustAppStatus::default();
275        assert!(!status.ready);
276        assert_eq!(status.replicas, 0);
277        assert!(status.conditions.is_empty());
278    }
279
280    #[test]
281    fn test_condition_serialization() {
282        let condition = SzRustAppCondition {
283            type_: "Ready".to_string(),
284            status: "True".to_string(),
285            last_transition_time: Some("2026-08-06T00:00:00Z".to_string()),
286            reason: Some("AllReplicasReady".to_string()),
287            message: None,
288        };
289        let json = serde_json::to_string(&condition).unwrap();
290        let decoded: SzRustAppCondition = serde_json::from_str(&json).unwrap();
291        assert_eq!(decoded.type_, "Ready");
292        assert_eq!(decoded.status, "True");
293        assert_eq!(decoded.reason.unwrap(), "AllReplicasReady");
294    }
295
296    #[test]
297    fn test_resource_requirements_default() {
298        let req = ResourceRequirements::default();
299        assert!(req.cpu_request.is_none());
300        assert!(req.cpu_limit.is_none());
301        assert!(req.memory_request.is_none());
302        assert!(req.memory_limit.is_none());
303    }
304
305    #[test]
306    fn test_crd_yaml_generation() {
307        let yaml = generate_crd_yaml();
308        assert!(yaml.contains("sz-rust.dev"));
309        assert!(yaml.contains("SzRustApp"));
310        assert!(yaml.contains("v1"));
311    }
312
313    #[test]
314    fn test_crd_has_correct_group() {
315        let crd = SzRustApp::crd();
316        assert_eq!(crd.spec.group, "sz-rust.dev");
317    }
318
319    #[test]
320    fn test_crd_has_correct_kind() {
321        let crd = SzRustApp::crd();
322        assert_eq!(crd.spec.names.kind, "SzRustApp");
323    }
324
325    #[test]
326    fn test_crd_has_shortname() {
327        let crd = SzRustApp::crd();
328        let short_names = crd.spec.names.short_names.as_ref().unwrap();
329        assert!(short_names.contains(&"szapp".to_string()));
330    }
331}