Skip to main content

elasticctl_api/
rules_ops.rs

1//! Rules command orchestration, above the endpoint wrappers in `rules`.
2//!
3//! The `-cli` crate resolves context, applies the guard, and renders; this
4//! module does the work between them so a future MCP server can call the same
5//! functions and serialize the same structs.
6
7use crate::codec::{self, Format};
8use crate::model::{ListKey, Rule, exception_refs, server_defaults};
9use crate::normalize;
10use crate::ops::{DeleteOutcome, ExportOutcome, ImportPlan, ImportReport, MutationPlan};
11use crate::rules::{self, BulkAction, RuleFilter, RuleSource};
12use crate::selection;
13use elasticctl_core::{Error, ErrorKind, Result, Transport};
14use serde::Serialize;
15use serde_json::{Value, json};
16use std::path::Path;
17
18/// Kibana validates an exception pointer as a string before it re-resolves the
19/// reference against the target stack. This deterministic placeholder is
20/// upload-only: the server replaces it with the live container id.
21const EXCEPTION_POINTER_PLACEHOLDER: &str = "00000000-0000-0000-0000-000000000000";
22
23/// The report `list` renders: every matching rule, in server order.
24#[derive(Debug, Clone, PartialEq, Serialize)]
25pub struct RuleListReport {
26    pub total: usize,
27    pub rules: Vec<Rule>,
28}
29
30/// The report an `enable`/`disable` apply renders. Field order is the
31/// serialized JSON key order and is contractual: the root `Cargo.toml` enables
32/// `serde_json`'s `preserve_order`.
33#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct SetEnabledOutcome {
35    pub applied: bool,
36    pub succeeded: u64,
37    pub failed: u64,
38    pub skipped: u64,
39    pub total: u64,
40}
41
42/// One rule's validation entry.
43#[derive(Debug, Clone, PartialEq, Serialize)]
44pub struct RuleValidation {
45    pub rule_id: String,
46    pub name: String,
47    #[serde(rename = "type")]
48    pub rule_type: String,
49    pub defaults_applied: Vec<String>,
50}
51
52/// The report `validate` renders.
53#[derive(Debug, Clone, PartialEq, Serialize)]
54pub struct ValidateReport {
55    pub valid: bool,
56    pub count: usize,
57    pub rules: Vec<RuleValidation>,
58}
59
60/// The report a preview renders.
61#[derive(Debug, Clone, PartialEq, Serialize)]
62pub struct PreviewReport {
63    pub rule: String,
64    pub preview_id: Option<String>,
65    pub invocations: u32,
66    pub hits: Option<u64>,
67    pub errors: Vec<String>,
68    pub warnings: Vec<String>,
69    pub hits_error: Option<String>,
70    pub sample: Vec<Value>,
71}
72
73pub async fn list(t: &Transport, filter: &RuleFilter) -> Result<RuleListReport> {
74    let rules = rules::find_all(t, filter).await?;
75    if rules.is_empty() && is_unselected_source_query(filter) {
76        rules::verify_source_partition(t).await?;
77    }
78    let total = rules.len();
79    Ok(RuleListReport { total, rules })
80}
81
82/// A `rules list` filter has no positional selectors. Any remaining clause
83/// deliberately narrows the result and must not trigger a corpus proof.
84fn is_unselected_source_query(filter: &RuleFilter) -> bool {
85    matches!(filter.source, RuleSource::Custom | RuleSource::Prebuilt)
86        && filter.enabled.is_none()
87        && filter.rule_type.is_none()
88        && filter.severity.is_none()
89        && filter.tag.is_none()
90        && filter.name.is_none()
91        && filter.query.is_none()
92        && filter.search.is_none()
93}
94
95/// Resolve a selector and fetch the rule, canonicalized so output is stable.
96pub async fn get_one(t: &Transport, selector: &str) -> Result<Rule> {
97    let rule_id = selection::to_rule_id(t, selector).await?;
98    let rule = rules::get(t, &rule_id).await?;
99    Ok(normalize::canonical(&rule))
100}
101
102/// Parse and validate a local file without contacting a server.
103///
104/// The codecs reject missing or non-string `rule_id` values. Keep the
105/// per-rule check because derived `Rule::Deserialize` bypasses that
106/// validation. Check every rule so one report names every invalid index.
107pub fn validate(path: &Path) -> Result<ValidateReport> {
108    let body = std::fs::read_to_string(path)
109        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
110
111    let rules = match Format::from_path(path) {
112        Format::Yaml => codec::decode_yaml(&body)?,
113        Format::Ndjson => codec::decode_ndjson(&body)?.0,
114    };
115
116    let defaults = server_defaults();
117    let mut reports = Vec::with_capacity(rules.len());
118    let mut failures = Vec::new();
119
120    for (i, r) in rules.iter().enumerate() {
121        match r.rule_id() {
122            Ok(rule_id) => {
123                // Show server defaults applied to sparse rules.
124                let mut applied: Vec<String> = defaults
125                    .keys()
126                    .filter(|k| !r.as_map().contains_key(*k))
127                    .cloned()
128                    .collect();
129                applied.sort();
130                reports.push(RuleValidation {
131                    rule_id: rule_id.to_string(),
132                    name: r.name().to_string(),
133                    rule_type: r.rule_type().to_string(),
134                    defaults_applied: applied,
135                });
136            }
137            // Do not report a blank rule ID as valid.
138            Err(e) => failures.push(format!("rule at index {i}: {}", e.message)),
139        }
140    }
141
142    if !failures.is_empty() {
143        // An invalid rule invalidates the file. Return a classified error,
144        // not a partial success payload.
145        return Err(Error::new(ErrorKind::Error, failures.join("; ")));
146    }
147
148    Ok(ValidateReport {
149        valid: true,
150        count: rules.len(),
151        rules: reports,
152    })
153}
154
155/// Resolve every selector to its rule ID and rule before previewing, so the
156/// preview is accurate and unresolved selectors fail before mutation.
157async fn resolve_targets(t: &Transport, selectors: &[String]) -> Result<Vec<(String, Rule)>> {
158    let mut out = Vec::with_capacity(selectors.len());
159    for s in selectors {
160        let rule_id = selection::to_rule_id(t, s).await?;
161        let rule = rules::get(t, &rule_id).await?;
162        out.push((rule_id, rule));
163    }
164    Ok(out)
165}
166
167pub async fn plan_set_enabled(
168    t: &Transport,
169    selectors: &[String],
170    enable: bool,
171) -> Result<MutationPlan> {
172    let resolved = resolve_targets(t, selectors).await?;
173    let preview_details = resolved
174        .iter()
175        .map(|(id, r)| {
176            let from = if r.enabled() { "enabled" } else { "disabled" };
177            let to = if enable { "enabled" } else { "disabled" };
178            format!("{id}  {}  {from} -> {to}", r.name())
179        })
180        .collect();
181    let verb = if enable { "Enable" } else { "Disable" };
182    Ok(MutationPlan {
183        preview_action: format!("{verb} {} rule(s)", resolved.len()),
184        preview_details,
185        targets: resolved.into_iter().map(|(id, _)| id).collect(),
186    })
187}
188
189pub async fn apply_set_enabled(
190    t: &Transport,
191    plan: &MutationPlan,
192    enable: bool,
193) -> Result<SetEnabledOutcome> {
194    let action = if enable {
195        BulkAction::Enable
196    } else {
197        BulkAction::Disable
198    };
199    let o = rules::bulk_by_rule_ids(t, action, &plan.targets, false).await?;
200    Ok(SetEnabledOutcome {
201        applied: true,
202        succeeded: o.succeeded,
203        failed: o.failed,
204        skipped: o.skipped,
205        total: o.total,
206    })
207}
208
209pub async fn plan_delete(t: &Transport, selectors: &[String]) -> Result<MutationPlan> {
210    let resolved = resolve_targets(t, selectors).await?;
211    let preview_details = resolved
212        .iter()
213        .map(|(id, r)| format!("{id}  {}", r.name()))
214        .collect();
215    Ok(MutationPlan {
216        preview_action: format!("Delete {} rule(s)", resolved.len()),
217        preview_details,
218        targets: resolved.into_iter().map(|(id, _)| id).collect(),
219    })
220}
221
222/// Continue after per-rule failures so the result records every deletion and
223/// every rule that remains.
224pub async fn apply_delete(t: &Transport, plan: &MutationPlan) -> Result<DeleteOutcome> {
225    let mut deleted = Vec::new();
226    let mut failed = Vec::new();
227    for id in &plan.targets {
228        match rules::delete(t, id).await {
229            Ok(_) => deleted.push(json!({"rule_id": id})),
230            Err(e) => failed.push(json!({"rule_id": id, "error": e.message})),
231        }
232    }
233    Ok(DeleteOutcome {
234        applied: true,
235        deleted,
236        failed,
237        total: plan.targets.len(),
238    })
239}
240
241/// Fetch, canonicalize, and sort selected rules by rule ID. Exports from an
242/// unchanged stack are byte-identical for version-control review. Scoped by
243/// `source`; the `all` default lives on the clap flag (spec 5.5).
244///
245/// A source scope without a selector or tag resolves to the matching rule IDs
246/// first, so the subset export transfers only the subset (spec 4.3). A selector
247/// or tag is an explicit narrowing and overrides the source default, matching
248/// the state commands (spec 5.3).
249pub async fn export_rules(
250    t: &Transport,
251    selectors: &[String],
252    tag: Option<&str>,
253    source: RuleSource,
254    format: Format,
255) -> Result<ExportOutcome> {
256    let selection: Option<Vec<String>> =
257        if selectors.is_empty() && tag.is_none() && source != RuleSource::All {
258            let scoped = rules::find_all(
259                t,
260                &RuleFilter {
261                    source,
262                    ..Default::default()
263                },
264            )
265            .await?;
266            if scoped.is_empty() {
267                if matches!(source, RuleSource::Custom | RuleSource::Prebuilt) {
268                    rules::verify_source_partition(t).await?;
269                }
270                return Ok(ExportOutcome {
271                    body: String::new(),
272                    exported: 0,
273                    missing: Vec::new(),
274                });
275            }
276            Some(
277                scoped
278                    .iter()
279                    .filter_map(|r| r.rule_id().ok().map(str::to_owned))
280                    .collect(),
281            )
282        } else {
283            // Export reads from the stack, so every selector names a server rule.
284            selection::resolve(t, selectors, tag, None, &[], "export").await?
285        };
286
287    let mut bundle = rules::export(t, selection.as_deref()).await?;
288    for r in &mut bundle.rules {
289        *r = normalize::canonical(r);
290    }
291    normalize::sort_rules(&mut bundle.rules);
292
293    let body = match format {
294        // YAML carries rules only. A bundle with exception objects has no YAML
295        // form, so refuse rather than silently drop them (spec 5.2).
296        Format::Yaml => {
297            if !bundle.lists.is_empty() || !bundle.items.is_empty() {
298                return Err(Error::new(
299                    ErrorKind::Unsupported,
300                    format!(
301                        "this export carries {} exception list(s) and {} item(s), which the \
302                         YAML format cannot represent; re-run with --format-file ndjson",
303                        bundle.lists.len(),
304                        bundle.items.len()
305                    ),
306                ));
307            }
308            codec::encode_yaml(&bundle.rules)?
309        }
310        // The bundle's lists and items must survive the export or importing
311        // the file elsewhere recreates a rule pointing at a missing list.
312        Format::Ndjson => codec::encode_bundle(&bundle)?,
313    };
314
315    // A requested but missing rule was deleted after selection. Report it in
316    // `missing` so a short export has a nonzero exit code.
317    let missing = bundle
318        .summary
319        .as_ref()
320        .map(|s| s.missing_rules.clone())
321        .unwrap_or_default();
322
323    Ok(ExportOutcome {
324        body,
325        exported: bundle.rules.len() as u64,
326        missing,
327    })
328}
329
330/// Compute the import preview and the NDJSON to upload.
331///
332/// With `--skip-existing`, check existing rule IDs before the preview so it
333/// shows only rules that would import. That query is a read, so it belongs
334/// here and does not violate the no-write rule. The transport is `None` unless
335/// `skip_existing` is set, so a dry run that only reads the file never needs
336/// one.
337pub async fn plan_import(
338    t: Option<&Transport>,
339    path: &Path,
340    overwrite: bool,
341    skip_existing: bool,
342) -> Result<ImportPlan> {
343    let body = std::fs::read_to_string(path)
344        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
345
346    let format = Format::from_path(path);
347    let mut bundle = match format {
348        // YAML has no exception-bundle representation. Preserve its existing
349        // rules-only behavior, then upload it as Kibana's required NDJSON.
350        Format::Yaml => codec::Bundle {
351            rules: codec::decode_yaml(&body)?,
352            ..Default::default()
353        },
354        // Rules export emits rules, exception containers, and exception items
355        // in one NDJSON bundle. Import must retain all three so Kibana can
356        // recreate containers and resolve their server-owned pointers.
357        Format::Ndjson => codec::decode_bundle(&body)?,
358    };
359    let total = bundle.rules.len();
360
361    let mut skipped: Vec<Value> = Vec::new();
362
363    if skip_existing {
364        let t = t.ok_or_else(|| {
365            Error::new(ErrorKind::Error, "import --skip-existing needs a transport")
366        })?;
367        let ids: Vec<String> = bundle
368            .rules
369            .iter()
370            .filter_map(|r| r.rule_id().ok().map(str::to_owned))
371            .collect();
372        let existing = rules::existing_rule_ids(t, &ids).await?;
373
374        let mut keep = Vec::with_capacity(bundle.rules.len());
375        for rule in std::mem::take(&mut bundle.rules) {
376            match rule.rule_id() {
377                Ok(id) if existing.contains(id) => {
378                    skipped.push(json!({"rule_id": id, "reason": "exists"}));
379                }
380                _ => keep.push(rule),
381            }
382        }
383        bundle.rules = keep;
384    }
385
386    if format == Format::Ndjson {
387        retain_referenced_exception_objects(&mut bundle);
388    }
389    add_upload_pointer_placeholders(&mut bundle);
390
391    let mut details: Vec<String> = bundle
392        .rules
393        .iter()
394        .map(|r| format!("{}  {}  import", r.rule_id().unwrap_or(""), r.name()))
395        .collect();
396    details.extend(skipped.iter().map(|s| {
397        format!(
398            "{}  skip (already exists)",
399            s["rule_id"].as_str().unwrap_or("")
400        )
401    }));
402
403    let qualifier = if overwrite {
404        ", overwriting existing".to_string()
405    } else if skip_existing && !skipped.is_empty() {
406        format!(", skipping {} that already exist", skipped.len())
407    } else {
408        String::new()
409    };
410    let preview = MutationPlan {
411        preview_action: format!(
412            "Import {} rule(s) from {}{qualifier}",
413            bundle.rules.len(),
414            path.display()
415        ),
416        preview_details: details,
417        targets: bundle
418            .rules
419            .iter()
420            .filter_map(|r| r.rule_id().ok().map(str::to_owned))
421            .collect(),
422    };
423
424    // Kibana's import takes NDJSON regardless of the source file's format.
425    // A source NDJSON bundle keeps its exception members; YAML remains rules
426    // only because that format cannot represent containers or items.
427    let ndjson = match format {
428        Format::Yaml => codec::encode_ndjson(&bundle.rules)?,
429        Format::Ndjson => codec::encode_bundle(&bundle)?,
430    };
431
432    Ok(ImportPlan {
433        preview,
434        ndjson,
435        total,
436        skipped,
437    })
438}
439
440/// Keep only containers and items a rule scheduled for upload references.
441/// `rules import` is guarded as a rule mutation, so an exception-only or
442/// unreferenced object must never become an unpreviewed side effect. Namespace
443/// is part of stable list identity, so equal `list_id` values in `single` and
444/// `agnostic` remain disjoint.
445fn retain_referenced_exception_objects(bundle: &mut codec::Bundle) {
446    let wanted: std::collections::BTreeSet<ListKey> = bundle
447        .rules
448        .iter()
449        .flat_map(exception_refs)
450        .map(|reference| ListKey {
451            list_id: reference.list_id,
452            namespace_type: reference.namespace_type,
453        })
454        .collect();
455
456    bundle
457        .lists
458        .retain(|list| list.key().is_ok_and(|key| wanted.contains(&key)));
459    bundle.items.retain(|item| {
460        item.list_id().is_ok_and(|list_id| {
461            wanted.contains(&ListKey {
462                list_id: list_id.to_string(),
463                namespace_type: item.namespace_type().to_string(),
464            })
465        })
466    });
467}
468
469/// Make every readable exception reference acceptable to Kibana's import
470/// schema. This deliberately changes only the transient upload bundle; source
471/// files remain pointer-free and malformed/unreadable entries survive intact.
472fn add_upload_pointer_placeholders(bundle: &mut codec::Bundle) {
473    for rule in &mut bundle.rules {
474        let Some(Value::Array(references)) = rule.as_map_mut().get_mut("exceptions_list") else {
475            continue;
476        };
477        for reference in references {
478            let Value::Object(reference) = reference else {
479                continue;
480            };
481            if reference.get("list_id").is_some_and(Value::is_string) {
482                reference.insert(
483                    "id".to_string(),
484                    Value::String(EXCEPTION_POINTER_PLACEHOLDER.to_string()),
485                );
486            }
487        }
488    }
489}
490
491/// Upload the NDJSON `plan_import` prepared.
492pub async fn apply_import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<ImportReport> {
493    // Do not upload empty NDJSON when every rule already exists.
494    if ndjson.is_empty() {
495        return Ok(ImportReport {
496            succeeded: json!(0),
497            failed: json!([]),
498        });
499    }
500
501    let response = rules::import(t, ndjson, overwrite).await?;
502
503    // Normalize Kibana's response to the bulk-action shape so partial imports
504    // use the existing exit-code rule, refusing a malformed success body.
505    crate::ops::decode_import_report(&response, "rules")
506}
507
508/// Retry once only when the first search finds no hits.
509///
510/// The preview already completed each invocation. A newly written alert can
511/// miss the first search because of Elasticsearch's refresh interval. Retrying
512/// only zero hits avoids delay for matching rules and false zero results.
513async fn fetch_hits(
514    transport: &Transport,
515    space: &str,
516    preview_id: &str,
517    sample: usize,
518) -> Result<rules::PreviewHits> {
519    let first = rules::preview_hits(transport, space, preview_id, sample).await?;
520    if first.total > 0 {
521        return Ok(first);
522    }
523    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
524    rules::preview_hits(transport, space, preview_id, sample).await
525}
526
527/// Preview a rule. An existing file path wins; otherwise the source is a rule
528/// ID or name from the stack. This supports unpushed local rules.
529pub async fn preview_rule(
530    t: &Transport,
531    source: &str,
532    invocations: u32,
533    sample: u32,
534    space: &str,
535) -> Result<PreviewReport> {
536    let path = Path::new(source);
537
538    let rule = if path.exists() {
539        let body = std::fs::read_to_string(path).map_err(|e| {
540            Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display()))
541        })?;
542        let rules = match Format::from_path(path) {
543            Format::Yaml => codec::decode_yaml(&body)?,
544            Format::Ndjson => codec::decode_ndjson(&body)?.0,
545        };
546        rules.into_iter().next().ok_or_else(|| {
547            Error::new(
548                ErrorKind::Error,
549                format!("{} contains no rules", path.display()),
550            )
551        })?
552    } else {
553        let rule_id = selection::to_rule_id(t, source).await?;
554        rules::get(t, &rule_id).await?
555    };
556
557    // The API requires an explicit end of the window it simulates over.
558    let timeframe_end = now_rfc3339();
559    let result = rules::preview(t, &rule, invocations, &timeframe_end).await?;
560
561    // Preserve the preview when reading hits fails. Set `hits` to null and
562    // report the error in `hits_error`.
563    let (hits, hits_error, sample_hits) = match &result.preview_id {
564        None => (
565            None,
566            Some("the server returned no preview_id".to_string()),
567            Vec::new(),
568        ),
569        Some(preview_id) => match fetch_hits(t, space, preview_id, sample as usize).await {
570            Ok(h) => (Some(h.total), None, h.sample),
571            Err(e) => (None, Some(e.message), Vec::new()),
572        },
573    };
574
575    Ok(PreviewReport {
576        rule: rule.name().to_string(),
577        preview_id: result.preview_id,
578        invocations,
579        hits,
580        errors: result.errors,
581        warnings: result.warnings,
582        hits_error,
583        sample: sample_hits,
584    })
585}
586
587fn now_rfc3339() -> String {
588    use std::time::{SystemTime, UNIX_EPOCH};
589    let secs = SystemTime::now()
590        .duration_since(UNIX_EPOCH)
591        .unwrap_or_default()
592        .as_secs();
593    // Format directly to avoid a date dependency; the API accepts UTC ISO-8601.
594    let days = secs / 86_400;
595    let rem = secs % 86_400;
596    let (y, m, d) = civil_from_days(days as i64);
597    format!(
598        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
599        rem / 3600,
600        (rem % 3600) / 60,
601        rem % 60
602    )
603}
604
605/// Howard Hinnant's days-from-civil inverse, used without a date dependency.
606fn civil_from_days(z: i64) -> (i64, u32, u32) {
607    let z = z + 719_468;
608    let era = z.div_euclid(146_097);
609    let doe = z.rem_euclid(146_097);
610    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
611    let y = yoe + era * 400;
612    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
613    let mp = (5 * doy + 2) / 153;
614    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
615    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
616    (if m <= 2 { y + 1 } else { y }, m, d)
617}
618
619#[cfg(test)]
620mod date_tests {
621    use super::*;
622
623    /// Independently computed epoch days catch plausible arithmetic errors
624    /// that would shift a preview's `timeframeEnd`.
625    #[test]
626    fn civil_from_days_matches_independently_computed_epoch_days() {
627        let cases = [
628            (0, (1970, 1, 1), "epoch"),
629            (19782, (2024, 2, 29), "leap day"),
630            (11017, (2000, 3, 1), "century leap year (2000 % 400 == 0)"),
631            (20818, (2026, 12, 31), "year end"),
632            (20819, (2027, 1, 1), "year rollover"),
633            // 2100 is divisible by 100 but not 400, so it is not a leap year.
634            (47541, (2100, 3, 1), "century non-leap (2100 % 400 != 0)"),
635            (
636                47540,
637                (2100, 2, 28),
638                "day before the century non-leap rollover",
639            ),
640        ];
641
642        for (day, expected, label) in cases {
643            assert_eq!(civil_from_days(day), expected, "{label}: day {day}");
644        }
645    }
646
647    /// The API requires this exact `timeframeEnd` format.
648    #[test]
649    fn now_rfc3339_matches_the_shape_the_api_requires() {
650        let s = now_rfc3339();
651        let bytes = s.as_bytes();
652
653        assert_eq!(s.len(), 24, "{s}");
654        assert!(bytes[4] == b'-' && bytes[7] == b'-', "{s}");
655        assert_eq!(bytes[10], b'T', "{s}");
656        assert!(bytes[13] == b':' && bytes[16] == b':', "{s}");
657        assert_eq!(bytes[19], b'.', "{s}");
658        assert_eq!(&s[20..], "000Z", "{s}");
659        assert!(
660            s[..19]
661                .chars()
662                .enumerate()
663                .all(|(i, c)| { matches!(i, 4 | 7 | 10 | 13 | 16) || c.is_ascii_digit() }),
664            "every non-separator position must be a digit: {s}"
665        );
666    }
667}