1use serde::{Deserialize, Serialize};
15
16use crate::surface::{Column, ColumnKind};
17
18pub const LENS_SCHEMA_VERSION: u32 = 1;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct GroupVersionKind {
26 #[serde(default)]
28 pub group: String,
29 pub version: String,
31 pub kind: String,
33}
34
35impl GroupVersionKind {
36 pub fn display(&self) -> String {
38 if self.group.is_empty() {
39 format!("{}/{}", self.version, self.kind)
40 } else {
41 format!("{}/{}/{}", self.group, self.version, self.kind)
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum RuleOp {
50 Eq,
52 Ne,
54 Gt,
56 Gte,
58 Lt,
60 Lte,
62 Contains,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct StatusRule {
74 pub field: String,
76 pub op: RuleOp,
78 pub value: serde_json::Value,
80 pub level: crate::render::StatusLevel,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct LensAction {
89 pub id: String,
91 pub label_key: String,
93 #[serde(rename = "state", default = "default_action_state")]
96 pub state: String,
97}
98
99fn default_action_state() -> String {
100 "allowed".into()
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct ViewDefinition {
106 pub id: String,
108 pub api_version: u32,
111 pub target: GroupVersionKind,
113 #[serde(default)]
115 pub columns: Vec<Column>,
116 #[serde(default)]
118 pub status: Vec<StatusRule>,
119 #[serde(default)]
121 pub actions: Vec<LensAction>,
122}
123
124pub fn validate_viewdef(vd: &ViewDefinition) -> Vec<String> {
129 let mut problems = Vec::new();
130
131 if vd.id.trim().is_empty() {
132 problems.push("id: must not be empty".into());
133 } else if !vd.id.contains('.') {
134 problems.push(format!(
135 "id {:?}: must be reverse-DNS (e.g. \"com.example.cnpg-lens\")",
136 vd.id
137 ));
138 }
139
140 if vd.api_version != LENS_SCHEMA_VERSION {
141 problems.push(format!(
142 "api_version: this release supports lens schema v{LENS_SCHEMA_VERSION}, but the \
143 lens declares v{} — a migration is required (docs/versioning.md)",
144 vd.api_version
145 ));
146 }
147
148 if vd.target.version.trim().is_empty() {
149 problems.push("target.version: must not be empty".into());
150 }
151 if vd.target.kind.trim().is_empty() {
152 problems.push("target.kind: must not be empty".into());
153 }
154
155 let mut seen = std::collections::HashSet::new();
157 for col in &vd.columns {
158 if col.id.trim().is_empty() {
159 problems.push("columns: a column has an empty id".into());
160 } else if !seen.insert(col.id.as_str()) {
161 problems.push(format!("columns: duplicate column id {:?}", col.id));
162 }
163 if !valid_header_key(&col.header_key) {
164 problems.push(format!(
165 "columns.{:?}: header_key must be a dotted i18n key (e.g. \"col.name\")",
166 col.id
167 ));
168 }
169 }
170
171 for (i, rule) in vd.status.iter().enumerate() {
173 if !valid_field_path(&rule.field) {
174 problems.push(format!(
175 "status[{i}].field {:?}: not a dotted JSON path",
176 rule.field
177 ));
178 }
179 match rule.op {
180 RuleOp::Gt | RuleOp::Gte | RuleOp::Lt | RuleOp::Lte => {
181 if !rule.value.is_number() {
182 problems.push(format!(
183 "status[{i}].value: a numeric operator ({:?}) needs a numeric value",
184 rule.op
185 ));
186 }
187 }
188 RuleOp::Contains => {
189 if !rule.value.is_string() {
190 problems.push(format!(
191 "status[{i}].value: `contains` needs a string value"
192 ));
193 }
194 }
195 RuleOp::Eq | RuleOp::Ne => {}
196 }
197 }
198
199 let mut seen_actions = std::collections::HashSet::new();
201 for action in &vd.actions {
202 if action.id.trim().is_empty() || !seen_actions.insert(action.id.as_str()) {
203 problems.push(format!(
204 "actions: duplicate or empty action id {:?}",
205 action.id
206 ));
207 }
208 }
209
210 problems
211}
212
213fn valid_field_path(field: &str) -> bool {
216 let mut parts = field.split('.');
217 let Some(first) = parts.next() else {
218 return false;
219 };
220 if !is_identifier(first) {
221 return false;
222 }
223 parts.all(is_segment)
224}
225
226fn resolve_field<'a>(root: &'a serde_json::Value, field: &str) -> Option<&'a serde_json::Value> {
230 let mut cur = root;
231 for segment in field.split('.') {
232 let (ident, subscripts) = split_subscripts(segment);
234 cur = cur.get(ident)?;
235 for sub in subscripts {
236 cur = cur.get(sub)?;
237 }
238 }
239 Some(cur)
240}
241
242fn split_subscripts(segment: &str) -> (&str, Vec<usize>) {
245 let mut idx = segment.len();
246 let mut subs = Vec::new();
247 while idx > 0 && segment[..idx].ends_with(']') {
248 if let Some(open) = segment[..idx].rfind('[') {
249 let inside = &segment[open + 1..idx - 1];
250 if let Ok(n) = inside.parse::<usize>() {
251 subs.push(n);
252 }
253 idx = open;
254 } else {
255 break;
256 }
257 }
258 subs.reverse();
259 (&segment[..idx], subs)
260}
261
262pub fn evaluate_status(
267 vd: &ViewDefinition,
268 resource: &serde_json::Value,
269) -> Option<crate::render::StatusLevel> {
270 for rule in &vd.status {
271 if rule_matches(rule, resource) {
272 return Some(rule.level);
273 }
274 }
275 None
276}
277
278fn rule_matches(rule: &StatusRule, resource: &serde_json::Value) -> bool {
279 let Some(actual) = resolve_field(resource, &rule.field) else {
280 return false;
281 };
282 match rule.op {
283 RuleOp::Eq => actual == &rule.value,
284 RuleOp::Ne => actual != &rule.value,
285 RuleOp::Gt | RuleOp::Gte | RuleOp::Lt | RuleOp::Lte => {
286 let (Some(a), Some(b)) = (actual.as_i64(), rule.value.as_i64()) else {
288 return false;
289 };
290 match rule.op {
291 RuleOp::Gt => a > b,
292 RuleOp::Gte => a >= b,
293 RuleOp::Lt => a < b,
294 RuleOp::Lte => a <= b,
295 _ => unreachable!(),
296 }
297 }
298 RuleOp::Contains => match (actual.as_str(), rule.value.as_str()) {
299 (Some(a), Some(b)) => a.contains(b),
300 _ => false,
301 },
302 }
303}
304
305fn is_segment(seg: &str) -> bool {
306 let mut idx = seg.len();
309 while idx > 0 && seg[..idx].ends_with(']') {
310 let Some(open) = seg[..idx].rfind('[') else {
311 return false;
312 };
313 let inside = &seg[open + 1..idx - 1];
314 if inside.is_empty() || !inside.chars().all(|c| c.is_ascii_digit()) {
315 return false;
316 }
317 idx = open;
318 }
319 is_identifier(&seg[..idx])
320}
321
322fn is_identifier(s: &str) -> bool {
323 !s.is_empty()
324 && s.chars()
325 .enumerate()
326 .all(|(i, c)| c.is_alphanumeric() || c == '_' || (i > 0 && c == '-'))
327}
328
329fn valid_header_key(key: &str) -> bool {
332 key.split('.').all(is_identifier) && key.contains('.')
333}
334
335pub fn example_cnpg_columns() -> Vec<Column> {
339 vec![
340 Column {
341 id: "name".into(),
342 header_key: "col.name".into(),
343 kind: ColumnKind::Text,
344 sortable: true,
345 },
346 Column {
347 id: "instances".into(),
348 header_key: "col.instances".into(),
349 kind: ColumnKind::Number,
350 sortable: true,
351 },
352 Column {
353 id: "status".into(),
354 header_key: "col.status".into(),
355 kind: ColumnKind::Status,
356 sortable: true,
357 },
358 ]
359}
360
361pub fn example_status_rule() -> StatusRule {
363 StatusRule {
364 field: "status.phase".into(),
365 op: RuleOp::Eq,
366 value: serde_json::json!("ClusterIsReady"),
367 level: crate::render::StatusLevel::Ok,
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 fn col(id: &str) -> Column {
376 Column {
377 id: id.into(),
378 header_key: format!("col.{id}"),
379 kind: ColumnKind::Text,
380 sortable: true,
381 }
382 }
383
384 fn action(id: &str) -> LensAction {
385 LensAction {
386 id: id.into(),
387 label_key: format!("action.{id}"),
388 state: "allowed".into(),
389 }
390 }
391
392 fn valid() -> ViewDefinition {
393 ViewDefinition {
394 id: "com.example.cnpg-lens".into(),
395 api_version: LENS_SCHEMA_VERSION,
396 target: GroupVersionKind {
397 group: "postgresql.cnpg.io".into(),
398 version: "v1".into(),
399 kind: "Cluster".into(),
400 },
401 columns: vec![col("name"), col("status")],
402 status: vec![example_status_rule()],
403 actions: vec![action("describe")],
404 }
405 }
406
407 #[test]
408 fn valid_lens_has_no_problems() {
409 assert!(validate_viewdef(&valid()).is_empty());
410 }
411
412 #[test]
413 fn missing_reverse_dns_id_is_flagged() {
414 let mut vd = valid();
415 vd.id = "no-dot-here".into();
416 let problems = validate_viewdef(&vd);
417 assert!(problems.iter().any(|p| p.contains("reverse-DNS")));
418 }
419
420 #[test]
421 fn wrong_api_version_is_flagged() {
422 let mut vd = valid();
423 vd.api_version = 999;
424 let problems = validate_viewdef(&vd);
425 assert!(problems.iter().any(|p| p.contains("api_version")));
426 }
427
428 #[test]
429 fn duplicate_column_id_is_flagged() {
430 let mut vd = valid();
431 vd.columns = vec![col("name"), col("name")];
432 let problems = validate_viewdef(&vd);
433 assert!(problems.iter().any(|p| p.contains("duplicate column")));
434 }
435
436 #[test]
437 fn numeric_op_with_string_value_is_flagged() {
438 let mut vd = valid();
439 vd.status = vec![StatusRule {
440 field: "spec.replicas".into(),
441 op: RuleOp::Gt,
442 value: serde_json::json!("many"),
443 level: crate::render::StatusLevel::Warning,
444 }];
445 let problems = validate_viewdef(&vd);
446 assert!(problems.iter().any(|p| p.contains("numeric")));
447 }
448
449 #[test]
450 fn contains_op_with_numeric_value_is_flagged() {
451 let mut vd = valid();
452 vd.status = vec![StatusRule {
453 field: "status.phase".into(),
454 op: RuleOp::Contains,
455 value: serde_json::json!(3),
456 level: crate::render::StatusLevel::Warning,
457 }];
458 let problems = validate_viewdef(&vd);
459 assert!(problems.iter().any(|p| p.contains("contains")));
460 }
461
462 #[test]
463 fn malformed_field_path_is_flagged() {
464 let mut vd = valid();
465 vd.status = vec![StatusRule {
466 field: ".bad.path".into(),
467 op: RuleOp::Eq,
468 value: serde_json::json!("x"),
469 level: crate::render::StatusLevel::Ok,
470 }];
471 let problems = validate_viewdef(&vd);
472 assert!(problems.iter().any(|p| p.contains("field")));
473 }
474
475 #[test]
476 fn duplicate_action_id_is_flagged() {
477 let mut vd = valid();
478 vd.actions = vec![action("x"), action("x")];
479 let problems = validate_viewdef(&vd);
480 assert!(problems.iter().any(|p| p.contains("action")));
481 }
482
483 #[test]
484 fn field_path_validator_accepts_indexes() {
485 assert!(valid_field_path("status.phase"));
486 assert!(valid_field_path("spec.containers[0].name"));
487 assert!(valid_field_path("metadata.labels.app"));
488 assert!(!valid_field_path(""));
489 assert!(!valid_field_path(".phase"));
490 assert!(!valid_field_path("status..phase"));
491 }
492
493 #[test]
494 fn resolve_field_reads_nested_and_indexed_paths() {
495 let v = serde_json::json!({
496 "status": {"phase": "Running"},
497 "spec": {"containers": [{"name": "app"}]}
498 });
499 assert_eq!(
500 resolve_field(&v, "status.phase"),
501 Some(&serde_json::json!("Running"))
502 );
503 assert_eq!(
504 resolve_field(&v, "spec.containers[0].name"),
505 Some(&serde_json::json!("app"))
506 );
507 assert_eq!(resolve_field(&v, "status.nope"), None);
508 }
509
510 #[test]
511 fn evaluate_status_first_match_wins() {
512 let mut vd = valid();
513 vd.status = vec![
514 StatusRule {
515 field: "status.phase".into(),
516 op: RuleOp::Eq,
517 value: serde_json::json!("Running"),
518 level: crate::render::StatusLevel::Ok,
519 },
520 StatusRule {
521 field: "status.phase".into(),
522 op: RuleOp::Ne,
523 value: serde_json::json!("Running"),
524 level: crate::render::StatusLevel::Warning,
525 },
526 ];
527 let running = serde_json::json!({"status": {"phase": "Running"}});
528 assert_eq!(
529 evaluate_status(&vd, &running),
530 Some(crate::render::StatusLevel::Ok)
531 );
532 let pending = serde_json::json!({"status": {"phase": "Pending"}});
533 assert_eq!(
534 evaluate_status(&vd, &pending),
535 Some(crate::render::StatusLevel::Warning)
536 );
537 let empty = serde_json::json!({});
538 assert_eq!(evaluate_status(&vd, &empty), None);
539 }
540
541 #[test]
542 fn numeric_rule_compares_numerically() {
543 let mut vd = valid();
544 vd.status = vec![StatusRule {
545 field: "spec.replicas".into(),
546 op: RuleOp::Gt,
547 value: serde_json::json!(1),
548 level: crate::render::StatusLevel::Warning,
549 }];
550 let three = serde_json::json!({"spec": {"replicas": 3}});
551 assert_eq!(
552 evaluate_status(&vd, &three),
553 Some(crate::render::StatusLevel::Warning)
554 );
555 let one = serde_json::json!({"spec": {"replicas": 1}});
556 assert_eq!(evaluate_status(&vd, &one), None);
557 }
558
559 #[test]
560 fn contains_rule_matches_substring() {
561 let mut vd = valid();
562 vd.status = vec![StatusRule {
563 field: "status.message".into(),
564 op: RuleOp::Contains,
565 value: serde_json::json!("back-off"),
566 level: crate::render::StatusLevel::Error,
567 }];
568 let msg = serde_json::json!({"status": {"message": "back-off pulling image"}});
569 assert_eq!(
570 evaluate_status(&vd, &msg),
571 Some(crate::render::StatusLevel::Error)
572 );
573 }
574}