1mod recheck;
4pub use recheck::observe_cases;
5mod observed;
6pub use observed::{plan_comment_observed, plan_status_observed};
7
8use crate::alerts;
9use crate::alerts_ops::source_str;
10use crate::cases::{self, Case, CaseStatus, NewCase};
11use crate::profiles;
12use elasticctl_core::{Error, ErrorKind, Result, Transport, urlencode};
13use serde_json::Value;
14use std::collections::{BTreeMap, BTreeSet};
15
16pub const PAGE_SIZE: u32 = 100;
18
19#[derive(Debug, Clone, Default)]
20pub struct CaseFilter {
21 pub status: Option<CaseStatus>,
22 pub severity: Option<String>,
23 pub tag: Option<String>,
24 pub search: Option<String>,
26}
27
28pub fn find_query(f: &CaseFilter, page: u32, per_page: u32) -> String {
31 let mut q = format!("page={page}&perPage={per_page}&sortField=createdAt&sortOrder=desc");
32 if let Some(status) = f.status {
33 q.push_str(&format!("&status={}", status.as_str()));
34 }
35 if let Some(severity) = &f.severity {
36 q.push_str(&format!("&severity={}", urlencode(severity)));
37 }
38 if let Some(tag) = &f.tag {
39 q.push_str(&format!("&tags={}", urlencode(tag)));
40 }
41 if let Some(search) = &f.search {
42 q.push_str(&format!(
43 "&search={}&searchFields=title&searchFields=description",
44 urlencode(search)
45 ));
46 }
47 q
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct CaseList {
52 pub cases: Vec<Case>,
53 pub total: u64,
54 pub truncated: bool,
55}
56
57pub async fn list(t: &Transport, f: &CaseFilter, limit: usize) -> Result<CaseList> {
60 let mut cases = Vec::new();
61 let mut total = 0;
62 let mut page = 1;
63 while cases.len() <= limit {
64 let (batch, batch_total) = cases::find_page(t, &find_query(f, page, PAGE_SIZE)).await?;
65 total = batch_total;
66 let got = batch.len();
67 cases.extend(batch);
68 if got < PAGE_SIZE as usize {
69 break;
70 }
71 page += 1;
72 }
73 let truncated = cases.len() > limit;
74 cases.truncate(limit);
75 Ok(CaseList {
76 cases,
77 total,
78 truncated,
79 })
80}
81
82pub async fn export(t: &Transport, f: &CaseFilter, limit: Option<usize>) -> Result<Vec<Case>> {
84 export_with_page_size(t, f, PAGE_SIZE, limit).await
85}
86
87pub async fn export_with_page_size(
91 t: &Transport,
92 f: &CaseFilter,
93 per_page: u32,
94 limit: Option<usize>,
95) -> Result<Vec<Case>> {
96 let mut all = Vec::new();
97 let mut page = 1;
98 loop {
99 let (batch, _) = cases::find_page(t, &find_query(f, page, per_page)).await?;
100 let got = batch.len();
101 all.extend(batch);
102 if let Some(limit) = limit
103 && all.len() >= limit
104 {
105 all.truncate(limit);
106 return Ok(all);
107 }
108 if got < per_page as usize {
109 return Ok(all);
110 }
111 page += 1;
112 }
113}
114
115pub async fn get_one(t: &Transport, id: &str) -> Result<Case> {
116 cases::get(t, id).await
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ResolvedCase {
121 pub id: String,
122 pub version: String,
123 pub title: String,
124 pub status: String,
125}
126
127fn case_noun(n: usize) -> &'static str {
128 if n == 1 { "case" } else { "cases" }
129}
130
131async fn resolve_cases(t: &Transport, ids: &[String]) -> Result<Vec<ResolvedCase>> {
134 Ok(resolve_case_values(t, ids)
135 .await?
136 .into_iter()
137 .map(|case| ResolvedCase {
138 id: case.id,
139 version: case.version,
140 title: case.title,
141 status: case.status,
142 })
143 .collect())
144}
145
146async fn resolve_case_values(t: &Transport, ids: &[String]) -> Result<Vec<Case>> {
147 let mut unique: Vec<String> = Vec::with_capacity(ids.len());
148 for id in ids {
149 if !unique.contains(id) {
150 unique.push(id.clone());
151 }
152 }
153 let mut resolved = Vec::with_capacity(unique.len());
154 let mut missing = Vec::new();
155 for id in &unique {
156 match cases::get(t, id).await {
157 Ok(case) => resolved.push(case),
158 Err(e) if e.kind == ErrorKind::NotFound => missing.push(id.clone()),
159 Err(e) => return Err(e),
160 }
161 }
162 if !missing.is_empty() {
163 return Err(Error::new(
164 ErrorKind::NotFound,
165 format!("No case with id: {}", missing.join(", ")),
166 ));
167 }
168 Ok(resolved)
169}
170
171pub fn case_row(case: &Case) -> Value {
174 let mut row = serde_json::Map::new();
175 row.insert("id".into(), Value::String(case.id.clone()));
176 row.insert("title".into(), Value::String(case.title.clone()));
177 row.insert("status".into(), Value::String(case.status.clone()));
178 if let Some(severity) = &case.severity {
179 row.insert("severity".into(), Value::String(severity.clone()));
180 }
181 row.insert("tags".into(), serde_json::json!(case.tags));
182 if let Some(n) = case.total_comment {
183 row.insert("comments".into(), serde_json::json!(n));
184 }
185 if let Some(at) = &case.created_at {
186 row.insert("created_at".into(), Value::String(at.clone()));
187 }
188 if let Some(at) = &case.updated_at {
189 row.insert("updated_at".into(), Value::String(at.clone()));
190 }
191 Value::Object(row)
192}
193
194#[derive(Debug, Clone, PartialEq, serde::Serialize)]
195pub struct CaseEditReport {
196 pub applied: bool,
197 pub total: u64,
198 pub updated: u64,
199 pub failed: u64,
202 pub failures: Vec<String>,
207}
208
209#[derive(Debug, Clone, PartialEq)]
210pub struct CreatePlan {
211 pub new: NewCase,
212 pub preview_action: String,
213 pub preview_details: Vec<String>,
214}
215
216pub async fn plan_create(
217 t: &Transport,
218 title: &str,
219 description: Option<String>,
220 tags: Vec<String>,
221 severity: Option<String>,
222 assignees: &[String],
223) -> Result<CreatePlan> {
224 if title.trim().is_empty() {
225 return Err(Error::new(
226 ErrorKind::Error,
227 "a case needs a non-empty --title",
228 ));
229 }
230 let mut details = Vec::new();
231 if let Some(severity) = &severity {
232 details.push(format!("severity: {severity}"));
233 }
234 if !tags.is_empty() {
235 details.push(format!("tags: {}", tags.join(", ")));
236 }
237 let mut assignee_uids = Vec::with_capacity(assignees.len());
238 for user in assignees {
239 let uid = profiles::resolve_assignee(t, user).await?;
240 details.push(format!("assign {user} -> {uid}"));
241 assignee_uids.push(uid);
242 }
243 Ok(CreatePlan {
244 preview_action: format!("Create case '{title}'"),
245 new: NewCase {
246 title: title.to_string(),
247 description,
248 tags,
249 severity,
250 assignee_uids,
251 },
252 preview_details: details,
253 })
254}
255
256pub async fn apply_create(t: &Transport, plan: &CreatePlan) -> Result<Value> {
257 let case = cases::create(t, &plan.new).await?;
258 let mut row = case_row(&case);
259 if let Some(obj) = row.as_object_mut() {
260 obj.insert("applied".into(), Value::Bool(true));
261 }
262 Ok(row)
263}
264
265#[derive(Debug, Clone, PartialEq)]
266pub struct StatusPlan {
267 pub target: CaseStatus,
268 pub updates: Vec<(String, String, CaseStatus)>,
270 pub resolved: usize,
273 pub preview_action: String,
274 pub preview_details: Vec<String>,
275}
276
277pub async fn plan_status(t: &Transport, ids: &[String], target: CaseStatus) -> Result<StatusPlan> {
280 Ok(plan_status_observed(t, ids, target).await?.0)
281}
282
283pub async fn apply_status(t: &Transport, plan: &StatusPlan) -> Result<CaseEditReport> {
284 if plan.updates.is_empty() {
285 return Ok(CaseEditReport {
286 applied: true,
287 total: 0,
288 updated: 0,
289 failed: 0,
290 failures: Vec::new(),
291 });
292 }
293 let mut expected = BTreeMap::new();
294 for (id, _, status) in &plan.updates {
295 if *status != plan.target {
296 return Err(Error::new(
297 ErrorKind::Error,
298 format!(
299 "case '{id}' status '{}' does not match plan target '{}'",
300 status.as_str(),
301 plan.target.as_str()
302 ),
303 ));
304 }
305 if expected.insert(id.as_str(), *status).is_some() {
306 return Err(Error::new(
307 ErrorKind::Error,
308 format!("duplicate case id in status plan: '{id}'"),
309 ));
310 }
311 }
312 let updated = cases::patch_status(t, &plan.updates).await.map_err(|e| {
313 if e.kind == ErrorKind::Conflict {
314 let mut wrapped = Error::new(
315 ErrorKind::Conflict,
316 format!(
317 "a case changed since the preview ({}); re-run the command",
318 e.message
319 ),
320 );
321 wrapped.http_status = e.http_status;
322 wrapped
323 } else {
324 e
325 }
326 })?;
327 if updated.len() != expected.len() {
328 return Err(Error::new(
329 ErrorKind::Http,
330 format!(
331 "decoding case status response: expected {} cases, got {}",
332 expected.len(),
333 updated.len()
334 ),
335 ));
336 }
337 let mut returned_ids = BTreeSet::new();
338 for case in &updated {
339 if !returned_ids.insert(case.id.as_str()) {
340 return Err(Error::new(
341 ErrorKind::Http,
342 format!(
343 "decoding case status response: duplicate case id '{}'",
344 case.id
345 ),
346 ));
347 }
348 let requested_status = expected.get(case.id.as_str()).ok_or_else(|| {
349 Error::new(
350 ErrorKind::Http,
351 format!(
352 "decoding case status response: unexpected case id '{}'",
353 case.id
354 ),
355 )
356 })?;
357 if case.status != requested_status.as_str() {
358 return Err(Error::new(
359 ErrorKind::Http,
360 format!(
361 "decoding case status response: case '{}' has status '{}', expected '{}'",
362 case.id,
363 case.status,
364 requested_status.as_str()
365 ),
366 ));
367 }
368 }
369 let total = expected.len() as u64;
370 Ok(CaseEditReport {
371 applied: true,
372 total,
373 updated: total,
374 failed: 0,
375 failures: Vec::new(),
376 })
377}
378
379#[derive(Debug, Clone, PartialEq)]
380pub struct DeletePlan {
381 pub targets: Vec<String>,
382 pub preview_action: String,
383 pub preview_details: Vec<String>,
384}
385
386pub async fn plan_delete(t: &Transport, ids: &[String]) -> Result<DeletePlan> {
388 let resolved = resolve_cases(t, ids).await?;
389 Ok(DeletePlan {
390 preview_action: format!(
391 "Delete {} {} permanently",
392 resolved.len(),
393 case_noun(resolved.len())
394 ),
395 preview_details: resolved
396 .iter()
397 .map(|c| format!("{} {} ({})", c.id, c.title, c.status))
398 .collect(),
399 targets: resolved.into_iter().map(|c| c.id).collect(),
400 })
401}
402
403pub async fn apply_delete(t: &Transport, plan: &DeletePlan) -> Result<CaseEditReport> {
404 cases::delete(t, &plan.targets).await?;
405 Ok(CaseEditReport {
406 applied: true,
407 total: plan.targets.len() as u64,
408 updated: plan.targets.len() as u64,
409 failed: 0,
410 failures: Vec::new(),
411 })
412}
413
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct AttachGroup {
416 pub rule_id: String,
417 pub rule_name: String,
418 pub alert_ids: Vec<String>,
419 pub indices: Vec<String>,
420}
421
422#[derive(Debug, Clone, PartialEq)]
423pub struct AttachPlan {
424 pub case_id: String,
425 pub groups: Vec<AttachGroup>,
426 pub resolved: usize,
429 pub preview_action: String,
430 pub preview_details: Vec<String>,
431}
432
433pub async fn plan_attach(t: &Transport, case_id: &str, alert_ids: &[String]) -> Result<AttachPlan> {
437 if alert_ids.is_empty() {
438 return Err(Error::new(ErrorKind::Error, "pass at least one --alert id"));
439 }
440 let case = cases::get(t, case_id).await?;
441 let mut unique: Vec<String> = Vec::with_capacity(alert_ids.len());
442 for id in alert_ids {
443 if !unique.contains(id) {
444 unique.push(id.clone());
445 }
446 }
447 let body = serde_json::json!({
448 "query": {"ids": {"values": unique}},
449 "size": unique.len(),
450 "_source": ["kibana.alert.rule.name", "kibana.alert.rule.uuid", "kibana.alert.workflow_status"],
451 });
452 let page = alerts::search(t, &body).await?;
453 let missing: Vec<&str> = unique
454 .iter()
455 .filter(|id| !page.hits.iter().any(|h| &h.id == *id))
456 .map(String::as_str)
457 .collect();
458 if !missing.is_empty() {
459 return Err(Error::new(
460 ErrorKind::NotFound,
461 format!("No alert with id: {}", missing.join(", ")),
462 ));
463 }
464 let mut groups: Vec<AttachGroup> = Vec::new();
465 let mut details = Vec::new();
466 for id in &unique {
467 let hit = page
468 .hits
469 .iter()
470 .find(|h| &h.id == id)
471 .expect("checked above");
472 let rule_id = source_str(&hit.source, "kibana.alert.rule.uuid")
473 .ok_or_else(|| {
474 Error::new(
475 ErrorKind::Http,
476 "decoding alert field `kibana.alert.rule.uuid`",
477 )
478 })?
479 .to_string();
480 let rule_name = source_str(&hit.source, "kibana.alert.rule.name")
481 .unwrap_or("(unnamed rule)")
482 .to_string();
483 let index = hit.index.clone().ok_or_else(|| {
484 Error::new(
485 ErrorKind::Http,
486 "decoding alert field `_index` (needed to attach)",
487 )
488 })?;
489 details.push(format!("{} {}", id, rule_name));
490 match groups.iter_mut().find(|g| g.rule_id == rule_id) {
491 Some(group) => {
492 group.alert_ids.push(id.clone());
493 group.indices.push(index);
494 }
495 None => groups.push(AttachGroup {
496 rule_id,
497 rule_name,
498 alert_ids: vec![id.clone()],
499 indices: vec![index],
500 }),
501 }
502 }
503 Ok(AttachPlan {
504 preview_action: format!(
505 "Attach {} {} to case '{}'",
506 unique.len(),
507 if unique.len() == 1 { "alert" } else { "alerts" },
508 case.title
509 ),
510 case_id: case.id,
511 groups,
512 resolved: unique.len(),
513 preview_details: details,
514 })
515}
516
517pub async fn apply_attach(t: &Transport, plan: &AttachPlan) -> Result<CaseEditReport> {
524 let total = plan.resolved as u64;
525 let mut attached = 0u64;
526 let mut failures = Vec::new();
527 for group in &plan.groups {
528 match cases::attach_alerts(
529 t,
530 &plan.case_id,
531 &group.alert_ids,
532 &group.indices,
533 &group.rule_id,
534 &group.rule_name,
535 )
536 .await
537 {
538 Ok(_) => attached += group.alert_ids.len() as u64,
539 Err(e) => failures.push(format!("{}: {}", group.rule_name, e.message)),
540 }
541 }
542 Ok(CaseEditReport {
543 applied: true,
544 total,
545 updated: attached,
546 failed: total.abs_diff(attached),
550 failures,
551 })
552}
553
554#[derive(Debug, Clone, PartialEq)]
555pub struct CommentPlan {
556 pub case_id: String,
557 pub message: String,
558 pub preview_action: String,
559 pub preview_details: Vec<String>,
560}
561
562pub async fn plan_comment(t: &Transport, case_id: &str, message: &str) -> Result<CommentPlan> {
563 Ok(plan_comment_observed(t, case_id, message).await?.0)
564}
565
566pub async fn apply_comment(t: &Transport, plan: &CommentPlan) -> Result<CaseEditReport> {
567 cases::add_comment(t, &plan.case_id, &plan.message).await?;
568 Ok(CaseEditReport {
569 applied: true,
570 total: 1,
571 updated: 1,
572 failed: 0,
573 failures: Vec::new(),
574 })
575}