Skip to main content

elasticctl_api/fleet/
integration_policies.rs

1//! Typed integration-policy models and public Fleet route wrappers.
2
3use crate::compose::require_path_segment;
4use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
5use serde::{Deserialize, Deserializer, Serialize};
6use serde_json::{Map, Value};
7
8const BASE: &str = "/api/fleet/package_policies";
9const PACKAGES: &str = "/api/fleet/epm/packages";
10
11/// The exact package coordinate required by a portable integration policy.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct IntegrationPackageSpec {
15    pub name: String,
16    pub version: String,
17}
18
19/// The portable, author-controlled integration-policy representation.
20#[derive(Debug, Clone, PartialEq, Serialize)]
21pub struct IntegrationPolicySpec {
22    pub id: String,
23    pub name: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub description: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub namespace: Option<String>,
28    pub policy_ids: Vec<String>,
29    pub package: IntegrationPackageSpec,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub vars: Option<Map<String, Value>>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub var_group_selections: Option<Map<String, Value>>,
34    pub inputs: Map<String, Value>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub condition: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub additional_datastreams_permissions: Option<Vec<String>>,
39}
40
41#[derive(Deserialize)]
42#[serde(deny_unknown_fields)]
43struct RawIntegrationPolicySpec {
44    id: String,
45    name: String,
46    #[serde(default)]
47    description: Option<String>,
48    #[serde(default)]
49    namespace: Option<String>,
50    policy_ids: Vec<String>,
51    package: IntegrationPackageSpec,
52    #[serde(default)]
53    vars: Option<Map<String, Value>>,
54    #[serde(default)]
55    var_group_selections: Option<Map<String, Value>>,
56    inputs: Map<String, Value>,
57    #[serde(default)]
58    condition: Option<String>,
59    #[serde(default)]
60    additional_datastreams_permissions: Option<Vec<String>>,
61}
62
63impl IntegrationPolicySpec {
64    fn from_raw(raw: RawIntegrationPolicySpec) -> Result<Self> {
65        let spec = Self {
66            id: raw.id,
67            name: raw.name,
68            description: raw.description,
69            namespace: raw.namespace,
70            policy_ids: raw.policy_ids,
71            package: raw.package,
72            vars: raw.vars,
73            var_group_selections: raw.var_group_selections,
74            inputs: raw.inputs,
75            condition: raw.condition,
76            additional_datastreams_permissions: raw.additional_datastreams_permissions,
77        };
78        spec.validate()?;
79        Ok(spec)
80    }
81
82    /// Validate a portable 0.6.1 integration-policy artifact.
83    pub fn validate(&self) -> Result<()> {
84        for (field, value) in [
85            ("id", &self.id),
86            ("name", &self.name),
87            ("package.name", &self.package.name),
88            ("package.version", &self.package.version),
89        ] {
90            if value.trim().is_empty() {
91                return Err(Error::new(
92                    ErrorKind::Error,
93                    format!("integration policy {field} must not be empty"),
94                ));
95            }
96        }
97        if self.policy_ids.is_empty() {
98            return Err(Error::new(
99                ErrorKind::Error,
100                "integration policy policy_ids must not be empty",
101            ));
102        }
103        let mut previous = None;
104        for policy_id in &self.policy_ids {
105            if policy_id.trim().is_empty() {
106                return Err(Error::new(
107                    ErrorKind::Error,
108                    "integration policy policy_ids must not contain an empty id",
109                ));
110            }
111            if previous.is_some_and(|previous: &String| previous >= policy_id) {
112                return Err(Error::new(
113                    ErrorKind::Error,
114                    "integration policy policy_ids must be sorted and duplicate-free",
115                ));
116            }
117            previous = Some(policy_id);
118        }
119        if let Some(selections) = &self.var_group_selections {
120            for (name, selection) in selections {
121                if !selection.is_string() {
122                    return Err(Error::new(
123                        ErrorKind::Error,
124                        format!("integration policy var_group_selections.{name} must be a string"),
125                    ));
126                }
127            }
128        }
129        Ok(())
130    }
131}
132
133impl TryFrom<Value> for IntegrationPolicySpec {
134    type Error = Error;
135
136    fn try_from(value: Value) -> Result<Self> {
137        serde_json::from_value(value).map_err(|error| {
138            Error::new(
139                ErrorKind::Error,
140                format!("decoding integration policy: {error}"),
141            )
142        })
143    }
144}
145
146impl<'de> Deserialize<'de> for IntegrationPolicySpec {
147    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
148    where
149        D: Deserializer<'de>,
150    {
151        let raw = RawIntegrationPolicySpec::deserialize(deserializer)?;
152        Self::from_raw(raw).map_err(serde::de::Error::custom)
153    }
154}
155
156/// A safe integration-policy list row.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158pub struct IntegrationPolicySummary {
159    pub id: String,
160    pub name: String,
161    pub namespace: String,
162    pub description: Option<String>,
163    pub policy_ids: Vec<String>,
164    pub package: IntegrationPackageSpec,
165}
166
167/// Safe single-integration output. Raw Fleet items are never rendered.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
169pub struct IntegrationPolicyDetail {
170    pub id: String,
171    pub name: String,
172    pub namespace: String,
173    pub description: Option<String>,
174    pub policy_ids: Vec<String>,
175    pub package: IntegrationPackageSpec,
176    pub affected_agents: u64,
177    pub blocked_by: Vec<String>,
178}
179
180/// One page of the paginated simplified list route.
181#[derive(Debug, Clone, PartialEq)]
182pub struct IntegrationPolicyPage {
183    pub items: Vec<Map<String, Value>>,
184    pub total: u64,
185    pub page: u64,
186    pub per_page: u64,
187}
188
189/// A single integration as returned by its read, create, or update route.
190#[derive(Debug, Clone, PartialEq)]
191pub struct IntegrationPolicy {
192    pub item: Map<String, Value>,
193}
194
195/// Exact package metadata retained for internal secret classification only.
196#[derive(Debug, Clone, PartialEq)]
197pub struct PackageMetadata {
198    pub(crate) item: Map<String, Value>,
199}
200
201/// Read one page of the simplified list route with deterministic ordering.
202pub async fn list_page(transport: &Transport, page: u64) -> Result<IntegrationPolicyPage> {
203    transport.require_feature(Feature::FleetPolicies).await?;
204    let body = transport
205        .get(&format!(
206            "{BASE}?page={page}&perPage=1000&sortField=created_at&sortOrder=asc&format=simplified"
207        ))
208        .await?;
209    let envelope: PageEnvelope = decode(&body, "integration policies list")?;
210    Ok(IntegrationPolicyPage {
211        items: envelope.items,
212        total: envelope.total,
213        page: envelope.page,
214        per_page: envelope.per_page,
215    })
216}
217
218/// Read one integration in simplified form by a stable id read from a
219/// response or a local file.
220pub async fn get(transport: &Transport, id: &str) -> Result<IntegrationPolicy> {
221    get_selector(transport, require_path_segment(id)?).await
222}
223
224/// Read one integration in simplified form by the caller's own selector, which
225/// keeps the caller's validation.
226pub(crate) async fn get_selector(
227    transport: &Transport,
228    selector: &str,
229) -> Result<IntegrationPolicy> {
230    transport.require_feature(Feature::FleetPolicies).await?;
231    decode_item(
232        &transport
233            .get(&format!("{}?format=simplified", policy_path(selector)))
234            .await?,
235        "integration policy get",
236    )
237}
238
239/// Create an integration from its complete portable specification.
240pub async fn create(
241    transport: &Transport,
242    spec: &IntegrationPolicySpec,
243) -> Result<IntegrationPolicy> {
244    transport.require_feature(Feature::FleetPolicies).await?;
245    spec.validate()?;
246    let body = encode_spec(spec, "create")?;
247    decode_item(
248        &transport.post(BASE, Some(&body)).await?,
249        "integration policy create",
250    )
251}
252
253/// Replace an integration with its complete portable specification.
254pub async fn update(
255    transport: &Transport,
256    id: &str,
257    spec: &IntegrationPolicySpec,
258) -> Result<IntegrationPolicy> {
259    let id = require_path_segment(id)?;
260    transport.require_feature(Feature::FleetPolicies).await?;
261    spec.validate()?;
262    let mut body = encode_spec(spec, "update")?;
263    let object = body
264        .as_object_mut()
265        .expect("integration policy serialization is an object");
266    object.remove("id");
267    decode_item(
268        &transport.put(&policy_path(id), &body).await?,
269        "integration policy update",
270    )
271}
272
273/// Delete one integration by id. Never sends `force`.
274pub async fn delete(transport: &Transport, id: &str) -> Result<()> {
275    let id = require_path_segment(id)?;
276    transport.require_feature(Feature::FleetPolicies).await?;
277    let response = transport.delete(&policy_path(id)).await?;
278    let deleted: DeleteEnvelope = decode(&response, "integration policy delete")?;
279    if deleted.id != id {
280        return Err(Error::with_status(
281            ErrorKind::Http,
282            200,
283            format!(
284                "decoding integration policy delete: expected id '{id}', got '{}'",
285                deleted.id
286            ),
287        ));
288    }
289    Ok(())
290}
291
292/// Read exact package metadata for internal package-secret classification.
293pub async fn package_metadata(
294    transport: &Transport,
295    name: &str,
296    version: &str,
297) -> Result<PackageMetadata> {
298    transport.require_feature(Feature::FleetPolicies).await?;
299    for (field, value) in [("name", name), ("version", version)] {
300        if value.trim().is_empty() {
301            return Err(Error::new(
302                ErrorKind::Error,
303                format!("integration package metadata {field} must not be empty"),
304            ));
305        }
306        require_path_segment(value)?;
307    }
308    let body = transport
309        .get(&format!(
310            "{PACKAGES}/{}/{}",
311            urlencode(name),
312            urlencode(version)
313        ))
314        .await?;
315    let envelope: ItemEnvelope = decode(&body, "integration package metadata")?;
316    let returned_name = required_string(&envelope.item, "name", "integration package metadata")?;
317    let returned_version =
318        required_string(&envelope.item, "version", "integration package metadata")?;
319    if returned_name != name || returned_version != version {
320        return Err(Error::new(
321            ErrorKind::Http,
322            format!(
323                "decoding integration package metadata: expected {name}@{version}, got {returned_name}@{returned_version}"
324            ),
325        ));
326    }
327    Ok(PackageMetadata {
328        item: envelope.item,
329    })
330}
331
332fn encode_spec(spec: &IntegrationPolicySpec, context: &str) -> Result<Value> {
333    serde_json::to_value(spec).map_err(|error| {
334        Error::new(
335            ErrorKind::Error,
336            format!("encoding integration policy {context}: {error}"),
337        )
338    })
339}
340
341fn policy_path(id: &str) -> String {
342    format!("{BASE}/{}", urlencode(id))
343}
344
345fn decode_item(body: &Value, context: &str) -> Result<IntegrationPolicy> {
346    let envelope: ItemEnvelope = decode(body, context)?;
347    Ok(IntegrationPolicy {
348        item: envelope.item,
349    })
350}
351
352fn decode<T: serde::de::DeserializeOwned>(body: &Value, context: &str) -> Result<T> {
353    serde_json::from_value(body.clone())
354        .map_err(|error| Error::new(ErrorKind::Http, format!("decoding {context}: {error}")))
355}
356
357fn required_string(item: &Map<String, Value>, field: &str, context: &str) -> Result<String> {
358    item.get(field)
359        .and_then(Value::as_str)
360        .filter(|value| !value.trim().is_empty())
361        .map(str::to_owned)
362        .ok_or_else(|| {
363            Error::new(
364                ErrorKind::Http,
365                format!("decoding {context}: {field} must be a non-empty string"),
366            )
367        })
368}
369
370#[derive(Deserialize)]
371#[serde(deny_unknown_fields)]
372struct PageEnvelope {
373    items: Vec<Map<String, Value>>,
374    total: u64,
375    page: u64,
376    #[serde(rename = "perPage")]
377    per_page: u64,
378}
379
380#[derive(Deserialize)]
381#[serde(deny_unknown_fields)]
382struct ItemEnvelope {
383    item: Map<String, Value>,
384}
385
386#[derive(Deserialize)]
387#[serde(deny_unknown_fields)]
388struct DeleteEnvelope {
389    id: String,
390}