Skip to main content

elasticctl_api/
cases_ops.rs

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}