Skip to main content

elasticctl_api/
cases_ops.rs

1//! Case orchestration: filters, list/get, and the guarded mutation plans.
2
3mod 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
16/// The `_find` route's per-page cap.
17pub 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    /// Matches title and description server-side.
25    pub search: Option<String>,
26}
27
28/// Deterministic query string for `GET /api/cases/_find`. Key order is
29/// fixed so tests and fixtures are stable.
30pub 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
57/// One bounded peek: page until `limit + 1` rows are in hand or the server
58/// runs out, then truncate.
59pub 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
82/// The `--out` path: every page, unless `limit` stops it early.
83pub 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
87/// The paging loop with an explicit page size, exposed for tests. Stops
88/// paging as soon as `limit` rows are in hand, mirroring
89/// `alerts_ops::export`'s shape.
90pub 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
131/// Resolve explicit case ids. Fail-closed: every id must resolve or nothing
132/// proceeds; duplicates collapse preserving first-seen order.
133async 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
171/// The compact case row for list output and mutation reports: stable columns
172/// in a fixed order (`preserve_order` makes this the render contract).
173pub 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    /// `render::exit_code_for_value` keys on this field: a positive count
200    /// exits 1. Field order is the rendered JSON key order (`preserve_order`).
201    pub failed: u64,
202    /// One entry per failed unit of work (currently only `apply_attach`'s
203    /// per-rule-group comment POSTs), naming what failed and why. Empty for
204    /// every other mutation's report. Appended after `failed` so existing
205    /// consumers of the field order are unaffected.
206    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    /// Only the cases actually transitioning: (id, version, target).
269    pub updates: Vec<(String, String, CaseStatus)>,
270    /// The resolved, deduplicated case count the preview names — what a
271    /// dry-run stub must report, not the raw argv count.
272    pub resolved: usize,
273    pub preview_action: String,
274    pub preview_details: Vec<String>,
275}
276
277/// Fetch each case for its version, mark already-in-state rows, and PATCH
278/// only the rest. A no-op set still previews and reports zero updates.
279pub 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
386/// The 0.4 area's only destructive verb: the preview names each title.
387pub 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    /// The resolved, deduplicated alert count the preview names — what a
427    /// dry-run stub must report, not the raw argv count.
428    pub resolved: usize,
429    pub preview_action: String,
430    pub preview_details: Vec<String>,
431}
432
433/// Resolve the case (for its title) and every alert (id, index, rule),
434/// fail-closed on any missing alert, then group by rule — the comments route
435/// takes one `rule` object per comment.
436pub 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
517/// One comments POST per rule group (the API takes one `rule` per comment).
518/// A failed group must not discard the groups that already attached: a `?`
519/// on the first error would report only the raw error while leaving earlier
520/// groups attached, and a retry would then double-attach them. Accumulate
521/// per-group outcomes instead, so a partial failure renders as counts plus
522/// per-group detail rather than an opaque error.
523pub 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        // `abs_diff`, not `saturating_sub`: a surplus (more alerts attached
547        // than the plan resolved) is a mismatch too, and `total - attached`
548        // would saturate that at 0 and read it as zero failures.
549        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}