Skip to main content

elasticctl_api/fleet/
integration_policies.rs

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