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