diff --git a/.buildkite/scripts/common/env.sh b/.buildkite/scripts/common/env.sh
index 511f6ead2d43c..2704f894cf2b6 100755
--- a/.buildkite/scripts/common/env.sh
+++ b/.buildkite/scripts/common/env.sh
@@ -8,6 +8,7 @@ KIBANA_DIR=$(pwd)
export KIBANA_DIR
export XPACK_DIR="$KIBANA_DIR/x-pack"
+export XDG_CACHE_HOME="$HOME/.cache"
export CACHE_DIR="$HOME/.kibana"
export ES_CACHE_DIR="$HOME/.es-snapshot-cache"
PARENT_DIR="$(cd "$KIBANA_DIR/.."; pwd)"
@@ -110,7 +111,6 @@ export TEST_CORS_SERVER_PORT=6105
if [[ "$(which google-chrome-stable)" || "$(which google-chrome)" ]]; then
echo "Chrome detected, setting DETECT_CHROMEDRIVER_VERSION=true"
export DETECT_CHROMEDRIVER_VERSION=true
- export CHROMEDRIVER_FORCE_DOWNLOAD=true
else
echo "Chrome not detected, installing default chromedriver binary for the package version"
fi
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 56fe95cd65b39..c000628cf9c52 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -597,6 +597,7 @@ packages/kbn-management/settings/types @elastic/kibana-management
packages/kbn-management/settings/utilities @elastic/kibana-management
packages/kbn-management/storybook/config @elastic/kibana-management
test/plugin_functional/plugins/management_test_plugin @elastic/kibana-management
+packages/kbn-manifest @elastic/kibana-core
packages/kbn-mapbox-gl @elastic/kibana-presentation
x-pack/examples/third_party_maps_source_example @elastic/kibana-presentation
src/plugins/maps_ems @elastic/kibana-presentation
@@ -929,9 +930,9 @@ packages/kbn-test-eui-helpers @elastic/kibana-visualizations
x-pack/test/licensing_plugin/plugins/test_feature_usage @elastic/kibana-security
packages/kbn-test-jest-helpers @elastic/kibana-operations @elastic/appex-qa
packages/kbn-test-subj-selector @elastic/kibana-operations @elastic/appex-qa
-x-pack/test_serverless
-test
-x-pack/test
+x-pack/test_serverless
+test
+x-pack/test
x-pack/performance @elastic/appex-qa
x-pack/examples/testing_embedded_lens @elastic/kibana-visualizations
x-pack/examples/third_party_lens_navigation_prompt @elastic/kibana-visualizations
diff --git a/dev_docs/key_concepts/api_authorization.mdx b/dev_docs/key_concepts/api_authorization.mdx
new file mode 100644
index 0000000000000..b781808757c9a
--- /dev/null
+++ b/dev_docs/key_concepts/api_authorization.mdx
@@ -0,0 +1,319 @@
+---
+id: kibDevDocsSecurityAPIAuthorization
+slug: /kibana-dev-docs/key-concepts/security-api-authorization
+title: Kibana API authorization
+description: This guide provides an overview of API authorization in Kibana.
+date: 2024-10-04
+tags: ['kibana', 'dev', 'contributor', 'security']
+---
+
+Authorization is an important aspect of API design. It must be considered for all endpoints, even those marked as `internal`. This guide explains how and when to apply authorization to your endpoints
+
+Table of contents:
+1. [API authorization](#api-authorization)
+2. [[Deprecated] Adding API authorization with `access` tags](#deprecated-adding-api-authorization-with-access-tags)
+ - [Why not add `access` tags to all routes by default?](#why-not-add-access-tags-to-all-routes-by-default)
+3. [Adding API authorization with `security` configuration](#adding-api-authorization-with-security-configuration)
+ - [Key features](#key-features)
+ - [Configuring authorization on routes](#configuring-authorization-on-routes)
+ - [Opting out of authorization for specific routes](#opting-out-of-authorization-for-specific-routes)
+ - [Classic router security configuration examples](#classic-router-security-configuration-examples)
+ - [Versioned router security configuration examples](#versioned-router-security-configuration-examples)
+4. [Authorization response available in route handlers](#authorization-response-available-in-route-handlers)
+5. [OpenAPI specification (OAS) documentation](#openapi-specification-oas-documentation)
+6. [Migrating from `access` tags to `security` configuration](#migrating-from-access-tags-to-security-configuration)
+7. [Questions?](#questions)
+
+## API authorization
+Kibana API routes do not have any authorization checks applied by default. This means that your APIs are accessible to anyone with valid credentials, regardless of their permissions. This includes users with no roles assigned.
+This on its own is insufficient, and care must be taken to ensure that only authorized users can invoke your endpoints.
+
+Kibana leverages for a majority of its persistence. The Saved Objects Service performs its own authorization checks, so if your API route is primarily a CRUD interface to Saved Objects, then your authorization needs are likely already met.
+This is also true for derivatives of the Saved Objects Service, such as the Alerting and Cases services.
+
+If your endpoint is not a CRUD interface to Saved Objects, or if your endpoint bypasses our built-in Saved Objects authorization checks, then you must ensure that only authorized users can invoke your endpoint.
+This is **especially** important if your route does any of the following:
+1. Performs non-insignificant processing, causing load on the Elasticsearch cluster or the Kibana server.
+2. Calls Elasticsearch APIs using the internal `kibana_system` user.
+3. Calls a third-party service.
+4. Exposes any non-public information to the caller, such as system configuration or state, as part of the successful or even error response.
+
+## [Deprecated] Adding API authorization with `access` tags
+**Note**: `access` tags were deprecated in favour of `security` configuration.
+
+`access` tags are used to restrict access to API routes. They are used to ensure that only users with the required privileges can access the route.
+
+Example configuration:
+```ts
+router.get({
+ path: '/api/path',
+ options: {
+ tags: ['access:', 'access:'],
+ },
+ ...
+}, handler);
+```
+
+More information on adding `access` tags to your routes can be found temporarily in the [legacy documentation](https://www.elastic.co/guide/en/kibana/current/development-security.html#development-plugin-feature-registration)
+
+### Why not add `access` tags to all routes by default?
+Each authorization check that we perform involves a round-trip to Elasticsearch, so they are not as cheap as we'd like. Therefore, we want to keep the number of authorization checks we perform within a single route to a minimum.
+Adding an `access` tag to routes that leverage the Saved Objects Service would be redundant in most cases, since the Saved Objects Service will be performing authorization anyway.
+
+
+## Adding API authorization with `security` configuration
+`KibanaRouteOptions` provides a security configuration at the route definition level, offering robust security configurations for both **Classic** and **Versioned** routes.
+
+### Key features:
+1. **Fine-grained control**:
+ - Define the exact privileges required to access the route.
+ - Use `requiredPrivileges` to specify privileges with support for complex rules:
+ - **AND rules** using `allRequired`: Requires all specified privileges for access.
+ - **OR rules** using `anyRequired`: Allows access if any one of the specified privileges is met.
+ - **Complex Nested Rules**: Combine both `allRequired` and `anyRequired` for advanced access rules.
+2. **Explicit Opt-out**: Provide a reason for opting out of authorization to maintain transparency.
+3. **Versioned Routes**: Define security configurations for different versions of the same route.
+4. **Improved Documentation with OpenAPI (OAS)**: Automatically generated OAS documentation with the required privileges for each route.
+5. **AuthzResult Object in Route Handlers**: Access the authorization response in route handlers to see which privileges were met.
+
+
+### Configuring authorization on routes
+**Before migration:**
+```ts
+router.get({
+ path: '/api/path',
+ options: {
+ tags: ['access:', 'access:'],
+ },
+ ...
+}, handler);
+```
+
+**After migration:**
+```ts
+router.get({
+ path: '/api/path',
+ security: {
+ authz: {
+ requiredPrivileges: ['', ''],
+ },
+ },
+ ...
+}, handler);
+```
+
+### Opting out of authorization for specific routes
+**Before migration:**
+```ts
+router.get({
+ path: '/api/path',
+ ...
+}, handler);
+```
+
+**After migration:**
+```ts
+router.get({
+ path: '/api/path',
+ security: {
+ authz: {
+ enabled: false,
+ reason: 'This route is opted out from authorization because ...',
+ },
+ },
+ ...
+}, handler);
+```
+
+### Classic router security configuration examples
+
+**Example 1: All privileges required.**
+Requires `` AND `` to access the route.
+```ts
+router.get({
+ path: '/api/path',
+ security: {
+ authz: {
+ requiredPrivileges: ['', ''],
+ },
+ },
+ ...
+}, handler);
+```
+
+**Example 2: Any privileges required.**
+Requires `` OR `` to access the route.
+```ts
+router.get({
+ path: '/api/path',
+ security: {
+ authz: {
+ requiredPrivileges: [{ anyRequired: ['', ''] }],
+ },
+ },
+ ...
+}, handler);
+```
+
+**Example 3: Complex configuration.**
+Requires `` AND `` AND (`` OR ``) to access the route.
+```ts
+router.get({
+ path: '/api/path',
+ security: {
+ authz: {
+ requiredPrivileges: [{ allRequired: ['', ''], anyRequired: ['', ''] }],
+ },
+ },
+ ...
+}, handler);
+```
+
+### Versioned router security configuration examples
+Different security configurations can be applied to each version when using the Versioned Router. This allows your authorization needs to evolve in lockstep with your API.
+
+**Example 1: Default and custom version security.**
+
+1. **Default configuration**: Applies to versions without specific authorization, requires ``.
+
+2. **Version 1**: Requires **both** `` and `` privileges.
+
+3. **Version 2**: Inherits the default authorization configuration, requiring ``.
+
+```ts
+router.versioned
+ .get({
+ path: '/internal/path',
+ access: 'internal',
+ // default security configuration, will be used for version unless overridden
+ security: {
+ authz: {
+ requiredPrivileges: [''],
+ },
+ },
+ })
+ .addVersion({
+ version: '1',
+ validate: false,
+ security: {
+ authz: {
+ requiredPrivileges: ['', ''],
+ },
+ },
+ }, handlerV1)
+ .addVersion({
+ version: '2',
+ validate: false,
+ }, handlerV2);
+```
+
+**Example 2: Multiple versions with different security requirements.**
+1. **Default Configuration**: Applies to versions without specific authorization, requires ``.
+
+2. **Version 1**: Requires **both** `` and `` privileges.
+
+3. **Version 2**: Requires `` AND (`` OR ``).
+
+4. **Version 3**: Requires only ``.
+
+```ts
+router.versioned
+ .get({
+ path: '/internal/path',
+ access: 'internal',
+ // default security configuration, will be used for version unless overridden
+ security: {
+ authz: {
+ requiredPrivileges: [''],
+ },
+ },
+ })
+ .addVersion({
+ version: '1',
+ validate: false,
+ security: {
+ authz: {
+ requiredPrivileges: ['', ''],
+ },
+ },
+ }, handlerV1)
+ .addVersion({
+ version: '2',
+ validate: false,
+ security: {
+ authz: {
+ requiredPrivileges: ['', anyRequired: ['', '']],
+ },
+ },
+ }, handlerV2)
+ .addVersion({
+ version: '3',
+ validate: false,
+ security: {
+ authz: {
+ requiredPrivileges: [''],
+ },
+ },
+ }, handlerV3);
+```
+
+## Authorization response available in route handlers
+The `AuthzResult` object is available in route handlers, which provides information about the privileges granted to the caller.
+For example, you have a route that requires `` and ANY of the privileges `` OR ``:
+```ts
+router.get({
+ path: '/api/path',
+ security: {
+ authz: {
+ requiredPrivileges: ['', { anyRequired: ['', ''] }],
+ },
+ },
+ ...
+}, (context, request, response) => {
+ // The authorization response is available in `request.authzResult`
+ // {
+ // "": true,
+ // "": true,
+ // "": false
+ // }
+});
+```
+
+## OpenAPI specification (OAS) documentation
+Based on the security configuration defined in routes, OAS documentation will automatically generate and include description about the required privileges.
+This makes it easy to view the security requirements of each endpoint in a standardized format, facilitating better understanding and usage by developers or teams consuming the API.
+
+To check the OAS documentation for a specific API route and see its security details, you can use the following command:
+```sh
+GET /api/oas?pathStartsWith=/your/api/path
+```
+
+## Migrating from `access` tags to `security` configuration
+We aim to use the same privileges that are currently defined with tags `access:`.
+To assist with this migration, we have created eslint rule `no_deprecated_authz_config`, that will automatically convert your `access` tags to the new `security` configuration.
+It scans route definitions and converts `access` tags to the new `requiredPriviliges` configuration.
+
+Note: The rule is disabled by default to avoid automatic migrations without an oversight. You can perform migrations by running:
+
+**Migrate routes with defined authorization**
+```sh
+MIGRATE_DISABLED_AUTHZ=false MIGRATE_ENABLED_AUTHZ=true npx eslint --ext .ts --fix path/to/your/folder
+```
+
+**Migrate routes opted out from authorization**
+```sh
+MIGRATE_DISABLED_AUTHZ=true MIGRATE_ENABLED_AUTHZ=false npx eslint --ext .ts --fix path/to/your/folder
+```
+We encourage you to migrate routes that are opted out from authorization to new config and provide legitimate reason for disabled authorization.
+It is better to migrate routes opted out from authorization iteratively and elaborate on the reasoning.
+Routes without a compelling reason to opt-out of authorization should plan to introduce them as soon as possible.
+
+**Migrate all routes**
+```sh
+MIGRATE_DISABLED_AUTHZ=true MIGRATE_ENABLED_AUTHZ=true npx eslint --ext .ts --fix path/to/your/folder
+```
+
+## Questions?
+If you have any questions or need help with API authorization, please reach out to the `@elastic/kibana-security` team.
+
+
diff --git a/dev_docs/key_concepts/security.mdx b/dev_docs/key_concepts/kibana_system_user.mdx
similarity index 62%
rename from dev_docs/key_concepts/security.mdx
rename to dev_docs/key_concepts/kibana_system_user.mdx
index 8e0bed133fe79..0373c8fa5d402 100644
--- a/dev_docs/key_concepts/security.mdx
+++ b/dev_docs/key_concepts/kibana_system_user.mdx
@@ -1,40 +1,12 @@
---
-id: kibDevDocsSecurityIntro
-slug: /kibana-dev-docs/key-concepts/security-intro
-title: Security
-description: Maintaining Kibana's security posture
-date: 2023-07-11
+id: kibDevDocsSecurityKibanaSystemUser
+slug: /kibana-dev-docs/key-concepts/security-kibana-system-user
+title: Security Kibana System User
+description: This guide provides an overview of `kibana_system` user
+date: 2024-10-04
tags: ['kibana', 'dev', 'contributor', 'security']
---
-Security is everyone's responsibility. This is inclusive of design, product, and engineering. The purpose of this guide is to give a high-level overview of security constructs and expectations.
-
-This guide covers the following topics:
-
-* [API authorization](#api-authorization)
-* [The `kibana_system` user](#the-kibana_system-user)
-
-## API authorization
-Kibana API routes do not have any authorization checks applied by default. This means that your APIs are accessible to anyone with valid credentials, regardless of their permissions. This includes users with no roles assigned.
-This on its own is insufficient, and care must be taken to ensure that only authorized users can invoke your endpoints.
-
-### Adding API authorization
-Kibana leverages for a majority of its persistence. The Saved Objects Service performs its own authorization checks, so if your API route is primarily a CRUD interface to Saved Objects, then your authorization needs are already met.
-This is also true for derivatives of the Saved Objects Service, such as the Alerting and Cases services.
-
-If your endpoint is not a CRUD interface to Saved Objects, then your route should include `access` tags to ensure that only authorized users can invoke your endpoint. This is **especially** important if your route does any of the following:
-1. Performs non-insignificant processing, causing load on the Kibana server.
-2. Calls Elasticsearch using the internal `kibana_system` user.
-3. Calls a third-party service.
-4. Returns any non-public information to the caller, such as system configuration or state.
-
-More information on adding `access` tags to your routes can be found temporarily in the [legacy documentation](https://www.elastic.co/guide/en/kibana/current/development-security.html#development-plugin-feature-registration)
-
-### Why not add `access` tags to all routes by default?
-Each authorization check that we perform involves a round-trip to Elasticsearch, so they are not as cheap as we'd like. Therefore, we want to keep the number of authorization checks we perform within a single route to a minimum.
-Adding an `access` tag to routes that leverage the Saved Objects Service would be redundant in most cases, since the Saved Objects Service will be performing authorization anyway.
-
-
## The `kibana_system` user
The Kibana server authenticates to Elasticsearch using the `elastic/kibana` [service account](https://www.elastic.co/guide/en/elasticsearch/reference/current/service-accounts.html#service-accounts-explanation). This service account has privileges that are equivilent to the `kibana_system` reserved role, whose descriptor is managed in the Elasticsearch repository ([source link](https://github.com/elastic/elasticsearch/blob/430cde6909eae12e1a90ac2bff29b71cbf4af18b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/store/KibanaOwnedReservedRoleDescriptors.java#L58)).
diff --git a/dev_docs/nav-kibana-dev.docnav.json b/dev_docs/nav-kibana-dev.docnav.json
index a7d696fc10574..6dd2ca052b7bd 100644
--- a/dev_docs/nav-kibana-dev.docnav.json
+++ b/dev_docs/nav-kibana-dev.docnav.json
@@ -101,7 +101,10 @@
"id": "kibBuildingBlocks"
},
{
- "id": "kibDevDocsSecurityIntro"
+ "id": "kibDevDocsSecurityAPIAuthorization"
+ },
+ {
+ "id": "kibDevDocsSecurityKibanaSystemUser"
},
{
"id": "kibDevFeaturePrivileges",
@@ -653,4 +656,4 @@
]
}
]
-}
\ No newline at end of file
+}
diff --git a/oas_docs/bundle.json b/oas_docs/bundle.json
index 169133c63753c..098fb1de18699 100644
--- a/oas_docs/bundle.json
+++ b/oas_docs/bundle.json
@@ -9776,6 +9776,191 @@
]
}
},
+ "/api/fleet/agent_policies/outputs": {
+ "post": {
+ "description": "Get list of outputs associated with agent policies",
+ "operationId": "%2Fapi%2Ffleet%2Fagent_policies%2Foutputs#0",
+ "parameters": [
+ {
+ "description": "The version of the API to use",
+ "in": "header",
+ "name": "elastic-api-version",
+ "schema": {
+ "default": "2023-10-31",
+ "enum": [
+ "2023-10-31"
+ ],
+ "type": "string"
+ }
+ },
+ {
+ "description": "A required header to protect against CSRF attacks",
+ "in": "header",
+ "name": "kbn-xsrf",
+ "required": true,
+ "schema": {
+ "example": "true",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json; Elastic-Api-Version=2023-10-31": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "ids": {
+ "description": "list of package policy ids",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "ids"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json; Elastic-Api-Version=2023-10-31": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "items": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "agentPolicyId": {
+ "type": "string"
+ },
+ "data": {
+ "additionalProperties": false,
+ "properties": {
+ "integrations": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "integrationPolicyName": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "pkgName": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "output": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "output"
+ ],
+ "type": "object"
+ },
+ "monitoring": {
+ "additionalProperties": false,
+ "properties": {
+ "output": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "output"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "monitoring",
+ "data"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "items"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ },
+ "400": {
+ "content": {
+ "application/json; Elastic-Api-Version=2023-10-31": {
+ "schema": {
+ "additionalProperties": false,
+ "description": "Generic Error",
+ "properties": {
+ "error": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "statusCode": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "message"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "summary": "",
+ "tags": [
+ "Elastic Agent policies"
+ ]
+ }
+ },
"/api/fleet/agent_policies/{agentPolicyId}": {
"get": {
"description": "Get an agent policy by ID",
@@ -12938,6 +13123,164 @@
]
}
},
+ "/api/fleet/agent_policies/{agentPolicyId}/outputs": {
+ "get": {
+ "description": "Get list of outputs associated with agent policy by policy id",
+ "operationId": "%2Fapi%2Ffleet%2Fagent_policies%2F%7BagentPolicyId%7D%2Foutputs#0",
+ "parameters": [
+ {
+ "description": "The version of the API to use",
+ "in": "header",
+ "name": "elastic-api-version",
+ "schema": {
+ "default": "2023-10-31",
+ "enum": [
+ "2023-10-31"
+ ],
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "agentPolicyId",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json; Elastic-Api-Version=2023-10-31": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "item": {
+ "additionalProperties": false,
+ "properties": {
+ "agentPolicyId": {
+ "type": "string"
+ },
+ "data": {
+ "additionalProperties": false,
+ "properties": {
+ "integrations": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "integrationPolicyName": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "pkgName": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "output": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "output"
+ ],
+ "type": "object"
+ },
+ "monitoring": {
+ "additionalProperties": false,
+ "properties": {
+ "output": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "output"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "monitoring",
+ "data"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "item"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ },
+ "400": {
+ "content": {
+ "application/json; Elastic-Api-Version=2023-10-31": {
+ "schema": {
+ "additionalProperties": false,
+ "description": "Generic Error",
+ "properties": {
+ "error": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "statusCode": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "message"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "summary": "",
+ "tags": [
+ "Elastic Agent policies"
+ ]
+ }
+ },
"/api/fleet/agent_status": {
"get": {
"description": "Get agent status summary",
diff --git a/oas_docs/bundle.serverless.json b/oas_docs/bundle.serverless.json
index 1d989c69f48d4..479f79922fea8 100644
--- a/oas_docs/bundle.serverless.json
+++ b/oas_docs/bundle.serverless.json
@@ -9776,6 +9776,191 @@
]
}
},
+ "/api/fleet/agent_policies/outputs": {
+ "post": {
+ "description": "Get list of outputs associated with agent policies",
+ "operationId": "%2Fapi%2Ffleet%2Fagent_policies%2Foutputs#0",
+ "parameters": [
+ {
+ "description": "The version of the API to use",
+ "in": "header",
+ "name": "elastic-api-version",
+ "schema": {
+ "default": "2023-10-31",
+ "enum": [
+ "2023-10-31"
+ ],
+ "type": "string"
+ }
+ },
+ {
+ "description": "A required header to protect against CSRF attacks",
+ "in": "header",
+ "name": "kbn-xsrf",
+ "required": true,
+ "schema": {
+ "example": "true",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json; Elastic-Api-Version=2023-10-31": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "ids": {
+ "description": "list of package policy ids",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "ids"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json; Elastic-Api-Version=2023-10-31": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "items": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "agentPolicyId": {
+ "type": "string"
+ },
+ "data": {
+ "additionalProperties": false,
+ "properties": {
+ "integrations": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "integrationPolicyName": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "pkgName": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "output": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "output"
+ ],
+ "type": "object"
+ },
+ "monitoring": {
+ "additionalProperties": false,
+ "properties": {
+ "output": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "output"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "monitoring",
+ "data"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "items"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ },
+ "400": {
+ "content": {
+ "application/json; Elastic-Api-Version=2023-10-31": {
+ "schema": {
+ "additionalProperties": false,
+ "description": "Generic Error",
+ "properties": {
+ "error": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "statusCode": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "message"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "summary": "",
+ "tags": [
+ "Elastic Agent policies"
+ ]
+ }
+ },
"/api/fleet/agent_policies/{agentPolicyId}": {
"get": {
"description": "Get an agent policy by ID",
@@ -12938,6 +13123,164 @@
]
}
},
+ "/api/fleet/agent_policies/{agentPolicyId}/outputs": {
+ "get": {
+ "description": "Get list of outputs associated with agent policy by policy id",
+ "operationId": "%2Fapi%2Ffleet%2Fagent_policies%2F%7BagentPolicyId%7D%2Foutputs#0",
+ "parameters": [
+ {
+ "description": "The version of the API to use",
+ "in": "header",
+ "name": "elastic-api-version",
+ "schema": {
+ "default": "2023-10-31",
+ "enum": [
+ "2023-10-31"
+ ],
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "agentPolicyId",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json; Elastic-Api-Version=2023-10-31": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "item": {
+ "additionalProperties": false,
+ "properties": {
+ "agentPolicyId": {
+ "type": "string"
+ },
+ "data": {
+ "additionalProperties": false,
+ "properties": {
+ "integrations": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "integrationPolicyName": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "pkgName": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "output": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "output"
+ ],
+ "type": "object"
+ },
+ "monitoring": {
+ "additionalProperties": false,
+ "properties": {
+ "output": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "output"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "monitoring",
+ "data"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "item"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ },
+ "400": {
+ "content": {
+ "application/json; Elastic-Api-Version=2023-10-31": {
+ "schema": {
+ "additionalProperties": false,
+ "description": "Generic Error",
+ "properties": {
+ "error": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "statusCode": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "message"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "summary": "",
+ "tags": [
+ "Elastic Agent policies"
+ ]
+ }
+ },
"/api/fleet/agent_status": {
"get": {
"description": "Get agent status summary",
diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml
index e7a3e9c42ec7a..b1f6938936fbd 100644
--- a/oas_docs/output/kibana.serverless.staging.yaml
+++ b/oas_docs/output/kibana.serverless.staging.yaml
@@ -14594,6 +14594,110 @@ paths:
summary: ''
tags:
- Elastic Agent policies
+ /api/fleet/agent_policies/{agentPolicyId}/outputs:
+ get:
+ description: Get list of outputs associated with agent policy by policy id
+ operationId: '%2Fapi%2Ffleet%2Fagent_policies%2F%7BagentPolicyId%7D%2Foutputs#0'
+ parameters:
+ - description: The version of the API to use
+ in: header
+ name: elastic-api-version
+ schema:
+ default: '2023-10-31'
+ enum:
+ - '2023-10-31'
+ type: string
+ - in: path
+ name: agentPolicyId
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ item:
+ additionalProperties: false
+ type: object
+ properties:
+ agentPolicyId:
+ type: string
+ data:
+ additionalProperties: false
+ type: object
+ properties:
+ integrations:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ integrationPolicyName:
+ type: string
+ name:
+ type: string
+ pkgName:
+ type: string
+ type: array
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ monitoring:
+ additionalProperties: false
+ type: object
+ properties:
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ required:
+ - monitoring
+ - data
+ required:
+ - item
+ '400':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ description: Generic Error
+ type: object
+ properties:
+ error:
+ type: string
+ message:
+ type: string
+ statusCode:
+ type: number
+ required:
+ - message
+ summary: ''
+ tags:
+ - Elastic Agent policies
/api/fleet/agent_policies/delete:
post:
description: Delete agent policy by ID
@@ -14664,6 +14768,128 @@ paths:
summary: ''
tags:
- Elastic Agent policies
+ /api/fleet/agent_policies/outputs:
+ post:
+ description: Get list of outputs associated with agent policies
+ operationId: '%2Fapi%2Ffleet%2Fagent_policies%2Foutputs#0'
+ parameters:
+ - description: The version of the API to use
+ in: header
+ name: elastic-api-version
+ schema:
+ default: '2023-10-31'
+ enum:
+ - '2023-10-31'
+ type: string
+ - description: A required header to protect against CSRF attacks
+ in: header
+ name: kbn-xsrf
+ required: true
+ schema:
+ example: 'true'
+ type: string
+ requestBody:
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ ids:
+ description: list of package policy ids
+ items:
+ type: string
+ type: array
+ required:
+ - ids
+ responses:
+ '200':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ items:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ agentPolicyId:
+ type: string
+ data:
+ additionalProperties: false
+ type: object
+ properties:
+ integrations:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ integrationPolicyName:
+ type: string
+ name:
+ type: string
+ pkgName:
+ type: string
+ type: array
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ monitoring:
+ additionalProperties: false
+ type: object
+ properties:
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ required:
+ - monitoring
+ - data
+ type: array
+ required:
+ - items
+ '400':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ description: Generic Error
+ type: object
+ properties:
+ error:
+ type: string
+ message:
+ type: string
+ statusCode:
+ type: number
+ required:
+ - message
+ summary: ''
+ tags:
+ - Elastic Agent policies
/api/fleet/agent_status:
get:
description: Get agent status summary
diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml
index e7a3e9c42ec7a..b1f6938936fbd 100644
--- a/oas_docs/output/kibana.serverless.yaml
+++ b/oas_docs/output/kibana.serverless.yaml
@@ -14594,6 +14594,110 @@ paths:
summary: ''
tags:
- Elastic Agent policies
+ /api/fleet/agent_policies/{agentPolicyId}/outputs:
+ get:
+ description: Get list of outputs associated with agent policy by policy id
+ operationId: '%2Fapi%2Ffleet%2Fagent_policies%2F%7BagentPolicyId%7D%2Foutputs#0'
+ parameters:
+ - description: The version of the API to use
+ in: header
+ name: elastic-api-version
+ schema:
+ default: '2023-10-31'
+ enum:
+ - '2023-10-31'
+ type: string
+ - in: path
+ name: agentPolicyId
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ item:
+ additionalProperties: false
+ type: object
+ properties:
+ agentPolicyId:
+ type: string
+ data:
+ additionalProperties: false
+ type: object
+ properties:
+ integrations:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ integrationPolicyName:
+ type: string
+ name:
+ type: string
+ pkgName:
+ type: string
+ type: array
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ monitoring:
+ additionalProperties: false
+ type: object
+ properties:
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ required:
+ - monitoring
+ - data
+ required:
+ - item
+ '400':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ description: Generic Error
+ type: object
+ properties:
+ error:
+ type: string
+ message:
+ type: string
+ statusCode:
+ type: number
+ required:
+ - message
+ summary: ''
+ tags:
+ - Elastic Agent policies
/api/fleet/agent_policies/delete:
post:
description: Delete agent policy by ID
@@ -14664,6 +14768,128 @@ paths:
summary: ''
tags:
- Elastic Agent policies
+ /api/fleet/agent_policies/outputs:
+ post:
+ description: Get list of outputs associated with agent policies
+ operationId: '%2Fapi%2Ffleet%2Fagent_policies%2Foutputs#0'
+ parameters:
+ - description: The version of the API to use
+ in: header
+ name: elastic-api-version
+ schema:
+ default: '2023-10-31'
+ enum:
+ - '2023-10-31'
+ type: string
+ - description: A required header to protect against CSRF attacks
+ in: header
+ name: kbn-xsrf
+ required: true
+ schema:
+ example: 'true'
+ type: string
+ requestBody:
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ ids:
+ description: list of package policy ids
+ items:
+ type: string
+ type: array
+ required:
+ - ids
+ responses:
+ '200':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ items:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ agentPolicyId:
+ type: string
+ data:
+ additionalProperties: false
+ type: object
+ properties:
+ integrations:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ integrationPolicyName:
+ type: string
+ name:
+ type: string
+ pkgName:
+ type: string
+ type: array
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ monitoring:
+ additionalProperties: false
+ type: object
+ properties:
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ required:
+ - monitoring
+ - data
+ type: array
+ required:
+ - items
+ '400':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ description: Generic Error
+ type: object
+ properties:
+ error:
+ type: string
+ message:
+ type: string
+ statusCode:
+ type: number
+ required:
+ - message
+ summary: ''
+ tags:
+ - Elastic Agent policies
/api/fleet/agent_status:
get:
description: Get agent status summary
diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml
index 16aa969df06d0..ac76216c78801 100644
--- a/oas_docs/output/kibana.staging.yaml
+++ b/oas_docs/output/kibana.staging.yaml
@@ -18023,6 +18023,110 @@ paths:
summary: ''
tags:
- Elastic Agent policies
+ /api/fleet/agent_policies/{agentPolicyId}/outputs:
+ get:
+ description: Get list of outputs associated with agent policy by policy id
+ operationId: '%2Fapi%2Ffleet%2Fagent_policies%2F%7BagentPolicyId%7D%2Foutputs#0'
+ parameters:
+ - description: The version of the API to use
+ in: header
+ name: elastic-api-version
+ schema:
+ default: '2023-10-31'
+ enum:
+ - '2023-10-31'
+ type: string
+ - in: path
+ name: agentPolicyId
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ item:
+ additionalProperties: false
+ type: object
+ properties:
+ agentPolicyId:
+ type: string
+ data:
+ additionalProperties: false
+ type: object
+ properties:
+ integrations:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ integrationPolicyName:
+ type: string
+ name:
+ type: string
+ pkgName:
+ type: string
+ type: array
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ monitoring:
+ additionalProperties: false
+ type: object
+ properties:
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ required:
+ - monitoring
+ - data
+ required:
+ - item
+ '400':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ description: Generic Error
+ type: object
+ properties:
+ error:
+ type: string
+ message:
+ type: string
+ statusCode:
+ type: number
+ required:
+ - message
+ summary: ''
+ tags:
+ - Elastic Agent policies
/api/fleet/agent_policies/delete:
post:
description: Delete agent policy by ID
@@ -18093,6 +18197,128 @@ paths:
summary: ''
tags:
- Elastic Agent policies
+ /api/fleet/agent_policies/outputs:
+ post:
+ description: Get list of outputs associated with agent policies
+ operationId: '%2Fapi%2Ffleet%2Fagent_policies%2Foutputs#0'
+ parameters:
+ - description: The version of the API to use
+ in: header
+ name: elastic-api-version
+ schema:
+ default: '2023-10-31'
+ enum:
+ - '2023-10-31'
+ type: string
+ - description: A required header to protect against CSRF attacks
+ in: header
+ name: kbn-xsrf
+ required: true
+ schema:
+ example: 'true'
+ type: string
+ requestBody:
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ ids:
+ description: list of package policy ids
+ items:
+ type: string
+ type: array
+ required:
+ - ids
+ responses:
+ '200':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ items:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ agentPolicyId:
+ type: string
+ data:
+ additionalProperties: false
+ type: object
+ properties:
+ integrations:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ integrationPolicyName:
+ type: string
+ name:
+ type: string
+ pkgName:
+ type: string
+ type: array
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ monitoring:
+ additionalProperties: false
+ type: object
+ properties:
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ required:
+ - monitoring
+ - data
+ type: array
+ required:
+ - items
+ '400':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ description: Generic Error
+ type: object
+ properties:
+ error:
+ type: string
+ message:
+ type: string
+ statusCode:
+ type: number
+ required:
+ - message
+ summary: ''
+ tags:
+ - Elastic Agent policies
/api/fleet/agent_status:
get:
description: Get agent status summary
diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml
index 16aa969df06d0..ac76216c78801 100644
--- a/oas_docs/output/kibana.yaml
+++ b/oas_docs/output/kibana.yaml
@@ -18023,6 +18023,110 @@ paths:
summary: ''
tags:
- Elastic Agent policies
+ /api/fleet/agent_policies/{agentPolicyId}/outputs:
+ get:
+ description: Get list of outputs associated with agent policy by policy id
+ operationId: '%2Fapi%2Ffleet%2Fagent_policies%2F%7BagentPolicyId%7D%2Foutputs#0'
+ parameters:
+ - description: The version of the API to use
+ in: header
+ name: elastic-api-version
+ schema:
+ default: '2023-10-31'
+ enum:
+ - '2023-10-31'
+ type: string
+ - in: path
+ name: agentPolicyId
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ item:
+ additionalProperties: false
+ type: object
+ properties:
+ agentPolicyId:
+ type: string
+ data:
+ additionalProperties: false
+ type: object
+ properties:
+ integrations:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ integrationPolicyName:
+ type: string
+ name:
+ type: string
+ pkgName:
+ type: string
+ type: array
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ monitoring:
+ additionalProperties: false
+ type: object
+ properties:
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ required:
+ - monitoring
+ - data
+ required:
+ - item
+ '400':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ description: Generic Error
+ type: object
+ properties:
+ error:
+ type: string
+ message:
+ type: string
+ statusCode:
+ type: number
+ required:
+ - message
+ summary: ''
+ tags:
+ - Elastic Agent policies
/api/fleet/agent_policies/delete:
post:
description: Delete agent policy by ID
@@ -18093,6 +18197,128 @@ paths:
summary: ''
tags:
- Elastic Agent policies
+ /api/fleet/agent_policies/outputs:
+ post:
+ description: Get list of outputs associated with agent policies
+ operationId: '%2Fapi%2Ffleet%2Fagent_policies%2Foutputs#0'
+ parameters:
+ - description: The version of the API to use
+ in: header
+ name: elastic-api-version
+ schema:
+ default: '2023-10-31'
+ enum:
+ - '2023-10-31'
+ type: string
+ - description: A required header to protect against CSRF attacks
+ in: header
+ name: kbn-xsrf
+ required: true
+ schema:
+ example: 'true'
+ type: string
+ requestBody:
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ ids:
+ description: list of package policy ids
+ items:
+ type: string
+ type: array
+ required:
+ - ids
+ responses:
+ '200':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ type: object
+ properties:
+ items:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ agentPolicyId:
+ type: string
+ data:
+ additionalProperties: false
+ type: object
+ properties:
+ integrations:
+ items:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ integrationPolicyName:
+ type: string
+ name:
+ type: string
+ pkgName:
+ type: string
+ type: array
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ monitoring:
+ additionalProperties: false
+ type: object
+ properties:
+ output:
+ additionalProperties: false
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ required:
+ - output
+ required:
+ - monitoring
+ - data
+ type: array
+ required:
+ - items
+ '400':
+ content:
+ application/json; Elastic-Api-Version=2023-10-31:
+ schema:
+ additionalProperties: false
+ description: Generic Error
+ type: object
+ properties:
+ error:
+ type: string
+ message:
+ type: string
+ statusCode:
+ type: number
+ required:
+ - message
+ summary: ''
+ tags:
+ - Elastic Agent policies
/api/fleet/agent_status:
get:
description: Get agent status summary
diff --git a/package.json b/package.json
index 003047638d9d0..51d1b7472c6dc 100644
--- a/package.json
+++ b/package.json
@@ -635,6 +635,7 @@
"@kbn/management-settings-types": "link:packages/kbn-management/settings/types",
"@kbn/management-settings-utilities": "link:packages/kbn-management/settings/utilities",
"@kbn/management-test-plugin": "link:test/plugin_functional/plugins/management_test_plugin",
+ "@kbn/manifest": "link:packages/kbn-manifest",
"@kbn/mapbox-gl": "link:packages/kbn-mapbox-gl",
"@kbn/maps-custom-raster-source-plugin": "link:x-pack/examples/third_party_maps_source_example",
"@kbn/maps-ems-plugin": "link:src/plugins/maps_ems",
diff --git a/packages/core/http/core-http-server-internal/src/static_assets/util.ts b/packages/core/http/core-http-server-internal/src/static_assets/util.ts
index 9cd9213805b23..0bcc738582f2b 100644
--- a/packages/core/http/core-http-server-internal/src/static_assets/util.ts
+++ b/packages/core/http/core-http-server-internal/src/static_assets/util.ts
@@ -14,11 +14,23 @@ function isEmptyPathname(pathname: string): boolean {
}
function removeTailSlashes(pathname: string): string {
- return pathname.replace(/\/+$/, '');
+ let updated = pathname;
+
+ while (updated.endsWith('/')) {
+ updated = updated.substring(0, updated.length - 1);
+ }
+
+ return updated;
}
function removeLeadSlashes(pathname: string): string {
- return pathname.replace(/^\/+/, '');
+ let updated = pathname;
+
+ while (updated.startsWith('/')) {
+ updated = updated.substring(1);
+ }
+
+ return updated;
}
export function removeSurroundingSlashes(pathname: string): string {
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/es_errors.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/es_errors.test.ts
index 9a93f50487bcd..73a0fc0659939 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/es_errors.test.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/es_errors.test.ts
@@ -8,6 +8,7 @@
*/
import {
+ hasAllKeywordsInOrder,
isClusterShardLimitExceeded,
isIncompatibleMappingException,
isIndexNotFoundException,
@@ -128,3 +129,31 @@ describe('isClusterShardLimitExceeded', () => {
expect(isClusterShardLimitExceeded(undefined)).toEqual(false);
});
});
+
+describe('hasAllKeywordsInOrder', () => {
+ it('returns false if not all keywords are present', () => {
+ expect(
+ hasAllKeywordsInOrder('some keywords in a message', ['some', 'in', 'message', 'missing'])
+ ).toEqual(false);
+ });
+
+ it('returns false if keywords are not in the right order', () => {
+ expect(
+ hasAllKeywordsInOrder('some keywords in a message', ['some', 'message', 'keywords'])
+ ).toEqual(false);
+ });
+
+ it('returns false if the message is empty', () => {
+ expect(hasAllKeywordsInOrder('', ['some', 'message', 'keywords'])).toEqual(false);
+ });
+
+ it('returns false if the keyword list is empty', () => {
+ expect(hasAllKeywordsInOrder('some keywords in a message', [])).toEqual(false);
+ });
+
+ it('returns true if keywords are present and in the right order', () => {
+ expect(
+ hasAllKeywordsInOrder('some keywords in a message', ['some', 'keywords', 'in', 'message'])
+ ).toEqual(true);
+ });
+});
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/es_errors.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/es_errors.ts
index fbded8ad44b29..0ea6ccc227cba 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/es_errors.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/es_errors.ts
@@ -7,16 +7,16 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
-import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
+import type { ErrorCause } from '@elastic/elasticsearch/lib/api/types';
-export const isWriteBlockException = (errorCause?: estypes.ErrorCause): boolean => {
+export const isWriteBlockException = (errorCause?: ErrorCause): boolean => {
return (
errorCause?.type === 'cluster_block_exception' &&
- errorCause?.reason?.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/.+ \(api\)\]/) !== null
+ hasAllKeywordsInOrder(errorCause?.reason, ['index [', '] blocked by: [FORBIDDEN/8/', ' (api)]'])
);
};
-export const isIncompatibleMappingException = (errorCause?: estypes.ErrorCause): boolean => {
+export const isIncompatibleMappingException = (errorCause?: ErrorCause): boolean => {
return (
errorCause?.type === 'strict_dynamic_mapping_exception' ||
errorCause?.type === 'mapper_parsing_exception' ||
@@ -24,17 +24,29 @@ export const isIncompatibleMappingException = (errorCause?: estypes.ErrorCause):
);
};
-export const isIndexNotFoundException = (errorCause?: estypes.ErrorCause): boolean => {
+export const isIndexNotFoundException = (errorCause?: ErrorCause): boolean => {
return errorCause?.type === 'index_not_found_exception';
};
-export const isClusterShardLimitExceeded = (errorCause?: estypes.ErrorCause): boolean => {
+export const isClusterShardLimitExceeded = (errorCause?: ErrorCause): boolean => {
// traditional ES: validation_exception. serverless ES: illegal_argument_exception
return (
(errorCause?.type === 'validation_exception' ||
errorCause?.type === 'illegal_argument_exception') &&
- errorCause?.reason?.match(
- /this action would add .* shards, but this cluster currently has .* maximum normal shards open/
- ) !== null
+ hasAllKeywordsInOrder(errorCause?.reason, [
+ 'this action would add',
+ 'shards, but this cluster currently has',
+ 'maximum normal shards open',
+ ])
);
};
+
+export const hasAllKeywordsInOrder = (message: string | undefined, keywords: string[]): boolean => {
+ if (!message || !keywords.length) {
+ return false;
+ }
+
+ const keywordIndices = keywords.map((keyword) => message?.indexOf(keyword) ?? -1);
+ // check that all keywords are present and in the right order
+ return keywordIndices.every((v, i, a) => v >= 0 && (!i || a[i - 1] <= v));
+};
diff --git a/packages/kbn-dev-utils/src/plugin_list/run_plugin_list_cli.ts b/packages/kbn-dev-utils/src/plugin_list/run_plugin_list_cli.ts
index a201cfcd0e262..65f6735e22ca6 100644
--- a/packages/kbn-dev-utils/src/plugin_list/run_plugin_list_cli.ts
+++ b/packages/kbn-dev-utils/src/plugin_list/run_plugin_list_cli.ts
@@ -20,14 +20,39 @@ const OUTPUT_PATH = Path.resolve(REPO_ROOT, 'docs/developer/plugin-list.asciidoc
export function runPluginListCli() {
run(async ({ log }) => {
log.info('looking for oss plugins');
- const ossPlugins = discoverPlugins('src/plugins');
- log.success(`found ${ossPlugins.length} plugins`);
+ const ossLegacyPlugins = discoverPlugins('src/plugins');
+ const ossPlatformPlugins = discoverPlugins('src/platform/plugins');
+ log.success(`found ${ossLegacyPlugins.length + ossPlatformPlugins.length} plugins`);
log.info('looking for x-pack plugins');
- const xpackPlugins = discoverPlugins('x-pack/plugins');
- log.success(`found ${xpackPlugins.length} plugins`);
+ const xpackLegacyPlugins = discoverPlugins('x-pack/plugins');
+ const xpackPlatformPlugins = discoverPlugins('x-pack/platform/plugins');
+ const xpackSearchPlugins = discoverPlugins('x-pack/solutions/search/plugins');
+ const xpackSecurityPlugins = discoverPlugins('x-pack/solutions/security/plugins');
+ const xpackObservabilityPlugins = discoverPlugins('x-pack/solutions/observability/plugins');
+ log.success(
+ `found ${
+ xpackLegacyPlugins.length +
+ xpackPlatformPlugins.length +
+ xpackSearchPlugins.length +
+ xpackSecurityPlugins.length +
+ xpackObservabilityPlugins.length
+ } plugins`
+ );
log.info('writing plugin list to', OUTPUT_PATH);
- Fs.writeFileSync(OUTPUT_PATH, generatePluginList(ossPlugins, xpackPlugins));
+ Fs.writeFileSync(
+ OUTPUT_PATH,
+ generatePluginList(
+ [...ossLegacyPlugins, ...ossPlatformPlugins],
+ [
+ ...xpackLegacyPlugins,
+ ...xpackPlatformPlugins,
+ ...xpackSearchPlugins,
+ ...xpackSecurityPlugins,
+ ...xpackObservabilityPlugins,
+ ]
+ )
+ );
});
}
diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts
index 6cbf6107b770a..2e9183ed18b56 100644
--- a/packages/kbn-doc-links/src/get_doc_links.ts
+++ b/packages/kbn-doc-links/src/get_doc_links.ts
@@ -578,7 +578,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D
alertingRules: `${ELASTICSEARCH_DOCS}transform-alerts.html`,
},
visualize: {
- guide: `${KIBANA_DOCS}dashboard.html`,
+ guide: `${KIBANA_DOCS}_panels_and_visualizations.html`,
lens: `${ELASTIC_WEBSITE_URL}what-is/kibana-lens`,
lensPanels: `${KIBANA_DOCS}lens.html`,
maps: `${ELASTIC_WEBSITE_URL}maps`,
diff --git a/packages/kbn-eslint-config/.eslintrc.js b/packages/kbn-eslint-config/.eslintrc.js
index f241131cd6273..ec39d88606438 100644
--- a/packages/kbn-eslint-config/.eslintrc.js
+++ b/packages/kbn-eslint-config/.eslintrc.js
@@ -317,6 +317,7 @@ module.exports = {
'@kbn/disable/no_naked_eslint_disable': 'error',
'@kbn/eslint/no_async_promise_body': 'error',
'@kbn/eslint/no_async_foreach': 'error',
+ '@kbn/eslint/no_deprecated_authz_config': 'error',
'@kbn/eslint/no_trailing_import_slash': 'error',
'@kbn/eslint/no_constructor_args_in_property_initializers': 'error',
'@kbn/eslint/no_this_in_property_initializers': 'error',
@@ -326,7 +327,8 @@ module.exports = {
'@kbn/imports/uniform_imports': 'error',
'@kbn/imports/no_unused_imports': 'error',
'@kbn/imports/no_boundary_crossing': 'error',
-
+ '@kbn/imports/no_group_crossing_manifests': 'error',
+ '@kbn/imports/no_group_crossing_imports': 'error',
'no-new-func': 'error',
'no-implied-eval': 'error',
'no-prototype-builtins': 'error',
diff --git a/packages/kbn-eslint-plugin-disable/src/helpers/protected_rules.ts b/packages/kbn-eslint-plugin-disable/src/helpers/protected_rules.ts
index 0eabafc48ab69..6e555f1d9527c 100644
--- a/packages/kbn-eslint-plugin-disable/src/helpers/protected_rules.ts
+++ b/packages/kbn-eslint-plugin-disable/src/helpers/protected_rules.ts
@@ -12,4 +12,6 @@ export const PROTECTED_RULES = new Set([
'@kbn/disable/no_protected_eslint_disable',
'@kbn/disable/no_naked_eslint_disable',
'@kbn/imports/no_unused_imports',
+ '@kbn/imports/no_group_crossing_imports',
+ '@kbn/imports/no_group_crossing_manifests',
]);
diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.js b/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.js
index f6485d0914c15..0f0b8759b4a82 100644
--- a/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.js
+++ b/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.js
@@ -39,6 +39,33 @@ const maybeReportDisabledSecurityConfig = (node, context, isVersionedRoute = fal
return;
}
+ const hasSecurityInRoot = (config) => {
+ const securityInRoot = config.properties.find(
+ (property) => property.key && property.key.name === 'security'
+ );
+
+ if (securityInRoot) {
+ return true;
+ }
+
+ const optionsProperty = config.properties.find(
+ (prop) => prop.key && prop.key.name === 'options'
+ );
+
+ if (optionsProperty?.value?.properties) {
+ const tagsProperty = optionsProperty.value.properties.find(
+ (prop) => prop.key.name === 'tags'
+ );
+
+ const accessTagsFilter = (el) => isLiteralAccessTag(el) || isTemplateLiteralAccessTag(el);
+ const accessTags = tagsProperty?.value?.elements?.filter(accessTagsFilter) ?? [];
+
+ return accessTags.length > 0;
+ }
+
+ return false;
+ };
+
if (isVersionedRoute) {
const [versionConfig] = node.arguments;
@@ -53,33 +80,6 @@ const maybeReportDisabledSecurityConfig = (node, context, isVersionedRoute = fal
let currentNode = node;
- const hasSecurityInRoot = (config) => {
- const securityInRoot = config.properties.find(
- (property) => property.key && property.key.name === 'security'
- );
-
- if (securityInRoot) {
- return true;
- }
-
- const optionsProperty = config.properties.find(
- (prop) => prop.key && prop.key.name === 'options'
- );
-
- if (optionsProperty?.value?.properties) {
- const tagsProperty = optionsProperty.value.properties.find(
- (prop) => prop.key.name === 'tags'
- );
-
- const accessTagsFilter = (el) => isLiteralAccessTag(el) || isTemplateLiteralAccessTag(el);
- const accessTags = tagsProperty.value.elements.filter(accessTagsFilter);
-
- return accessTags.length > 0;
- }
-
- return false;
- };
-
while (
currentNode &&
currentNode.type === 'CallExpression' &&
@@ -126,11 +126,14 @@ const maybeReportDisabledSecurityConfig = (node, context, isVersionedRoute = fal
}
} else {
const [routeConfig] = node.arguments;
- const securityProperty = routeConfig.properties.find(
- (property) => property.key && property.key.name === 'security'
- );
- if (!securityProperty) {
+ const pathProperty = routeConfig.properties?.find((prop) => prop?.key?.name === 'path');
+
+ if (!pathProperty) {
+ return;
+ }
+
+ if (!hasSecurityInRoot(routeConfig)) {
const pathProperty = routeConfig.properties.find((prop) => prop.key.name === 'path');
context.report({
node: routeConfig,
@@ -181,7 +184,14 @@ const handleRouteConfig = (node, context, isVersionedRoute = false) => {
const staticPart = firstQuasi.split(ACCESS_TAG_PREFIX)[1] || '';
const dynamicParts = el.expressions.map((expression, index) => {
- const dynamicPlaceholder = `\${${expression.name}}`;
+ let dynamicPlaceholder;
+ if (expression.property) {
+ // Case: object.property
+ dynamicPlaceholder = `\${${expression.object.name}.${expression.property.name}}`;
+ } else {
+ // Case: simple variable
+ dynamicPlaceholder = `\${${expression.name}}`;
+ }
const nextQuasi = el.quasis[index + 1].value.raw || '';
return `${dynamicPlaceholder}${nextQuasi}`;
});
@@ -290,13 +300,25 @@ module.exports = {
CallExpression(node) {
const callee = node.callee;
+ // Skipping by default if any of env vars is not set
+ const shouldSkipMigration =
+ !process.env.MIGRATE_ENABLED_AUTHZ && !process.env.MIGRATE_DISABLED_AUTHZ;
+
+ if (shouldSkipMigration) {
+ return;
+ }
+
if (
callee.type === 'MemberExpression' &&
callee.object &&
callee.object.name === 'router' &&
routeMethods.includes(callee.property.name)
) {
- handleRouteConfig(node, context, false);
+ if (process.env.MIGRATE_ENABLED_AUTHZ === 'false') {
+ maybeReportDisabledSecurityConfig(node, context, false);
+ } else {
+ handleRouteConfig(node, context, false);
+ }
}
if (
@@ -310,7 +332,11 @@ module.exports = {
const versionConfig = node.arguments[0];
if (versionConfig && versionConfig.type === 'ObjectExpression') {
- handleRouteConfig(node, context, true);
+ if (process.env.MIGRATE_ENABLED_AUTHZ === 'false') {
+ maybeReportDisabledSecurityConfig(node, context, true);
+ } else {
+ handleRouteConfig(node, context, true);
+ }
}
}
},
diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.test.js b/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.test.js
index b397c4457b2c7..4f0fc375ad55e 100644
--- a/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.test.js
+++ b/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.test.js
@@ -11,8 +11,17 @@ const { RuleTester } = require('eslint');
const rule = require('./no_deprecated_authz_config');
const dedent = require('dedent');
-// Indentation is a big problem in the test cases, dedent library does not work as expected.
+beforeAll(() => {
+ process.env.MIGRATE_ENABLED_AUTHZ = 'true';
+ process.env.MIGRATE_DISABLED_AUTHZ = 'true';
+});
+
+afterAll(() => {
+ delete process.env.MIGRATE_ENABLED_AUTHZ;
+ delete process.env.MIGRATE_DISABLED_AUTHZ;
+});
+// Indentation is a big problem in the test cases, dedent library does not work as expected.
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
@@ -202,6 +211,28 @@ ruleTester.run('no_deprecated_authz_config', rule, {
`,
name: 'invalid: access tags are template literals, move to security.authz.requiredPrivileges',
},
+ {
+ code: `
+ router.get({
+ path: '/some/path',
+ options: {
+ tags: [\`access:\${APP.TEST_ID}\`],
+ },
+ });
+ `,
+ errors: [{ message: "Move 'access' tags to security.authz.requiredPrivileges." }],
+ output: `
+ router.get({
+ path: '/some/path',
+ security: {
+ authz: {
+ requiredPrivileges: [\`\${APP.TEST_ID}\`],
+ },
+ },
+ });
+ `,
+ name: 'invalid: access tags are template literals, move to security.authz.requiredPrivileges',
+ },
{
code: `
router.get({
diff --git a/packages/kbn-eslint-plugin-imports/index.ts b/packages/kbn-eslint-plugin-imports/index.ts
index 9c57d66f60225..31e3483ea6139 100644
--- a/packages/kbn-eslint-plugin-imports/index.ts
+++ b/packages/kbn-eslint-plugin-imports/index.ts
@@ -13,6 +13,8 @@ import { UniformImportsRule } from './src/rules/uniform_imports';
import { ExportsMovedPackagesRule } from './src/rules/exports_moved_packages';
import { NoUnusedImportsRule } from './src/rules/no_unused_imports';
import { NoBoundaryCrossingRule } from './src/rules/no_boundary_crossing';
+import { NoGroupCrossingImportsRule } from './src/rules/no_group_crossing_imports';
+import { NoGroupCrossingManifestsRule } from './src/rules/no_group_crossing_manifests';
import { RequireImportRule } from './src/rules/require_import';
/**
@@ -25,5 +27,7 @@ export const rules = {
exports_moved_packages: ExportsMovedPackagesRule,
no_unused_imports: NoUnusedImportsRule,
no_boundary_crossing: NoBoundaryCrossingRule,
+ no_group_crossing_imports: NoGroupCrossingImportsRule,
+ no_group_crossing_manifests: NoGroupCrossingManifestsRule,
require_import: RequireImportRule,
};
diff --git a/packages/kbn-eslint-plugin-imports/src/helpers/groups.ts b/packages/kbn-eslint-plugin-imports/src/helpers/groups.ts
new file mode 100644
index 0000000000000..a76251f028389
--- /dev/null
+++ b/packages/kbn-eslint-plugin-imports/src/helpers/groups.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
+
+/**
+ * Checks whether a given ModuleGroup can import from another one
+ * @param importerGroup The group of the module that we are checking
+ * @param importedGroup The group of the imported module
+ * @param importedVisibility The visibility of the imported module
+ * @returns true if importerGroup is allowed to import from importedGroup/Visibiliy
+ */
+export function isImportableFrom(
+ importerGroup: ModuleGroup,
+ importedGroup: ModuleGroup,
+ importedVisibility: ModuleVisibility
+): boolean {
+ return importerGroup === importedGroup || importedVisibility === 'shared';
+}
diff --git a/packages/kbn-eslint-plugin-imports/src/helpers/report.ts b/packages/kbn-eslint-plugin-imports/src/helpers/report.ts
index 9ac0171507efd..11fc09fbecab3 100644
--- a/packages/kbn-eslint-plugin-imports/src/helpers/report.ts
+++ b/packages/kbn-eslint-plugin-imports/src/helpers/report.ts
@@ -30,3 +30,19 @@ export function report(context: Rule.RuleContext, options: ReportOptions) {
: null,
});
}
+
+export const toList = (strings: string[]) => {
+ const items = strings.map((s) => `"${s}"`);
+ const list = items.slice(0, -1).join(', ');
+ const last = items.at(-1);
+ return !list.length ? last ?? '' : `${list} or ${last}`;
+};
+
+export const formatSuggestions = (suggestions: string[]) => {
+ const s = suggestions.map((l) => l.trim()).filter(Boolean);
+ if (!s.length) {
+ return '';
+ }
+
+ return ` \nSuggestions:\n - ${s.join('\n - ')}\n\n`;
+};
diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.test.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.test.ts
index be9e60978fa88..f44c0571b2c94 100644
--- a/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.test.ts
+++ b/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.test.ts
@@ -9,8 +9,9 @@
import { RuleTester } from 'eslint';
import { NoBoundaryCrossingRule } from './no_boundary_crossing';
-import { ModuleType } from '@kbn/repo-source-classifier';
+import type { ModuleType } from '@kbn/repo-source-classifier';
import dedent from 'dedent';
+import { formatSuggestions } from '../helpers/report';
const make = (from: ModuleType, to: ModuleType, imp = 'import') => ({
filename: `${from}.ts`,
@@ -107,13 +108,12 @@ for (const [name, tester] of [tsTester, babelTester]) {
data: {
importedType: 'server package',
ownType: 'common package',
- suggestion: ` ${dedent`
- Suggestions:
- - Remove the import statement.
- - Limit your imports to "common package" or "static" code.
- - Covert to a type-only import.
- - Reach out to #kibana-operations for help.
- `}`,
+ suggestion: formatSuggestions([
+ 'Remove the import statement.',
+ 'Limit your imports to "common package" or "static" code.',
+ 'Covert to a type-only import.',
+ 'Reach out to #kibana-operations for help.',
+ ]),
},
},
],
diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.ts
index 59c73c1d0336c..3f426e13a6215 100644
--- a/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.ts
+++ b/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.ts
@@ -12,13 +12,14 @@ import Path from 'path';
import { TSESTree } from '@typescript-eslint/typescript-estree';
import * as Bt from '@babel/types';
import type { Rule } from 'eslint';
-import ESTree from 'estree';
-import { ModuleType } from '@kbn/repo-source-classifier';
+import type { Node } from 'estree';
+import type { ModuleType } from '@kbn/repo-source-classifier';
import { visitAllImportStatements, Importer } from '../helpers/visit_all_import_statements';
import { getSourcePath } from '../helpers/source';
import { getRepoSourceClassifier } from '../helpers/repo_source_classifier';
import { getImportResolver } from '../get_import_resolver';
+import { formatSuggestions, toList } from '../helpers/report';
const ANY = Symbol();
@@ -33,22 +34,6 @@ const IMPORTABLE_FROM: Record = {
tooling: ANY,
};
-const toList = (strings: string[]) => {
- const items = strings.map((s) => `"${s}"`);
- const list = items.slice(0, -1).join(', ');
- const last = items.at(-1);
- return !list.length ? last ?? '' : `${list} or ${last}`;
-};
-
-const formatSuggestions = (suggestions: string[]) => {
- const s = suggestions.map((l) => l.trim()).filter(Boolean);
- if (!s.length) {
- return '';
- }
-
- return ` Suggestions:\n - ${s.join('\n - ')}`;
-};
-
const isTypeOnlyImport = (importer: Importer) => {
// handle babel nodes
if (Bt.isImportDeclaration(importer)) {
@@ -125,7 +110,7 @@ export const NoBoundaryCrossingRule: Rule.RuleModule = {
if (!importable.includes(imported.type)) {
context.report({
- node: node as ESTree.Node,
+ node: node as Node,
messageId: 'TYPE_MISMATCH',
data: {
ownType: self.type,
diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.test.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.test.ts
new file mode 100644
index 0000000000000..dc4828603f73f
--- /dev/null
+++ b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.test.ts
@@ -0,0 +1,155 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { RuleTester } from 'eslint';
+import dedent from 'dedent';
+import { NoGroupCrossingImportsRule } from './no_group_crossing_imports';
+import { formatSuggestions } from '../helpers/report';
+import { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
+
+const make = (
+ fromGroup: ModuleGroup,
+ fromVisibility: ModuleVisibility,
+ toGroup: ModuleGroup,
+ toVisibility: ModuleVisibility,
+ imp = 'import'
+) => ({
+ filename: `${fromGroup}.${fromVisibility}.ts`,
+ code: dedent`
+ ${imp} '${toGroup}.${toVisibility}'
+ `,
+});
+
+jest.mock('../get_import_resolver', () => {
+ return {
+ getImportResolver() {
+ return {
+ resolve(req: string) {
+ return {
+ type: 'file',
+ absolute: req.split('.'),
+ };
+ },
+ };
+ },
+ };
+});
+
+jest.mock('../helpers/repo_source_classifier', () => {
+ return {
+ getRepoSourceClassifier() {
+ return {
+ classify(r: string | [string, string]) {
+ const [group, visibility] =
+ typeof r === 'string' ? (r.endsWith('.ts') ? r.slice(0, -3) : r).split('.') : r;
+ return {
+ pkgInfo: {
+ pkgId: 'aPackage',
+ },
+ group,
+ visibility,
+ };
+ },
+ };
+ },
+ };
+});
+
+const tsTester = [
+ '@typescript-eslint/parser',
+ new RuleTester({
+ parser: require.resolve('@typescript-eslint/parser'),
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 2018,
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ }),
+] as const;
+
+const babelTester = [
+ '@babel/eslint-parser',
+ new RuleTester({
+ parser: require.resolve('@babel/eslint-parser'),
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 2018,
+ requireConfigFile: false,
+ babelOptions: {
+ presets: ['@kbn/babel-preset/node_preset'],
+ },
+ },
+ }),
+] as const;
+
+for (const [name, tester] of [tsTester, babelTester]) {
+ describe(name, () => {
+ tester.run('@kbn/imports/no_group_crossing_imports', NoGroupCrossingImportsRule, {
+ valid: [
+ make('observability', 'private', 'observability', 'private'),
+ make('security', 'private', 'security', 'private'),
+ make('search', 'private', 'search', 'private'),
+ make('observability', 'private', 'platform', 'shared'),
+ make('security', 'private', 'common', 'shared'),
+ make('platform', 'shared', 'platform', 'shared'),
+ make('platform', 'shared', 'platform', 'private'),
+ make('common', 'shared', 'common', 'shared'),
+ ],
+
+ invalid: [
+ {
+ ...make('observability', 'private', 'security', 'private'),
+ errors: [
+ {
+ line: 1,
+ messageId: 'ILLEGAL_IMPORT',
+ data: {
+ importerPackage: 'aPackage',
+ importerGroup: 'observability',
+ importedPackage: 'aPackage',
+ importedGroup: 'security',
+ importedVisibility: 'private',
+ sourcePath: 'observability.private.ts',
+ suggestion: formatSuggestions([
+ `Please review the dependencies in your module's manifest (kibana.jsonc).`,
+ `Relocate this module to a different group, and/or make sure it has the right 'visibility'.`,
+ `Address the conflicting dependencies by refactoring the code`,
+ ]),
+ },
+ },
+ ],
+ },
+ {
+ ...make('security', 'private', 'platform', 'private'),
+ errors: [
+ {
+ line: 1,
+ messageId: 'ILLEGAL_IMPORT',
+ data: {
+ importerPackage: 'aPackage',
+ importerGroup: 'security',
+ importedPackage: 'aPackage',
+ importedGroup: 'platform',
+ importedVisibility: 'private',
+ sourcePath: 'security.private.ts',
+ suggestion: formatSuggestions([
+ `Please review the dependencies in your module's manifest (kibana.jsonc).`,
+ `Relocate this module to a different group, and/or make sure it has the right 'visibility'.`,
+ `Address the conflicting dependencies by refactoring the code`,
+ ]),
+ },
+ },
+ ],
+ },
+ ],
+ });
+ });
+}
diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.ts
new file mode 100644
index 0000000000000..255973ab7460a
--- /dev/null
+++ b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.ts
@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { dirname } from 'path';
+import type { Rule } from 'eslint';
+import type { Node } from 'estree';
+import { REPO_ROOT } from '@kbn/repo-info';
+
+import { visitAllImportStatements } from '../helpers/visit_all_import_statements';
+import { getSourcePath } from '../helpers/source';
+import { getRepoSourceClassifier } from '../helpers/repo_source_classifier';
+import { getImportResolver } from '../get_import_resolver';
+import { formatSuggestions } from '../helpers/report';
+import { isImportableFrom } from '../helpers/groups';
+
+export const NoGroupCrossingImportsRule: Rule.RuleModule = {
+ meta: {
+ docs: {
+ url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.mdx#kbnimportsno_unused_imports',
+ },
+ messages: {
+ ILLEGAL_IMPORT: `âš Illegal import statement: "{{importerPackage}}" ({{importerGroup}}) is importing "{{importedPackage}}" ({{importedGroup}}/{{importedVisibility}}). File: {{sourcePath}}\n{{suggestion}}\n`,
+ },
+ },
+ create(context) {
+ const resolver = getImportResolver(context);
+ const classifier = getRepoSourceClassifier(resolver);
+ const sourcePath = getSourcePath(context);
+ const ownDirname = dirname(sourcePath);
+ const self = classifier.classify(sourcePath);
+ const relativePath = sourcePath.replace(REPO_ROOT, '').replace(/^\//, '');
+
+ return visitAllImportStatements((req, { node }) => {
+ if (
+ req === null ||
+ // we can ignore imports using the raw-loader, they will need to be resolved but can be managed on a case by case basis
+ req.startsWith('!!raw-loader')
+ ) {
+ return;
+ }
+
+ const result = resolver.resolve(req, ownDirname);
+ if (result?.type !== 'file' || result.nodeModule) {
+ return;
+ }
+
+ const imported = classifier.classify(result.absolute);
+
+ if (!isImportableFrom(self.group, imported.group, imported.visibility)) {
+ context.report({
+ node: node as Node,
+ messageId: 'ILLEGAL_IMPORT',
+ data: {
+ importerPackage: self.pkgInfo?.pkgId ?? 'unknown',
+ importerGroup: self.group,
+ importedPackage: imported.pkgInfo?.pkgId ?? 'unknown',
+ importedGroup: imported.group,
+ importedVisibility: imported.visibility,
+ sourcePath: relativePath,
+ suggestion: formatSuggestions([
+ `Please review the dependencies in your module's manifest (kibana.jsonc).`,
+ `Relocate this module to a different group, and/or make sure it has the right 'visibility'.`,
+ `Address the conflicting dependencies by refactoring the code`,
+ ]),
+ },
+ });
+ return;
+ }
+ });
+ },
+};
diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.test.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.test.ts
new file mode 100644
index 0000000000000..bf75a01b222bb
--- /dev/null
+++ b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.test.ts
@@ -0,0 +1,280 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { RuleTester } from 'eslint';
+import dedent from 'dedent';
+import { NoGroupCrossingManifestsRule } from './no_group_crossing_manifests';
+import { formatSuggestions } from '../helpers/report';
+import { ModuleId } from '@kbn/repo-source-classifier/src/module_id';
+import { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
+
+const makePlugin = (filename: string) => ({
+ filename,
+ code: dedent`
+ export function plugin() {
+ return new MyPlugin();
+ }
+ `,
+});
+
+const makePluginClass = (filename: string) => ({
+ filename,
+ code: dedent`
+ class MyPlugin implements Plugin {
+ setup() {
+ console.log('foo');
+ }
+ start() {
+ console.log('foo');
+ }
+ }
+ `,
+});
+
+const makeModuleByPath = (
+ path: string,
+ group: ModuleGroup,
+ visibility: ModuleVisibility,
+ pluginOverrides: any = {}
+): Record => {
+ const pluginId = path.split('/')[4];
+ const packageId = `@kbn/${pluginId}-plugin`;
+
+ return {
+ [path]: {
+ type: 'server package',
+ dirs: [],
+ repoRel: 'some/relative/path',
+ pkgInfo: {
+ pkgId: packageId,
+ pkgDir: path.split('/').slice(0, -2).join('/'),
+ rel: 'some/relative/path',
+ },
+ group,
+ visibility,
+ manifest: {
+ type: 'plugin',
+ id: packageId,
+ owner: ['@kbn/kibana-operations'],
+ plugin: {
+ id: pluginId,
+ browser: true,
+ server: true,
+ ...pluginOverrides,
+ },
+ },
+ },
+ };
+};
+
+const makeError = (line: number, ...violations: string[]) => ({
+ line,
+ messageId: 'ILLEGAL_MANIFEST_DEPENDENCY',
+ data: {
+ violations: violations.join('\n'),
+ suggestion: formatSuggestions([
+ `Please review the dependencies in your plugin's manifest (kibana.jsonc).`,
+ `Relocate this module to a different group, and/or make sure it has the right 'visibility'.`,
+ `Address the conflicting dependencies by refactoring the code`,
+ ]),
+ },
+});
+
+jest.mock('../helpers/repo_source_classifier', () => {
+ const MODULES_BY_PATH: Record = {
+ ...makeModuleByPath(
+ 'path/to/search/plugins/searchPlugin1/server/index.ts',
+ 'search',
+ 'private',
+ {
+ requiredPlugins: ['searchPlugin2'], // allowed, same group
+ }
+ ),
+ ...makeModuleByPath(
+ 'path/to/search/plugins/searchPlugin2/server/index.ts',
+ 'search',
+ 'private',
+ {
+ requiredPlugins: ['securityPlugin1'], // invalid, dependency belongs to another group
+ }
+ ),
+ ...makeModuleByPath(
+ 'path/to/security/plugins/securityPlugin1/server/index.ts',
+ 'security',
+ 'private',
+ {
+ requiredPlugins: ['securityPlugin2'], // allowed, same group
+ }
+ ),
+ ...makeModuleByPath(
+ 'path/to/security/plugins/securityPlugin2/server/index.ts',
+ 'security',
+ 'private',
+ {
+ requiredPlugins: ['platformPlugin1', 'platformPlugin2', 'platformPlugin3'], // 3rd one is private!
+ }
+ ),
+ ...makeModuleByPath(
+ 'path/to/platform/shared/platformPlugin1/server/index.ts',
+ 'platform',
+ 'shared',
+ {
+ requiredPlugins: ['platformPlugin2', 'platformPlugin3', 'platformPlugin4'],
+ }
+ ),
+ ...makeModuleByPath(
+ 'path/to/platform/shared/platformPlugin2/server/index.ts',
+ 'platform',
+ 'shared'
+ ),
+ ...makeModuleByPath(
+ 'path/to/platform/private/platformPlugin3/server/index.ts',
+ 'platform',
+ 'private'
+ ),
+ ...makeModuleByPath(
+ 'path/to/platform/private/platformPlugin4/server/index.ts',
+ 'platform',
+ 'private'
+ ),
+ };
+
+ return {
+ getRepoSourceClassifier() {
+ return {
+ classify(path: string) {
+ return MODULES_BY_PATH[path];
+ },
+ };
+ },
+ };
+});
+
+jest.mock('@kbn/repo-packages', () => {
+ const original = jest.requireActual('@kbn/repo-packages');
+
+ return {
+ ...original,
+ getPluginPackagesFilter: () => () => true,
+ getPackages() {
+ return [
+ 'path/to/search/plugins/searchPlugin1/server/index.ts',
+ 'path/to/search/plugins/searchPlugin2/server/index.ts',
+ 'path/to/security/plugins/securityPlugin1/server/index.ts',
+ 'path/to/security/plugins/securityPlugin2/server/index.ts',
+ 'path/to/platform/shared/platformPlugin1/server/index.ts',
+ 'path/to/platform/shared/platformPlugin2/server/index.ts',
+ 'path/to/platform/private/platformPlugin3/server/index.ts',
+ 'path/to/platform/private/platformPlugin4/server/index.ts',
+ ].map((path) => {
+ const [, , group, , id] = path.split('/');
+ return {
+ id: `@kbn/${id}-plugin`,
+ group,
+ visibility: path.includes('platform/shared') ? 'shared' : 'private',
+ manifest: {
+ plugin: {
+ id,
+ },
+ },
+ };
+ });
+ },
+ };
+});
+
+const tsTester = [
+ '@typescript-eslint/parser',
+ new RuleTester({
+ parser: require.resolve('@typescript-eslint/parser'),
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 2018,
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ }),
+] as const;
+
+const babelTester = [
+ '@babel/eslint-parser',
+ new RuleTester({
+ parser: require.resolve('@babel/eslint-parser'),
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 2018,
+ requireConfigFile: false,
+ babelOptions: {
+ presets: ['@kbn/babel-preset/node_preset'],
+ },
+ },
+ }),
+] as const;
+
+for (const [name, tester] of [tsTester, babelTester]) {
+ describe(name, () => {
+ tester.run('@kbn/imports/no_group_crossing_manifests', NoGroupCrossingManifestsRule, {
+ valid: [
+ makePlugin('path/to/search/plugins/searchPlugin1/server/index.ts'),
+ makePlugin('path/to/security/plugins/securityPlugin1/server/index.ts'),
+ makePlugin('path/to/platform/shared/platformPlugin1/server/index.ts'),
+ makePluginClass('path/to/search/plugins/searchPlugin1/server/index.ts'),
+ makePluginClass('path/to/security/plugins/securityPlugin1/server/index.ts'),
+ makePluginClass('path/to/platform/shared/platformPlugin1/server/index.ts'),
+ ],
+ invalid: [
+ {
+ ...makePlugin('path/to/search/plugins/searchPlugin2/server/index.ts'),
+ errors: [
+ makeError(
+ 1,
+ `âš Illegal dependency on manifest: Plugin "searchPlugin2" (package: "@kbn/searchPlugin2-plugin"; group: "search") depends on "securityPlugin1" (package: "@kbn/securityPlugin1-plugin"; group: security/private). File: path/to/search/plugins/searchPlugin2/kibana.jsonc`
+ ),
+ ],
+ },
+ {
+ ...makePlugin('path/to/security/plugins/securityPlugin2/server/index.ts'),
+ errors: [
+ makeError(
+ 1,
+ `âš Illegal dependency on manifest: Plugin "securityPlugin2" (package: "@kbn/securityPlugin2-plugin"; group: "security") depends on "platformPlugin3" (package: "@kbn/platformPlugin3-plugin"; group: platform/private). File: path/to/security/plugins/securityPlugin2/kibana.jsonc`
+ ),
+ ],
+ },
+ {
+ ...makePluginClass('path/to/search/plugins/searchPlugin2/server/index.ts'),
+ errors: [
+ makeError(
+ 2,
+ `âš Illegal dependency on manifest: Plugin "searchPlugin2" (package: "@kbn/searchPlugin2-plugin"; group: "search") depends on "securityPlugin1" (package: "@kbn/securityPlugin1-plugin"; group: security/private). File: path/to/search/plugins/searchPlugin2/kibana.jsonc`
+ ),
+ makeError(
+ 5,
+ `âš Illegal dependency on manifest: Plugin "searchPlugin2" (package: "@kbn/searchPlugin2-plugin"; group: "search") depends on "securityPlugin1" (package: "@kbn/securityPlugin1-plugin"; group: security/private). File: path/to/search/plugins/searchPlugin2/kibana.jsonc`
+ ),
+ ],
+ },
+ {
+ ...makePluginClass('path/to/security/plugins/securityPlugin2/server/index.ts'),
+ errors: [
+ makeError(
+ 2,
+ `âš Illegal dependency on manifest: Plugin "securityPlugin2" (package: "@kbn/securityPlugin2-plugin"; group: "security") depends on "platformPlugin3" (package: "@kbn/platformPlugin3-plugin"; group: platform/private). File: path/to/security/plugins/securityPlugin2/kibana.jsonc`
+ ),
+ makeError(
+ 5,
+ `âš Illegal dependency on manifest: Plugin "securityPlugin2" (package: "@kbn/securityPlugin2-plugin"; group: "security") depends on "platformPlugin3" (package: "@kbn/platformPlugin3-plugin"; group: platform/private). File: path/to/security/plugins/securityPlugin2/kibana.jsonc`
+ ),
+ ],
+ },
+ ],
+ });
+ });
+}
diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.ts
new file mode 100644
index 0000000000000..e68f7217905a5
--- /dev/null
+++ b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.ts
@@ -0,0 +1,158 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { join } from 'path';
+import { TSESTree } from '@typescript-eslint/typescript-estree';
+import type { Rule } from 'eslint';
+import type { Node } from 'estree';
+import { getPackages, getPluginPackagesFilter } from '@kbn/repo-packages';
+import { REPO_ROOT } from '@kbn/repo-info';
+import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
+import { getSourcePath } from '../helpers/source';
+import { getImportResolver } from '../get_import_resolver';
+import { getRepoSourceClassifier } from '../helpers/repo_source_classifier';
+import { isImportableFrom } from '../helpers/groups';
+import { formatSuggestions } from '../helpers/report';
+
+const NODE_TYPES = TSESTree.AST_NODE_TYPES;
+
+interface PluginInfo {
+ id: string;
+ pluginId: string;
+ group: ModuleGroup;
+ visibility: ModuleVisibility;
+}
+
+export const NoGroupCrossingManifestsRule: Rule.RuleModule = {
+ meta: {
+ docs: {
+ url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.mdx#kbnimportsno_unused_imports',
+ },
+ messages: {
+ ILLEGAL_MANIFEST_DEPENDENCY: `{{violations}}\n{{suggestion}}`,
+ },
+ },
+ create(context) {
+ const sourcePath = getSourcePath(context);
+ let manifestPath: string;
+ const resolver = getImportResolver(context);
+ const classifier = getRepoSourceClassifier(resolver);
+ const moduleId = classifier.classify(sourcePath);
+ const offendingDependencies: PluginInfo[] = [];
+ let currentPlugin: PluginInfo;
+
+ if (moduleId.manifest?.type === 'plugin') {
+ manifestPath = join(moduleId.pkgInfo!.pkgDir, 'kibana.jsonc')
+ .replace(REPO_ROOT, '')
+ .replace(/^\//, '');
+ currentPlugin = {
+ id: moduleId.pkgInfo!.pkgId,
+ pluginId: moduleId.manifest.plugin.id,
+ group: moduleId.group,
+ visibility: moduleId.visibility,
+ };
+
+ const allPlugins = getPackages(REPO_ROOT).filter(getPluginPackagesFilter());
+ const currentPluginInfo = moduleId.manifest!.plugin;
+ // check all the dependencies in the manifest, looking for plugin violations
+ [
+ ...(currentPluginInfo.requiredPlugins ?? []),
+ ...(currentPluginInfo.requiredBundles ?? []),
+ ...(currentPluginInfo.optionalPlugins ?? []),
+ ...(currentPluginInfo.runtimePluginDependencies ?? []),
+ ].forEach((pluginId) => {
+ const dependency = allPlugins.find(({ manifest }) => manifest.plugin.id === pluginId);
+ if (dependency) {
+ // at this point, we know the dependency is a plugin
+ const { id, group, visibility } = dependency;
+ if (!isImportableFrom(moduleId.group, group, visibility)) {
+ offendingDependencies.push({ id, pluginId, group, visibility });
+ }
+ }
+ });
+ }
+
+ return {
+ FunctionDeclaration(node) {
+ // complain in exported plugin() function
+ if (
+ currentPlugin &&
+ offendingDependencies.length &&
+ node.id?.name === 'plugin' &&
+ node.parent.type === NODE_TYPES.ExportNamedDeclaration
+ ) {
+ reportViolation({
+ context,
+ node,
+ currentPlugin,
+ manifestPath,
+ offendingDependencies,
+ });
+ }
+ },
+ MethodDefinition(node) {
+ // complain in setup() and start() hooks
+ if (
+ offendingDependencies.length &&
+ node.key.type === NODE_TYPES.Identifier &&
+ (node.key.name === 'setup' || node.key.name === 'start') &&
+ node.kind === 'method' &&
+ node.parent.parent.type === NODE_TYPES.ClassDeclaration &&
+ (node.parent.parent.id?.name.includes('Plugin') ||
+ (node.parent.parent as TSESTree.ClassDeclaration).implements?.find(
+ (value) =>
+ value.expression.type === NODE_TYPES.Identifier &&
+ value.expression.name === 'Plugin'
+ ))
+ ) {
+ reportViolation({
+ context,
+ node,
+ currentPlugin,
+ manifestPath,
+ offendingDependencies,
+ });
+ }
+ },
+ };
+ },
+};
+
+interface ReportViolationParams {
+ context: Rule.RuleContext;
+ node: Node;
+ currentPlugin: PluginInfo;
+ offendingDependencies: PluginInfo[];
+ manifestPath: string;
+}
+
+const reportViolation = ({
+ context,
+ node,
+ currentPlugin,
+ offendingDependencies,
+ manifestPath,
+}: ReportViolationParams) =>
+ context.report({
+ node,
+ messageId: 'ILLEGAL_MANIFEST_DEPENDENCY',
+ data: {
+ violations: [
+ ...offendingDependencies.map(
+ ({ id, pluginId, group, visibility }) =>
+ `âš Illegal dependency on manifest: Plugin "${currentPlugin.pluginId}" (package: "${currentPlugin.id}"; group: "${currentPlugin.group}") depends on "${pluginId}" (package: "${id}"; group: ${group}/${visibility}). File: ${manifestPath}`
+ ),
+ ].join('\n'),
+ suggestion: formatSuggestions([
+ `Please review the dependencies in your plugin's manifest (kibana.jsonc).`,
+ `Relocate this module to a different group, and/or make sure it has the right 'visibility'.`,
+ `Address the conflicting dependencies by refactoring the code`,
+ ]),
+ },
+ });
diff --git a/packages/kbn-eslint-plugin-imports/tsconfig.json b/packages/kbn-eslint-plugin-imports/tsconfig.json
index 087d77fbfe437..b0ab9182171c3 100644
--- a/packages/kbn-eslint-plugin-imports/tsconfig.json
+++ b/packages/kbn-eslint-plugin-imports/tsconfig.json
@@ -14,6 +14,7 @@
"@kbn/import-resolver",
"@kbn/repo-source-classifier",
"@kbn/repo-info",
+ "@kbn/repo-packages",
],
"exclude": [
"target/**/*",
diff --git a/packages/kbn-generate/src/commands/codeowners_command.ts b/packages/kbn-generate/src/commands/codeowners_command.ts
index 79f7025b99a02..a86b4250d6850 100644
--- a/packages/kbn-generate/src/commands/codeowners_command.ts
+++ b/packages/kbn-generate/src/commands/codeowners_command.ts
@@ -63,7 +63,11 @@ export const CodeownersCommand: GenerateCommand = {
}
const newCodeowners = `${GENERATED_START}${pkgs
- .map((pkg) => `${pkg.normalizedRepoRelativeDir} ${pkg.manifest.owner.join(' ')}`)
+ .map(
+ (pkg) =>
+ pkg.normalizedRepoRelativeDir +
+ (pkg.manifest.owner.length ? ' ' + pkg.manifest.owner.join(' ') : '')
+ )
.join('\n')}${GENERATED_END}${content}${ULTIMATE_PRIORITY_RULES}`;
if (newCodeowners === oldCodeowners) {
diff --git a/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts b/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts
index 441df3948632b..30682d763e0b0 100644
--- a/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts
+++ b/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts
@@ -48,6 +48,20 @@ export const MANIFEST_V2: JSONSchema = {
For additional codeowners, the value can be an array of user/team names.
`,
},
+ group: {
+ enum: ['common', 'platform', 'observability', 'security', 'search'],
+ description: desc`
+ Specifies the group to which this module pertains.
+ `,
+ default: 'common',
+ },
+ visibility: {
+ enum: ['private', 'shared'],
+ description: desc`
+ Specifies the visibility of this module, i.e. whether it can be accessed by everybody or only modules in the same group
+ `,
+ default: 'shared',
+ },
devOnly: {
type: 'boolean',
description: desc`
diff --git a/packages/kbn-manifest/README.md b/packages/kbn-manifest/README.md
new file mode 100644
index 0000000000000..a7dc2054252dc
--- /dev/null
+++ b/packages/kbn-manifest/README.md
@@ -0,0 +1,30 @@
+# @kbn/manifest
+
+This package contains a CLI to list `kibana.jsonc` manifests and also to mass update their properties.
+
+## Usage
+
+To list all `kibana.jsonc` manifests, run the following command from the root of the Kibana repo:
+
+```sh
+node scripts/manifest --list all
+```
+
+To print a manifest by packageId or by pluginId, run the following command from the root of the Kibana repo:
+
+```sh
+node scripts/manifest --package @kbn/package_name
+node scripts/manifest --plugin pluginId
+```
+
+To update properties in one or more manifest files, run the following command from the root of the Kibana repo:
+
+```sh
+node scripts/manifest \
+--package @kbn/package_1 \
+--package @kbn/package_2 \
+# ...
+--package @kbn/package_N \
+--set path.to.property1=value \
+--set property2=value
+```
diff --git a/packages/kbn-manifest/index.ts b/packages/kbn-manifest/index.ts
new file mode 100644
index 0000000000000..5fc4727a1a72d
--- /dev/null
+++ b/packages/kbn-manifest/index.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { run } from '@kbn/dev-cli-runner';
+import { listManifestFiles, printManifest, updateManifest } from './manifest';
+
+/**
+ * A CLI to manipulate Kibana package manifest files
+ */
+export const runKbnManifestCli = () => {
+ run(
+ async ({ log, flags }) => {
+ if (flags.list === 'all') {
+ listManifestFiles(flags, log);
+ } else {
+ if (!flags.package && !flags.plugin) {
+ throw new Error('You must specify the identifer of the --package or --plugin to update.');
+ }
+ await updateManifest(flags, log);
+ await printManifest(flags, log);
+ }
+ },
+ {
+ log: {
+ defaultLevel: 'info',
+ },
+ flags: {
+ string: ['list', 'package', 'plugin', 'set', 'unset'],
+ help: `
+ Usage: node scripts/manifest --package --set group=platform --set visibility=private
+ --list all List all the manifests
+ --package [packageId] Select a package to update.
+ --plugin [pluginId] Select a plugin to update.
+ --set [property] [value] Set the desired "[property]": "[value]"
+ --unset [property] Removes the desired "[property]: value" from the manifest
+ `,
+ },
+ }
+ );
+};
diff --git a/packages/kbn-manifest/jest.config.js b/packages/kbn-manifest/jest.config.js
new file mode 100644
index 0000000000000..ed8288d9fb712
--- /dev/null
+++ b/packages/kbn-manifest/jest.config.js
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+module.exports = {
+ preset: '@kbn/test/jest_node',
+ rootDir: '../..',
+ roots: ['/packages/kbn-manifest'],
+};
diff --git a/packages/kbn-manifest/kibana.jsonc b/packages/kbn-manifest/kibana.jsonc
new file mode 100644
index 0000000000000..27f2d95e65501
--- /dev/null
+++ b/packages/kbn-manifest/kibana.jsonc
@@ -0,0 +1,5 @@
+{
+ "type": "shared-server",
+ "id": "@kbn/manifest",
+ "owner": "@elastic/kibana-core"
+}
diff --git a/packages/kbn-manifest/manifest.ts b/packages/kbn-manifest/manifest.ts
new file mode 100644
index 0000000000000..a839dba7b4077
--- /dev/null
+++ b/packages/kbn-manifest/manifest.ts
@@ -0,0 +1,113 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { join } from 'path';
+import { writeFile } from 'fs/promises';
+import { flatMap, unset } from 'lodash';
+import { set } from '@kbn/safer-lodash-set';
+import type { ToolingLog } from '@kbn/tooling-log';
+import type { Flags } from '@kbn/dev-cli-runner';
+import { type Package, getPackages } from '@kbn/repo-packages';
+import { REPO_ROOT } from '@kbn/repo-info';
+
+const MANIFEST_FILE = 'kibana.jsonc';
+
+const getKibanaJsonc = (flags: Flags, log: ToolingLog): Package[] => {
+ const modules = getPackages(REPO_ROOT);
+
+ let packageIds: string[] = [];
+ let pluginIds: string[] = [];
+
+ if (typeof flags.package === 'string') {
+ packageIds = [flags.package].filter(Boolean);
+ } else if (Array.isArray(flags.package)) {
+ packageIds = [...flags.package].filter(Boolean);
+ }
+
+ if (typeof flags.plugin === 'string') {
+ pluginIds = [flags.plugin].filter(Boolean);
+ } else if (Array.isArray(flags.plugin)) {
+ pluginIds = [...flags.plugin].filter(Boolean);
+ }
+
+ return modules.filter(
+ (pkg) =>
+ packageIds.includes(pkg.id) || (pkg.isPlugin() && pluginIds.includes(pkg.manifest.plugin.id))
+ );
+};
+
+export const listManifestFiles = (flags: Flags, log: ToolingLog) => {
+ const modules = getPackages(REPO_ROOT);
+ modules
+ .filter((module) => module.manifest.type === 'plugin')
+ .forEach((module) => {
+ log.info(join(module.directory, MANIFEST_FILE), module.id);
+ });
+};
+
+export const printManifest = (flags: Flags, log: ToolingLog) => {
+ const kibanaJsoncs = getKibanaJsonc(flags, log);
+ kibanaJsoncs.forEach((kibanaJsonc) => {
+ const manifestPath = join(kibanaJsonc.directory, MANIFEST_FILE);
+ log.info('\n\nShowing manifest: ', manifestPath);
+ log.info(JSON.stringify(kibanaJsonc, null, 2));
+ });
+};
+
+export const updateManifest = async (flags: Flags, log: ToolingLog) => {
+ let toSet: string[] = [];
+ let toUnset: string[] = [];
+
+ if (typeof flags.set === 'string') {
+ toSet = [flags.set].filter(Boolean);
+ } else if (Array.isArray(flags.set)) {
+ toSet = [...flags.set].filter(Boolean);
+ }
+
+ if (typeof flags.unset === 'string') {
+ toUnset = [flags.unset].filter(Boolean);
+ } else if (Array.isArray(flags.unset)) {
+ toUnset = [...flags.unset].filter(Boolean);
+ }
+
+ if (!toSet.length && !toUnset.length) {
+ // no need to update anything
+ return;
+ }
+
+ const kibanaJsoncs = getKibanaJsonc(flags, log);
+
+ for (let i = 0; i < kibanaJsoncs.length; ++i) {
+ const kibanaJsonc = kibanaJsoncs[i];
+
+ if (kibanaJsonc?.manifest) {
+ const manifestPath = join(kibanaJsonc.directory, MANIFEST_FILE);
+ log.info('Updating manifest: ', manifestPath);
+ toSet.forEach((propValue) => {
+ const [prop, value] = propValue.split('=');
+ log.info(`Setting "${prop}": "${value}"`);
+ set(kibanaJsonc.manifest, prop, value);
+ });
+
+ toUnset.forEach((prop) => {
+ log.info(`Removing "${prop}"`);
+ unset(kibanaJsonc.manifest, prop);
+ });
+
+ sanitiseManifest(kibanaJsonc);
+
+ await writeFile(manifestPath, JSON.stringify(kibanaJsonc.manifest, null, 2));
+ log.info('DONE');
+ }
+ }
+};
+
+const sanitiseManifest = (kibanaJsonc: Package) => {
+ kibanaJsonc.manifest.owner = flatMap(kibanaJsonc.manifest.owner.map((owner) => owner.split(' ')));
+};
diff --git a/packages/kbn-manifest/package.json b/packages/kbn-manifest/package.json
new file mode 100644
index 0000000000000..52304cc4c1e21
--- /dev/null
+++ b/packages/kbn-manifest/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@kbn/manifest",
+ "private": true,
+ "version": "1.0.0",
+ "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
+}
diff --git a/packages/kbn-manifest/tsconfig.json b/packages/kbn-manifest/tsconfig.json
new file mode 100644
index 0000000000000..1ee41aafca1ee
--- /dev/null
+++ b/packages/kbn-manifest/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "target/types",
+ "types": [
+ "jest",
+ "node"
+ ]
+ },
+ "include": [
+ "**/*.ts",
+ ],
+ "exclude": [
+ "target/**/*"
+ ],
+ "kbn_references": [
+ "@kbn/dev-cli-runner",
+ "@kbn/repo-info",
+ "@kbn/repo-packages",
+ "@kbn/safer-lodash-set",
+ "@kbn/tooling-log",
+ ]
+}
diff --git a/packages/kbn-repo-info/types.ts b/packages/kbn-repo-info/types.ts
index a4776c28760a2..338881e878fdc 100644
--- a/packages/kbn-repo-info/types.ts
+++ b/packages/kbn-repo-info/types.ts
@@ -7,6 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
+export type ModuleGroup = 'platform' | 'observability' | 'search' | 'security' | 'common';
+export type ModuleVisibility = 'private' | 'shared';
+
export interface KibanaPackageJson {
name: string;
version: string;
@@ -27,4 +30,6 @@ export interface KibanaPackageJson {
[name: string]: string | undefined;
};
[key: string]: unknown;
+ group?: ModuleGroup;
+ visibility?: ModuleVisibility;
}
diff --git a/packages/kbn-repo-packages/modern/package.js b/packages/kbn-repo-packages/modern/package.js
index 1c44cd0cf86d9..3ec33a69e841a 100644
--- a/packages/kbn-repo-packages/modern/package.js
+++ b/packages/kbn-repo-packages/modern/package.js
@@ -116,6 +116,22 @@ class Package {
* @readonly
*/
this.id = manifest.id;
+
+ const { group, visibility } = this.determineGroupAndVisibility();
+
+ /**
+ * the group to which this package belongs
+ * @type {import('@kbn/repo-info/types').ModuleGroup}
+ * @readonly
+ */
+
+ this.group = group;
+ /**
+ * the visibility of this package, i.e. whether it can be accessed by everybody or only modules in the same group
+ * @type {import('@kbn/repo-info/types').ModuleVisibility}
+ * @readonly
+ */
+ this.visibility = visibility;
}
/**
@@ -140,6 +156,24 @@ class Package {
return this.manifest.type === 'plugin';
}
+ /**
+ * Returns the group to which this package belongs
+ * @readonly
+ * @returns {import('@kbn/repo-info/types').ModuleGroup}
+ */
+ getGroup() {
+ return this.group;
+ }
+
+ /**
+ * Returns the package visibility, i.e. whether it can be accessed by everybody or only packages in the same group
+ * @readonly
+ * @returns {import('@kbn/repo-info/types').ModuleVisibility}
+ */
+ getVisibility() {
+ return this.visibility;
+ }
+
/**
* Returns true if the package represents some type of plugin
* @returns {import('./types').PluginCategoryInfo}
@@ -158,6 +192,7 @@ class Package {
const oss = !dir.startsWith('x-pack/');
const example = dir.startsWith('examples/') || dir.startsWith('x-pack/examples/');
const testPlugin = dir.startsWith('test/') || dir.startsWith('x-pack/test/');
+
return {
oss,
example,
@@ -165,6 +200,40 @@ class Package {
};
}
+ determineGroupAndVisibility() {
+ const dir = this.normalizedRepoRelativeDir;
+
+ /** @type {import('@kbn/repo-info/types').ModuleGroup} */
+ let group = 'common';
+ /** @type {import('@kbn/repo-info/types').ModuleVisibility} */
+ let visibility = 'shared';
+
+ if (dir.startsWith('src/platform/') || dir.startsWith('x-pack/platform/')) {
+ group = 'platform';
+ visibility =
+ /src\/platform\/[^\/]+\/shared/.test(dir) || /x-pack\/platform\/[^\/]+\/shared/.test(dir)
+ ? 'shared'
+ : 'private';
+ } else if (dir.startsWith('x-pack/solutions/search/')) {
+ group = 'search';
+ visibility = 'private';
+ } else if (dir.startsWith('x-pack/solutions/security/')) {
+ group = 'security';
+ visibility = 'private';
+ } else if (dir.startsWith('x-pack/solutions/observability/')) {
+ group = 'observability';
+ visibility = 'private';
+ } else {
+ group = this.manifest.group ?? 'common';
+ // if the group is 'private-only', enforce it
+ visibility = ['search', 'security', 'observability'].includes(group)
+ ? 'private'
+ : this.manifest.visibility ?? 'shared';
+ }
+
+ return { group, visibility };
+ }
+
/**
* Custom inspect handler so that logging variables in scripts/generate doesn't
* print all the BUILD.bazel files
diff --git a/packages/kbn-repo-packages/modern/parse_package_manifest.js b/packages/kbn-repo-packages/modern/parse_package_manifest.js
index 40a6f7bf1059b..46004983848bb 100644
--- a/packages/kbn-repo-packages/modern/parse_package_manifest.js
+++ b/packages/kbn-repo-packages/modern/parse_package_manifest.js
@@ -225,16 +225,20 @@ function validatePackageManifest(parsed, repoRoot, path) {
type,
id,
owner,
+ group,
+ visibility,
devOnly,
- plugin,
- sharedBrowserBundle,
build,
description,
serviceFolders,
...extra
- } = parsed;
+ } = /** @type {import('./types').PackageManifestBaseFields} */ (/** @type {unknown} */ (parsed));
- const extraKeys = Object.keys(extra);
+ const { plugin, sharedBrowserBundle } = parsed;
+
+ const extraKeys = Object.keys(extra).filter(
+ (key) => !['plugin', 'sharedBrowserBundle'].includes(key)
+ );
if (extraKeys.length) {
throw new Error(`unexpected keys in package manifest [${extraKeys.join(', ')}]`);
}
@@ -258,6 +262,25 @@ function validatePackageManifest(parsed, repoRoot, path) {
);
}
+ if (
+ group !== undefined &&
+ (!isSomeString(group) ||
+ !['platform', 'search', 'security', 'observability', 'common'].includes(group))
+ ) {
+ throw err(
+ `plugin.group`,
+ group,
+ `must have a valid value ("platform" | "search" | "security" | "observability" | "common")`
+ );
+ }
+
+ if (
+ visibility !== undefined &&
+ (!isSomeString(visibility) || !['private', 'shared'].includes(visibility))
+ ) {
+ throw err(`plugin.visibility`, visibility, `must have a valid value ("private" | "shared")`);
+ }
+
if (devOnly !== undefined && typeof devOnly !== 'boolean') {
throw err(`devOnly`, devOnly, `must be a boolean when defined`);
}
@@ -273,6 +296,8 @@ function validatePackageManifest(parsed, repoRoot, path) {
const base = {
id,
owner: Array.isArray(owner) ? owner : [owner],
+ group,
+ visibility,
devOnly,
build: validatePackageManifestBuild(build),
description,
diff --git a/packages/kbn-repo-packages/modern/types.ts b/packages/kbn-repo-packages/modern/types.ts
index 41250de7c6346..c883e33d82497 100644
--- a/packages/kbn-repo-packages/modern/types.ts
+++ b/packages/kbn-repo-packages/modern/types.ts
@@ -7,6 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
+import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
import type { Package } from './package';
import type { PLUGIN_CATEGORY } from './plugin_category_info';
@@ -44,7 +45,7 @@ export type KibanaPackageType =
| 'functional-tests'
| 'test-helper';
-interface PackageManifestBaseFields {
+export interface PackageManifestBaseFields {
/**
* The type of this package. Package types define how a package can and should
* be used/built. Some package types also change the way that packages are
@@ -91,6 +92,14 @@ interface PackageManifestBaseFields {
* @deprecated
*/
serviceFolders?: string[];
+ /**
+ * Specifies the group to which this package belongs
+ */
+ group?: ModuleGroup;
+ /**
+ * Specifies the package visibility, i.e. whether it can be accessed by everybody or only packages in the same group
+ */
+ visibility?: ModuleVisibility;
}
export interface PluginPackageManifest extends PackageManifestBaseFields {
diff --git a/packages/kbn-repo-packages/tsconfig.json b/packages/kbn-repo-packages/tsconfig.json
index 19c7e8d59f651..be62cc1a4c90b 100644
--- a/packages/kbn-repo-packages/tsconfig.json
+++ b/packages/kbn-repo-packages/tsconfig.json
@@ -14,5 +14,8 @@
],
"exclude": [
"target/**/*",
+ ],
+ "kbn_references": [
+ "@kbn/repo-info",
]
}
diff --git a/packages/kbn-repo-source-classifier/src/group.ts b/packages/kbn-repo-source-classifier/src/group.ts
new file mode 100644
index 0000000000000..8103d5c82c590
--- /dev/null
+++ b/packages/kbn-repo-source-classifier/src/group.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
+
+interface ModuleAttrs {
+ group: ModuleGroup;
+ visibility: ModuleVisibility;
+}
+
+const DEFAULT_MODULE_ATTRS: ModuleAttrs = {
+ group: 'common',
+ visibility: 'shared',
+};
+
+const MODULE_GROUPING_BY_PATH: Record = {
+ 'src/platform/plugins/shared': {
+ group: 'platform',
+ visibility: 'shared',
+ },
+ 'src/platform/plugins/internal': {
+ group: 'platform',
+ visibility: 'private',
+ },
+ 'x-pack/platform/plugins/shared': {
+ group: 'platform',
+ visibility: 'shared',
+ },
+ 'x-pack/platform/plugins/internal': {
+ group: 'platform',
+ visibility: 'private',
+ },
+ 'x-pack/solutions/observability/plugins': {
+ group: 'observability',
+ visibility: 'private',
+ },
+ 'x-pack/solutions/security/plugins': {
+ group: 'security',
+ visibility: 'private',
+ },
+ 'x-pack/solutions/search/plugins': {
+ group: 'search',
+ visibility: 'private',
+ },
+};
+
+/**
+ * Determine a plugin's grouping information based on the path where it is defined
+ * @param packageRelativePath the path in the repo where the package is located
+ * @returns The grouping information that corresponds to the given path
+ */
+export function inferGroupAttrsFromPath(packageRelativePath: string): ModuleAttrs {
+ const grouping = Object.entries(MODULE_GROUPING_BY_PATH).find(([chunk]) =>
+ packageRelativePath.startsWith(chunk)
+ )?.[1];
+ return grouping ?? DEFAULT_MODULE_ATTRS;
+}
diff --git a/packages/kbn-repo-source-classifier/src/module_id.ts b/packages/kbn-repo-source-classifier/src/module_id.ts
index 6af8ece2438fa..284ffe26de0db 100644
--- a/packages/kbn-repo-source-classifier/src/module_id.ts
+++ b/packages/kbn-repo-source-classifier/src/module_id.ts
@@ -7,16 +7,24 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
-import { ModuleType } from './module_type';
-import { PkgInfo } from './pkg_info';
+import type { KibanaPackageManifest } from '@kbn/repo-packages';
+import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
+import type { ModuleType } from './module_type';
+import type { PkgInfo } from './pkg_info';
export interface ModuleId {
/** Type of the module */
type: ModuleType;
+ /** Specifies the group to which this module belongs */
+ group: ModuleGroup;
+ /** Specifies the module visibility, i.e. whether it can be accessed by everybody or only modules in the same group */
+ visibility: ModuleVisibility;
/** repo relative path to the module's source file */
repoRel: string;
/** info about the package the source file is within, in the case the file is found within a package */
pkgInfo?: PkgInfo;
+ /** The type of package, as described in the manifest */
+ manifest?: KibanaPackageManifest;
/** path segments of the dirname of this */
dirs: string[];
}
diff --git a/packages/kbn-repo-source-classifier/src/repo_source_classifier.ts b/packages/kbn-repo-source-classifier/src/repo_source_classifier.ts
index 470dd3c424421..c0ab29f659ebd 100644
--- a/packages/kbn-repo-source-classifier/src/repo_source_classifier.ts
+++ b/packages/kbn-repo-source-classifier/src/repo_source_classifier.ts
@@ -7,11 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
-import { ImportResolver } from '@kbn/import-resolver';
-import { ModuleId } from './module_id';
-import { ModuleType } from './module_type';
+import type { ImportResolver } from '@kbn/import-resolver';
+import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
+import type { KibanaPackageManifest } from '@kbn/repo-packages/modern/types';
+import type { ModuleId } from './module_id';
+import type { ModuleType } from './module_type';
import { RANDOM_TEST_FILE_NAMES, TEST_DIR, TEST_TAG } from './config';
import { RepoPath } from './repo_path';
+import { inferGroupAttrsFromPath } from './group';
const STATIC_EXTS = new Set(
'json|woff|woff2|ttf|eot|svg|ico|png|jpg|gif|jpeg|html|md|txt|tmpl|xml'
@@ -231,7 +234,43 @@ export class RepoSourceClassifier {
return 'common package';
}
- classify(absolute: string) {
+ private getManifest(path: RepoPath): KibanaPackageManifest | undefined {
+ const pkgInfo = path.getPkgInfo();
+ return pkgInfo?.pkgId ? this.resolver.getPkgManifest(pkgInfo!.pkgId) : undefined;
+ }
+ /**
+ * Determine the "group" of a file
+ */
+ private getGroup(path: RepoPath): ModuleGroup {
+ const attrs = inferGroupAttrsFromPath(path.getRepoRel());
+ const manifest = this.getManifest(path);
+
+ if (attrs.group !== 'common') {
+ // this package has been moved to a 'group-specific' folder, the group is determined by its location
+ return attrs.group;
+ } else {
+ // the package is still in its original location, allow Manifest to dictate its group
+ return manifest?.group ?? 'common';
+ }
+ }
+
+ /**
+ * Determine the "visibility" of a file
+ */
+ private getVisibility(path: RepoPath): ModuleVisibility {
+ const attrs = inferGroupAttrsFromPath(path.getRepoRel());
+ const manifest = this.getManifest(path);
+
+ if (attrs.group !== 'common') {
+ // this package has been moved to a 'group-specific' folder, the visibility is determined by its location
+ return attrs.visibility;
+ } else {
+ // the package is still in its original location, allow Manifest to dictate its visibility
+ return manifest?.visibility ?? 'shared';
+ }
+ }
+
+ classify(absolute: string): ModuleId {
const path = this.getRepoPath(absolute);
const cached = this.ids.get(path);
@@ -241,8 +280,12 @@ export class RepoSourceClassifier {
const id: ModuleId = {
type: this.getType(path),
+ group: this.getGroup(path),
+ visibility: this.getVisibility(path),
repoRel: path.getRepoRel(),
pkgInfo: path.getPkgInfo() ?? undefined,
+ manifest:
+ (path.getPkgInfo() && this.resolver.getPkgManifest(path.getPkgInfo()!.pkgId)) ?? undefined,
dirs: path.getSegs(),
};
this.ids.set(path, id);
diff --git a/packages/kbn-repo-source-classifier/tsconfig.json b/packages/kbn-repo-source-classifier/tsconfig.json
index f41dffcd32f06..418b114eebafa 100644
--- a/packages/kbn-repo-source-classifier/tsconfig.json
+++ b/packages/kbn-repo-source-classifier/tsconfig.json
@@ -13,6 +13,7 @@
"kbn_references": [
"@kbn/import-resolver",
"@kbn/repo-info",
+ "@kbn/repo-packages",
],
"exclude": [
"target/**/*",
diff --git a/packages/kbn-search-index-documents/lib/fetch_search_results.test.ts b/packages/kbn-search-index-documents/lib/fetch_search_results.test.ts
index 470f8ba602ebc..a47c351a00180 100644
--- a/packages/kbn-search-index-documents/lib/fetch_search_results.test.ts
+++ b/packages/kbn-search-index-documents/lib/fetch_search_results.test.ts
@@ -88,6 +88,7 @@ describe('fetchSearchResults lib function', () => {
index: indexName,
q: query,
size: DEFAULT_DOCS_PER_PAGE,
+ track_total_hits: false,
});
});
@@ -109,6 +110,7 @@ describe('fetchSearchResults lib function', () => {
index: indexName,
q: '\\"yellow banana\\"',
size: DEFAULT_DOCS_PER_PAGE,
+ track_total_hits: false,
});
});
@@ -123,6 +125,7 @@ describe('fetchSearchResults lib function', () => {
from: DEFAULT_FROM_VALUE,
index: indexName,
size: DEFAULT_DOCS_PER_PAGE,
+ track_total_hits: false,
});
});
@@ -150,6 +153,42 @@ describe('fetchSearchResults lib function', () => {
index: indexName,
q: query,
size: DEFAULT_DOCS_PER_PAGE,
+ track_total_hits: false,
+ });
+ });
+
+ it('should send track_total_hits true when specified', async () => {
+ mockClient.search.mockImplementationOnce(() =>
+ Promise.resolve({
+ ...mockSearchResponseWithHits,
+ hits: {
+ ...mockSearchResponseWithHits.hits,
+ total: {
+ ...mockSearchResponseWithHits.hits.total,
+ value: 0,
+ },
+ hits: [],
+ },
+ })
+ );
+
+ await expect(
+ fetchSearchResults(
+ mockClient as unknown as ElasticsearchClient,
+ indexName,
+ query,
+ 0,
+ 25,
+ true
+ )
+ ).resolves.toEqual(emptySearchResultsResponse);
+
+ expect(mockClient.search).toHaveBeenCalledWith({
+ from: DEFAULT_FROM_VALUE,
+ index: indexName,
+ q: query,
+ size: DEFAULT_DOCS_PER_PAGE,
+ track_total_hits: true,
});
});
});
diff --git a/packages/kbn-search-index-documents/lib/fetch_search_results.ts b/packages/kbn-search-index-documents/lib/fetch_search_results.ts
index c5cefdf67ed9d..1831920f5d4c1 100644
--- a/packages/kbn-search-index-documents/lib/fetch_search_results.ts
+++ b/packages/kbn-search-index-documents/lib/fetch_search_results.ts
@@ -18,7 +18,8 @@ export const fetchSearchResults = async (
indexName: string,
query?: string,
from: number = 0,
- size: number = DEFAULT_DOCS_PER_PAGE
+ size: number = DEFAULT_DOCS_PER_PAGE,
+ trackTotalHits: boolean = false
): Promise> => {
const result = await fetchWithPagination(
async () =>
@@ -27,6 +28,7 @@ export const fetchSearchResults = async (
index: indexName,
size,
...(!!query ? { q: escapeLuceneChars(query) } : {}),
+ track_total_hits: trackTotalHits,
}),
from,
size
diff --git a/scripts/manifest.js b/scripts/manifest.js
new file mode 100644
index 0000000000000..f9da9c3d174bd
--- /dev/null
+++ b/scripts/manifest.js
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+require('../src/setup_node_env');
+require('@kbn/manifest').runKbnManifestCli();
diff --git a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts
index 66820ff0d3e94..314ee4d03042c 100644
--- a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts
+++ b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts
@@ -116,7 +116,14 @@ export const getDateHistogramBucketAgg = ({
dateFormat: getConfig('dateFormat'),
'dateFormat:scaled': getConfig('dateFormat:scaled'),
});
- updateTimeBuckets(this, calculateBounds, buckets);
+
+ try {
+ updateTimeBuckets(this, calculateBounds, buckets);
+ } catch (e) {
+ // swallow the error even though the agg is misconfigured
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
return buckets;
},
diff --git a/src/plugins/discover/public/components/data_types/logs/service_name_cell.test.tsx b/src/plugins/discover/public/components/data_types/logs/service_name_cell.test.tsx
index 8cf45be4f09e5..3171c5e61e629 100644
--- a/src/plugins/discover/public/components/data_types/logs/service_name_cell.test.tsx
+++ b/src/plugins/discover/public/components/data_types/logs/service_name_cell.test.tsx
@@ -7,15 +7,46 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
+import React from 'react';
import { buildDataTableRecord, DataTableRecord } from '@kbn/discover-utils';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import { render, screen } from '@testing-library/react';
-import React from 'react';
+import { DataGridDensity, ROWS_HEIGHT_OPTIONS } from '@kbn/unified-data-table';
import { getServiceNameCell } from './service_name_cell';
+import { CellRenderersExtensionParams } from '../../../context_awareness';
+
+const core = {
+ application: {
+ capabilities: {
+ apm: {
+ show: true,
+ },
+ },
+ },
+ uiSettings: {
+ get: () => true,
+ },
+};
+
+jest.mock('../../../hooks/use_discover_services', () => {
+ const originalModule = jest.requireActual('../../../hooks/use_discover_services');
+ return {
+ ...originalModule,
+ useDiscoverServices: () => ({ core, share: {} }),
+ };
+});
const renderCell = (serviceNameField: string, record: DataTableRecord) => {
- const ServiceNameCell = getServiceNameCell(serviceNameField);
+ const cellRenderersExtensionParamsMock: CellRenderersExtensionParams = {
+ actions: {
+ addFilter: jest.fn(),
+ },
+ dataView: dataViewMock,
+ density: DataGridDensity.COMPACT,
+ rowHeight: ROWS_HEIGHT_OPTIONS.single,
+ };
+ const ServiceNameCell = getServiceNameCell(serviceNameField, cellRenderersExtensionParamsMock);
render(
{
dataViewMock
);
renderCell('service.name', record);
- expect(screen.getByTestId('serviceNameCell-nodejs')).toBeInTheDocument();
- });
-
- it('renders default icon with unknwon test subject if agent name is missing', () => {
- const record = buildDataTableRecord(
- { fields: { 'service.name': 'test-service' } },
- dataViewMock
- );
- renderCell('service.name', record);
- expect(screen.getByTestId('serviceNameCell-unknown')).toBeInTheDocument();
+ expect(screen.getByTestId('dataTableCellActionsPopover_service.name')).toBeInTheDocument();
});
- it('does not render if service name is missing', () => {
+ it('does render empty div if service name is missing', () => {
const record = buildDataTableRecord({ fields: { 'agent.name': 'nodejs' } }, dataViewMock);
renderCell('service.name', record);
- expect(screen.queryByTestId('serviceNameCell-nodejs')).not.toBeInTheDocument();
- expect(screen.queryByTestId('serviceNameCell-unknown')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('serviceNameCell-empty')).toBeInTheDocument();
});
});
diff --git a/src/plugins/discover/public/components/data_types/logs/service_name_cell.tsx b/src/plugins/discover/public/components/data_types/logs/service_name_cell.tsx
index 39d112de5258e..cd94cd609dc69 100644
--- a/src/plugins/discover/public/components/data_types/logs/service_name_cell.tsx
+++ b/src/plugins/discover/public/components/data_types/logs/service_name_cell.tsx
@@ -7,19 +7,27 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
-import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
+import React from 'react';
+import { EuiToolTip } from '@elastic/eui';
import type { AgentName } from '@kbn/elastic-agent-utils';
import { dynamic } from '@kbn/shared-ux-utility';
import type { DataGridCellValueElementProps } from '@kbn/unified-data-table';
-import React from 'react';
+import { css } from '@emotion/react';
import { getFieldValue } from '@kbn/discover-utils';
+import { euiThemeVars } from '@kbn/ui-theme';
+import { CellRenderersExtensionParams } from '../../../context_awareness';
import { AGENT_NAME_FIELD } from '../../../../common/data_types/logs/constants';
+import { ServiceNameBadgeWithActions } from './service_name_badge_with_actions';
-const dataTestSubj = 'serviceNameCell';
const AgentIcon = dynamic(() => import('@kbn/custom-icons/src/components/agent_icon'));
+const dataTestSubj = 'serviceNameCell';
+const agentIconStyle = css`
+ margin-right: ${euiThemeVars.euiSizeXS};
+`;
export const getServiceNameCell =
- (serviceNameField: string) => (props: DataGridCellValueElementProps) => {
+ (serviceNameField: string, { actions }: CellRenderersExtensionParams) =>
+ (props: DataGridCellValueElementProps) => {
const serviceNameValue = getFieldValue(props.row, serviceNameField) as string;
const agentName = getFieldValue(props.row, AGENT_NAME_FIELD) as AgentName;
@@ -27,19 +35,18 @@ export const getServiceNameCell =
return -;
}
+ const getIcon = () => (
+
+
+
+ );
+
return (
-
-
-
-
-
-
- {serviceNameValue}
-
+
);
};
diff --git a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/accessors/get_cell_renderers.tsx b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/accessors/get_cell_renderers.tsx
index 9e45892070120..7e13baf8ddcf9 100644
--- a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/accessors/get_cell_renderers.tsx
+++ b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/accessors/get_cell_renderers.tsx
@@ -31,8 +31,8 @@ export const getCellRenderers: DataSourceProfileProvider['profile']['getCellRend
...SERVICE_NAME_FIELDS.reduce(
(acc, field) => ({
...acc,
- [field]: getServiceNameCell(field),
- [`${field}.keyword`]: getServiceNameCell(`${field}.keyword`),
+ [field]: getServiceNameCell(field, params),
+ [`${field}.keyword`]: getServiceNameCell(`${field}.keyword`, params),
}),
{}
),
diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.test.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.test.tsx
index 9297b84de6dbc..9b95e58baac38 100644
--- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.test.tsx
+++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.test.tsx
@@ -415,6 +415,21 @@ describe('SharingMetaFields', () => {
`);
});
+ it('Should convert to absolute correctly', () => {
+ jest.useFakeTimers().setSystemTime(new Date('2024-10-21T10:19:31.254Z'));
+
+ const from = 'now-1d/d';
+ const to = 'now-1d/d';
+ const component = ;
+
+ expect(shallow(component)).toMatchInlineSnapshot(`
+
+ `);
+ });
+
it('Should render the component without data-shared-timefilter-duration if time is not set correctly', () => {
const component = (
diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx
index 8321ca9130f54..4d4db4438d74d 100644
--- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx
+++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx
@@ -209,7 +209,7 @@ export const SharingMetaFields = React.memo(function SharingMetaFields({
try {
const dateRangePretty = usePrettyDuration({
timeFrom: toAbsoluteString(from),
- timeTo: toAbsoluteString(to),
+ timeTo: toAbsoluteString(to, true),
quickRanges: [],
dateFormat,
});
diff --git a/src/plugins/visualizations/public/wizard/group_selection/group_selection.test.tsx b/src/plugins/visualizations/public/wizard/group_selection/group_selection.test.tsx
index 2c1411a82028e..1df7925ba28e5 100644
--- a/src/plugins/visualizations/public/wizard/group_selection/group_selection.test.tsx
+++ b/src/plugins/visualizations/public/wizard/group_selection/group_selection.test.tsx
@@ -83,11 +83,11 @@ describe('GroupSelection', () => {
const docLinks = {
links: {
- dashboard: {
+ visualize: {
guide: 'test',
},
},
- };
+ } as unknown as DocLinksStart;
beforeAll(() => {
Object.defineProperty(window, 'location', {
diff --git a/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx b/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx
index b730798124d5a..32a59fd56c1e5 100644
--- a/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx
+++ b/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx
@@ -95,7 +95,7 @@ function GroupSelection({
visTypesRegistry,
...props
}: GroupSelectionProps) {
- const visualizeGuideLink = props.docLinks.links.dashboard.guide;
+ const visualizeGuideLink = props.docLinks.links.visualize.guide;
const promotedVisGroups = useMemo(
() =>
orderBy(
diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx
index adf36745b991b..7f73addb93116 100644
--- a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx
+++ b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx
@@ -78,11 +78,11 @@ describe('NewVisModal', () => {
const uiSettings: any = { get: settingsGet };
const docLinks = {
links: {
- dashboard: {
+ visualize: {
guide: 'test',
},
},
- };
+ } as unknown as DocLinksStart;
const contentManagement = contentManagementMock.createStartContract();
@@ -108,7 +108,7 @@ describe('NewVisModal', () => {
addBasePath={addBasePath}
uiSettings={uiSettings}
application={{} as ApplicationStart}
- docLinks={docLinks as DocLinksStart}
+ docLinks={docLinks}
contentClient={contentManagement.client}
{...propsOverrides}
/>
diff --git a/test/functional/apps/discover/context_awareness/extensions/_get_cell_renderers.ts b/test/functional/apps/discover/context_awareness/extensions/_get_cell_renderers.ts
index cb66afc7ebc57..e18f6c5860dd2 100644
--- a/test/functional/apps/discover/context_awareness/extensions/_get_cell_renderers.ts
+++ b/test/functional/apps/discover/context_awareness/extensions/_get_cell_renderers.ts
@@ -105,8 +105,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const firstCell = await dataGrid.getCellElementExcludingControlColumns(0, 0);
const lastCell = await dataGrid.getCellElementExcludingControlColumns(2, 0);
- const firstServiceNameCell = await firstCell.findByTestSubject('serviceNameCell-java');
- const lastServiceNameCell = await lastCell.findByTestSubject('serviceNameCell-unknown');
+ const firstServiceNameCell = await firstCell.findByTestSubject(
+ 'dataTableCellActionsPopover_service.name'
+ );
+ const lastServiceNameCell = await lastCell.findByTestSubject(
+ 'dataTableCellActionsPopover_service.name'
+ );
expect(await firstServiceNameCell.getVisibleText()).to.be('product');
expect(await lastServiceNameCell.getVisibleText()).to.be('accounting');
});
@@ -130,7 +134,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await retry.try(async () => {
const firstCell = await dataGrid.getCellElementExcludingControlColumns(0, 0);
expect(await firstCell.getVisibleText()).to.be('product');
- await testSubjects.missingOrFail('*serviceNameCell*');
+ await testSubjects.missingOrFail('dataTableCellActionsPopover_service.name');
});
});
});
@@ -278,8 +282,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await retry.try(async () => {
firstCell = await dataGrid.getCellElementExcludingControlColumns(0, 1);
lastCell = await dataGrid.getCellElementExcludingControlColumns(2, 1);
- const firstServiceNameCell = await firstCell.findByTestSubject('serviceNameCell-java');
- const lastServiceNameCell = await lastCell.findByTestSubject('serviceNameCell-unknown');
+ const firstServiceNameCell = await firstCell.findByTestSubject(
+ 'dataTableCellActionsPopover_service.name'
+ );
+ const lastServiceNameCell = await lastCell.findByTestSubject(
+ 'dataTableCellActionsPopover_service.name'
+ );
expect(await firstServiceNameCell.getVisibleText()).to.be('product');
expect(await lastServiceNameCell.getVisibleText()).to.be('accounting');
});
@@ -309,7 +317,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await firstCell.getVisibleText()).to.be('product');
expect(await lastCell.getVisibleText()).to.be('accounting');
- await testSubjects.missingOrFail('*serviceNameCell*');
+ await testSubjects.missingOrFail('dataTableCellActionsPopover_service.name');
});
});
});
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 09d1f31eceb23..5028780367b9c 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -1188,6 +1188,8 @@
"@kbn/management-storybook-config/*": ["packages/kbn-management/storybook/config/*"],
"@kbn/management-test-plugin": ["test/plugin_functional/plugins/management_test_plugin"],
"@kbn/management-test-plugin/*": ["test/plugin_functional/plugins/management_test_plugin/*"],
+ "@kbn/manifest": ["packages/kbn-manifest"],
+ "@kbn/manifest/*": ["packages/kbn-manifest/*"],
"@kbn/mapbox-gl": ["packages/kbn-mapbox-gl"],
"@kbn/mapbox-gl/*": ["packages/kbn-mapbox-gl/*"],
"@kbn/maps-custom-raster-source-plugin": ["x-pack/examples/third_party_maps_source_example"],
diff --git a/x-pack/packages/security-solution/upselling/messages/index.tsx b/x-pack/packages/security-solution/upselling/messages/index.tsx
index 722a711995d01..4bda9477f13c0 100644
--- a/x-pack/packages/security-solution/upselling/messages/index.tsx
+++ b/x-pack/packages/security-solution/upselling/messages/index.tsx
@@ -46,3 +46,11 @@ export const ALERT_SUPPRESSION_RULE_DETAILS = i18n.translate(
'Alert suppression is configured but will not be applied due to insufficient licensing',
}
);
+
+export const UPGRADE_NOTES_MANAGEMENT_USER_FILTER = (requiredLicense: string) =>
+ i18n.translate('securitySolutionPackages.noteManagement.userFilter.upsell', {
+ defaultMessage: 'Upgrade to {requiredLicense} to make use of user filters',
+ values: {
+ requiredLicense,
+ },
+ });
diff --git a/x-pack/packages/security-solution/upselling/service/types.ts b/x-pack/packages/security-solution/upselling/service/types.ts
index 43019271a7e02..b053c9aedf857 100644
--- a/x-pack/packages/security-solution/upselling/service/types.ts
+++ b/x-pack/packages/security-solution/upselling/service/types.ts
@@ -27,4 +27,5 @@ export type UpsellingMessageId =
| 'investigation_guide_interactions'
| 'alert_assignments'
| 'alert_suppression_rule_form'
- | 'alert_suppression_rule_details';
+ | 'alert_suppression_rule_details'
+ | 'note_management_user_filter';
diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts
index 9b5c35c3b3ce2..c071c6feecbf8 100644
--- a/x-pack/plugins/fleet/common/constants/routes.ts
+++ b/x-pack/plugins/fleet/common/constants/routes.ts
@@ -81,6 +81,8 @@ export const AGENT_POLICY_API_ROUTES = {
DELETE_PATTERN: `${AGENT_POLICY_API_ROOT}/delete`,
FULL_INFO_PATTERN: `${AGENT_POLICY_API_ROOT}/{agentPolicyId}/full`,
FULL_INFO_DOWNLOAD_PATTERN: `${AGENT_POLICY_API_ROOT}/{agentPolicyId}/download`,
+ LIST_OUTPUTS_PATTERN: `${AGENT_POLICY_API_ROOT}/outputs`,
+ INFO_OUTPUTS_PATTERN: `${AGENT_POLICY_API_ROOT}/{agentPolicyId}/outputs`,
};
// Kubernetes Manifest API routes
diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts
index ff1fb4a5ff693..520a71e1bdc0a 100644
--- a/x-pack/plugins/fleet/common/services/routes.ts
+++ b/x-pack/plugins/fleet/common/services/routes.ts
@@ -197,6 +197,14 @@ export const agentPolicyRouteService = {
getResetAllPreconfiguredAgentPolicyPath: () => {
return PRECONFIGURATION_API_ROUTES.RESET_PATTERN;
},
+
+ getInfoOutputsPath: (agentPolicyId: string) => {
+ return AGENT_POLICY_API_ROUTES.INFO_OUTPUTS_PATTERN.replace('{agentPolicyId}', agentPolicyId);
+ },
+
+ getListOutputsPath: () => {
+ return AGENT_POLICY_API_ROUTES.LIST_OUTPUTS_PATTERN;
+ },
};
export const dataStreamRouteService = {
diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts
index ebb1aa3afe7f1..ba1a0b182af72 100644
--- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts
+++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts
@@ -262,3 +262,24 @@ export interface AgentlessApiResponse {
id: string;
region_id: string;
}
+
+// Definitions for agent policy outputs endpoints
+export interface MinimalOutput {
+ name?: string;
+ id?: string;
+}
+export interface IntegrationsOutput extends MinimalOutput {
+ pkgName?: string;
+ integrationPolicyName?: string;
+}
+
+export interface OutputsForAgentPolicy {
+ agentPolicyId?: string;
+ monitoring: {
+ output: MinimalOutput;
+ };
+ data: {
+ output: MinimalOutput;
+ integrations?: IntegrationsOutput[];
+ };
+}
diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent_policy.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent_policy.ts
index 42f44f7c7271e..7432d1d00e61e 100644
--- a/x-pack/plugins/fleet/common/types/rest_spec/agent_policy.ts
+++ b/x-pack/plugins/fleet/common/types/rest_spec/agent_policy.ts
@@ -5,7 +5,12 @@
* 2.0.
*/
-import type { AgentPolicy, NewAgentPolicy, FullAgentPolicy } from '../models';
+import type {
+ AgentPolicy,
+ NewAgentPolicy,
+ FullAgentPolicy,
+ OutputsForAgentPolicy,
+} from '../models';
import type { ListResult, ListWithKuery, BulkGetResult } from './common';
@@ -93,3 +98,16 @@ export type FetchAllAgentPoliciesOptions = Pick<
export type FetchAllAgentPolicyIdsOptions = Pick & {
spaceId?: string;
};
+
+export interface GetAgentPolicyOutputsResponse {
+ item: OutputsForAgentPolicy;
+}
+export interface GetListAgentPolicyOutputsResponse {
+ items: OutputsForAgentPolicy[];
+}
+
+export interface GetListAgentPolicyOutputsRequest {
+ body: {
+ ids?: string[];
+ };
+}
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx
index 1497b1bb0589e..6b0a7c512d197 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx
@@ -53,7 +53,7 @@ import { UninstallCommandFlyout } from '../../../../../../components';
import type { ValidationResults } from '../agent_policy_validation';
import { ExperimentalFeaturesService } from '../../../../services';
-
+import { useAgentPolicyFormContext } from '../agent_policy_form';
import { policyHasEndpointSecurity as hasElasticDefend } from '../../../../../../../common/services';
import {
@@ -127,6 +127,8 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent =
const isManagedorAgentlessPolicy =
agentPolicy.is_managed === true || agentPolicy?.supports_agentless === true;
+ const agentPolicyFormContect = useAgentPolicyFormContext();
+
const AgentTamperProtectionSectionContent = useMemo(
() => (
=
/>
}
>
-
- {
+ if (newValue.length === 0) {
+ return;
}
- onChange={(newValue) => {
- if (newValue.length === 0) {
- return;
- }
- updateAgentPolicy({
- space_ids: newValue,
- });
- }}
- />
-
+ updateAgentPolicy({
+ space_ids: newValue,
+ });
+ }}
+ />
) : null}
{
+ beforeEach(() => {
+ jest.mocked(useAgentPoliciesSpaces).mockReturnValue({
+ data: {
+ items: [
+ {
+ name: 'Default',
+ id: 'default',
+ },
+ {
+ name: 'Test',
+ id: 'test',
+ },
+ ],
+ },
+ } as any);
+ });
+ function render() {
+ const renderer = createFleetTestRendererMock();
+ const onChange = jest.fn();
+ const setInvalidSpaceError = jest.fn();
+ const result = renderer.render(
+
+ );
+
+ return {
+ result,
+ onChange,
+ setInvalidSpaceError,
+ };
+ }
+
+ it('should render invalid space errors', () => {
+ const { result, onChange, setInvalidSpaceError } = render();
+ const inputEl = result.getByTestId('comboBoxSearchInput');
+ fireEvent.change(inputEl, {
+ target: { value: 'invalidSpace' },
+ });
+ fireEvent.keyDown(inputEl, { key: 'Enter', code: 'Enter' });
+ expect(result.container).toHaveTextContent('invalidSpace is not a valid space.');
+ expect(onChange).not.toBeCalled();
+ expect(setInvalidSpaceError).toBeCalledWith(true);
+ });
+
+ it('should clear invalid space errors', () => {
+ const { result, setInvalidSpaceError } = render();
+ const inputEl = result.getByTestId('comboBoxSearchInput');
+ fireEvent.change(inputEl, {
+ target: { value: 'invalidSpace' },
+ });
+ fireEvent.keyDown(inputEl, { key: 'Enter', code: 'Enter' });
+ expect(result.container).toHaveTextContent('invalidSpace is not a valid space.');
+ fireEvent.change(inputEl, {
+ target: { value: '' },
+ });
+ fireEvent.keyDown(inputEl, { key: 'Enter', code: 'Enter' });
+ expect(result.container).not.toHaveTextContent('invalidSpace is not a valid space.');
+ expect(setInvalidSpaceError).toBeCalledWith(false);
+ });
+
+ it('should accept valid space', () => {
+ const { result, onChange, setInvalidSpaceError } = render();
+ const inputEl = result.getByTestId('comboBoxSearchInput');
+ fireEvent.change(inputEl, {
+ target: { value: 'test' },
+ });
+ fireEvent.keyDown(inputEl, { key: 'Enter', code: 'Enter' });
+ expect(result.container).not.toHaveTextContent('test is not a valid space.');
+ expect(onChange).toBeCalledWith(['test']);
+ expect(setInvalidSpaceError).not.toBeCalledWith(true);
+ });
+});
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/space_selector.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/space_selector.tsx
index 0532c5306d50f..53c7ed1d8226d 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/space_selector.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/space_selector.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { type EuiComboBoxOptionOption, EuiHealth } from '@elastic/eui';
+import { type EuiComboBoxOptionOption, EuiHealth, EuiFormRow } from '@elastic/eui';
import { EuiComboBox } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
@@ -16,11 +16,19 @@ export interface SpaceSelectorProps {
value: string[];
onChange: (newVal: string[]) => void;
isDisabled?: boolean;
+ setInvalidSpaceError?: (hasError: boolean) => void;
}
-export const SpaceSelector: React.FC = ({ value, onChange, isDisabled }) => {
+export const SpaceSelector: React.FC = ({
+ setInvalidSpaceError,
+ value,
+ onChange,
+ isDisabled,
+}) => {
const res = useAgentPoliciesSpaces();
+ const [error, setError] = React.useState();
+
const renderOption = React.useCallback(
(option: any, searchValue: string, contentClassName: string) => (
@@ -57,20 +65,41 @@ export const SpaceSelector: React.FC = ({ value, onChange, i
}, [options, value, res.isInitialLoading]);
return (
- {
- onChange(newOptions.map(({ key }) => key as string));
- }}
- />
+ key="space"
+ error={error}
+ isDisabled={isDisabled}
+ isInvalid={Boolean(error)}
+ >
+ {
+ const newError =
+ searchValue.length === 0 || hasMatchingOptions
+ ? undefined
+ : i18n.translate('xpack.fleet.agentPolicies.spaceSelectorInvalid', {
+ defaultMessage: '{space} is not a valid space.',
+ values: { space: searchValue },
+ });
+ setError(newError);
+ if (setInvalidSpaceError) {
+ setInvalidSpaceError(!!newError);
+ }
+ }}
+ onChange={(newOptions) => {
+ onChange(newOptions.map(({ key }) => key as string));
+ }}
+ />
+
);
};
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx
index b437d61f64c58..8e97afcaa4d66 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx
@@ -45,12 +45,14 @@ interface Props {
isEditing?: boolean;
// form error state is passed up to the form
updateAdvancedSettingsHasErrors: (hasErrors: boolean) => void;
+ setInvalidSpaceError: (hasErrors: boolean) => void;
}
const AgentPolicyFormContext = React.createContext<
| {
agentPolicy: Partial & { [key: string]: any };
updateAgentPolicy: (u: Partial) => void;
updateAdvancedSettingsHasErrors: (hasErrors: boolean) => void;
+ setInvalidSpaceError: (hasErrors: boolean) => void;
}
| undefined
>(undefined);
@@ -67,6 +69,7 @@ export const AgentPolicyForm: React.FunctionComponent = ({
validation,
isEditing = false,
updateAdvancedSettingsHasErrors,
+ setInvalidSpaceError,
}) => {
const authz = useAuthz();
const isDisabled = !authz.fleet.allAgentPolicies;
@@ -97,7 +100,12 @@ export const AgentPolicyForm: React.FunctionComponent = ({
return (
{!isEditing ? (
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx
index 6e4f1e06b45a0..91cd710db4343 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx
@@ -90,6 +90,7 @@ export const SettingsView = memo<{ agentPolicy: AgentPolicy }>(
allowedNamespacePrefixes: spaceSettings?.allowedNamespacePrefixes,
});
const [hasAdvancedSettingsErrors, setHasAdvancedSettingsErrors] = useState(false);
+ const [hasInvalidSpaceError, setInvalidSpaceError] = useState(false);
const updateAgentPolicy = (updatedFields: Partial) => {
setAgentPolicy({
@@ -183,6 +184,7 @@ export const SettingsView = memo<{ agentPolicy: AgentPolicy }>(
validation={validation}
isEditing={true}
updateAdvancedSettingsHasErrors={setHasAdvancedSettingsErrors}
+ setInvalidSpaceError={setInvalidSpaceError}
/>
{hasChanges ? (
@@ -219,7 +221,8 @@ export const SettingsView = memo<{ agentPolicy: AgentPolicy }>(
isDisabled={
isLoading ||
Object.keys(validation).length > 0 ||
- hasAdvancedSettingsErrors
+ hasAdvancedSettingsErrors ||
+ hasInvalidSpaceError
}
btnProps={{
color: 'text',
@@ -242,7 +245,8 @@ export const SettingsView = memo<{ agentPolicy: AgentPolicy }>(
!hasAllAgentPoliciesPrivileges ||
isLoading ||
Object.keys(validation).length > 0 ||
- hasAdvancedSettingsErrors
+ hasAdvancedSettingsErrors ||
+ hasInvalidSpaceError
}
data-test-subj="agentPolicyDetailsSaveButton"
iconType="save"
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx
index f147f7e112ea1..a5538e7e0fa30 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx
@@ -61,6 +61,7 @@ export const CreateAgentPolicyFlyout: React.FunctionComponent = ({
allowedNamespacePrefixes: spaceSettings?.allowedNamespacePrefixes,
});
const [hasAdvancedSettingsErrors, setHasAdvancedSettingsErrors] = useState(false);
+ const [hasInvalidSpaceError, setInvalidSpaceError] = useState(false);
const updateAgentPolicy = (updatedFields: Partial) => {
setAgentPolicy({
@@ -104,6 +105,7 @@ export const CreateAgentPolicyFlyout: React.FunctionComponent = ({
updateSysMonitoring={(newValue) => setWithSysMonitoring(newValue)}
validation={validation}
updateAdvancedSettingsHasErrors={setHasAdvancedSettingsErrors}
+ setInvalidSpaceError={setInvalidSpaceError}
/>
);
@@ -130,7 +132,10 @@ export const CreateAgentPolicyFlyout: React.FunctionComponent = ({
0 || hasAdvancedSettingsErrors
+ isLoading ||
+ Object.keys(validation).length > 0 ||
+ hasAdvancedSettingsErrors ||
+ hasInvalidSpaceError
}
description={i18n.translate(
'xpack.fleet.createAgentPolicy.devtoolsRequestDescription',
@@ -150,7 +155,8 @@ export const CreateAgentPolicyFlyout: React.FunctionComponent = ({
!hasFleetAllAgentPoliciesPrivileges ||
isLoading ||
Object.keys(validation).length > 0 ||
- hasAdvancedSettingsErrors
+ hasAdvancedSettingsErrors ||
+ hasInvalidSpaceError
}
onClick={async () => {
setIsLoading(true);
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx
index 35fd048cc13cd..2c4113c003841 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx
@@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react';
import type { Agent, AgentPolicy } from '../../../../../types';
-import { useAgentVersion } from '../../../../../hooks';
+import { useAgentVersion, useGetInfoOutputsForPolicy } from '../../../../../hooks';
import { ExperimentalFeaturesService, isAgentUpgradeable } from '../../../../../services';
import { AgentPolicySummaryLine } from '../../../../../components';
import { AgentHealth } from '../../../components';
@@ -30,6 +30,7 @@ import { Tags } from '../../../components/tags';
import { formatAgentCPU, formatAgentMemory } from '../../../services/agent_metrics';
import { AgentDashboardLink } from '../agent_dashboard_link';
import { AgentUpgradeStatus } from '../../../agent_list_page/components/agent_upgrade_status';
+import { AgentPolicyOutputsSummary } from '../../../agent_list_page/components/agent_policy_outputs_summary';
// Allows child text to be truncated
const FlexItemWithMinWidth = styled(EuiFlexItem)`
@@ -43,10 +44,17 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{
const latestAgentVersion = useAgentVersion();
const { displayAgentMetrics } = ExperimentalFeaturesService.get();
+ const outputRes = useGetInfoOutputsForPolicy(agentPolicy?.id);
+ const outputs = outputRes?.data?.item;
+
return (
-
+
{displayAgentMetrics && (
@@ -206,6 +214,22 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{
? agent.local_metadata.host.id
: '-',
},
+ {
+ title: i18n.translate('xpack.fleet.agentDetails.outputForMonitoringLabel', {
+ defaultMessage: 'Output for integrations',
+ }),
+ description: outputs ? : '-',
+ },
+ {
+ title: i18n.translate('xpack.fleet.agentDetails.outputForMonitoringLabel', {
+ defaultMessage: 'Output for monitoring',
+ }),
+ description: outputs ? (
+
+ ) : (
+ '-'
+ ),
+ },
{
title: i18n.translate('xpack.fleet.agentDetails.logLevel', {
defaultMessage: 'Logging level',
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx
index 4c6c83dd7145e..d70ed67247207 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx
@@ -4,7 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-import React from 'react';
+import React, { useCallback, useMemo } from 'react';
+import type { EuiBasicTableColumn } from '@elastic/eui';
import { type CriteriaWithPagination } from '@elastic/eui';
import {
EuiBasicTable,
@@ -23,20 +24,31 @@ import { isAgentUpgradeable, ExperimentalFeaturesService } from '../../../../ser
import { AgentHealth } from '../../components';
import type { Pagination } from '../../../../hooks';
-import { useAgentVersion } from '../../../../hooks';
+import { useAgentVersion, useGetListOutputsForPolicies } from '../../../../hooks';
import { useLink, useAuthz } from '../../../../hooks';
import { AgentPolicySummaryLine } from '../../../../components';
import { Tags } from '../../components/tags';
-import type { AgentMetrics } from '../../../../../../../common/types';
+import type { AgentMetrics, OutputsForAgentPolicy } from '../../../../../../../common/types';
import { formatAgentCPU, formatAgentMemory } from '../../services/agent_metrics';
+import { AgentPolicyOutputsSummary } from './agent_policy_outputs_summary';
+
import { AgentUpgradeStatus } from './agent_upgrade_status';
import { EmptyPrompt } from './empty_prompt';
-const VERSION_FIELD = 'local_metadata.elastic.agent.version';
-const HOSTNAME_FIELD = 'local_metadata.host.hostname';
+const AGENTS_TABLE_FIELDS = {
+ ACTIVE: 'active',
+ HOSTNAME: 'local_metadata.host.hostname',
+ POLICY: 'policy_id',
+ METRICS: 'metrics',
+ VERSION: 'local_metadata.elastic.agent.version',
+ LAST_CHECKIN: 'last_checkin',
+ OUTPUT_INTEGRATION: 'output_integrations',
+ OUTPUT_MONITORING: 'output_monitoring',
+};
+
function safeMetadata(val: any) {
if (typeof val !== 'string') {
return '-';
@@ -96,14 +108,33 @@ export const AgentListTable: React.FC = (props: Props) => {
const { getHref } = useLink();
const latestAgentVersion = useAgentVersion();
- const isAgentSelectable = (agent: Agent) => {
- if (!agent.active) return false;
- if (!agent.policy_id) return true;
+ const isAgentSelectable = useCallback(
+ (agent: Agent) => {
+ if (!agent.active) return false;
+ if (!agent.policy_id) return true;
- const agentPolicy = agentPoliciesIndexedById[agent.policy_id];
- const isHosted = agentPolicy?.is_managed === true;
- return !isHosted;
- };
+ const agentPolicy = agentPoliciesIndexedById[agent.policy_id];
+ const isHosted = agentPolicy?.is_managed === true;
+ return !isHosted;
+ },
+ [agentPoliciesIndexedById]
+ );
+
+ const agentsShown = useMemo(() => {
+ return totalAgents
+ ? showUpgradeable
+ ? agents.filter((agent) => isAgentSelectable(agent) && isAgentUpgradeable(agent))
+ : agents
+ : [];
+ }, [agents, isAgentSelectable, showUpgradeable, totalAgents]);
+
+ // get the policyIds of the agents shown on the page
+ const policyIds = useMemo(() => {
+ return agentsShown.map((agent) => agent?.policy_id ?? '');
+ }, [agentsShown]);
+ const allOutputs = useGetListOutputsForPolicies({
+ ids: policyIds,
+ });
const noItemsMessage =
isLoading && isCurrentRequestIncremented ? (
@@ -140,9 +171,9 @@ export const AgentListTable: React.FC = (props: Props) => {
},
};
- const columns = [
+ const columns: Array> = [
{
- field: 'active',
+ field: AGENTS_TABLE_FIELDS.ACTIVE,
sortable: false,
width: '85px',
name: i18n.translate('xpack.fleet.agentList.statusColumnTitle', {
@@ -151,7 +182,7 @@ export const AgentListTable: React.FC = (props: Props) => {
render: (active: boolean, agent: any) => ,
},
{
- field: HOSTNAME_FIELD,
+ field: AGENTS_TABLE_FIELDS.HOSTNAME,
sortable: true,
name: i18n.translate('xpack.fleet.agentList.hostColumnTitle', {
defaultMessage: 'Host',
@@ -171,7 +202,7 @@ export const AgentListTable: React.FC = (props: Props) => {
),
},
{
- field: 'policy_id',
+ field: AGENTS_TABLE_FIELDS.POLICY,
sortable: true,
truncateText: true,
name: i18n.translate('xpack.fleet.agentList.policyColumnTitle', {
@@ -208,7 +239,7 @@ export const AgentListTable: React.FC = (props: Props) => {
...(displayAgentMetrics
? [
{
- field: 'metrics',
+ field: AGENTS_TABLE_FIELDS.METRICS,
sortable: false,
name: (
= (props: Props) => {
),
},
{
- field: 'metrics',
+ field: AGENTS_TABLE_FIELDS.METRICS,
sortable: false,
name: (
= (props: Props) => {
},
]
: []),
-
{
- field: 'last_checkin',
+ field: AGENTS_TABLE_FIELDS.LAST_CHECKIN,
sortable: true,
name: i18n.translate('xpack.fleet.agentList.lastCheckinTitle', {
defaultMessage: 'Last activity',
}),
+ width: '100px',
+ render: (lastCheckin: string) =>
+ lastCheckin ? : undefined,
+ },
+ {
+ field: AGENTS_TABLE_FIELDS.OUTPUT_INTEGRATION,
+ sortable: true,
+ truncateText: true,
+ name: i18n.translate('xpack.fleet.agentList.integrationsOutputTitle', {
+ defaultMessage: 'Output for integrations',
+ }),
+ width: '180px',
+ render: (outputs: OutputsForAgentPolicy[], agent: Agent) => {
+ if (!agent?.policy_id) return null;
+
+ const outputsForPolicy = allOutputs?.data?.items.find(
+ (item) => item.agentPolicyId === agent?.policy_id
+ );
+ return ;
+ },
+ },
+ {
+ field: AGENTS_TABLE_FIELDS.OUTPUT_MONITORING,
+ sortable: true,
+ truncateText: true,
+ name: i18n.translate('xpack.fleet.agentList.monitoringOutputTitle', {
+ defaultMessage: 'Output for monitoring',
+ }),
width: '180px',
- render: (lastCheckin: string, agent: any) =>
- lastCheckin ? : null,
+ render: (outputs: OutputsForAgentPolicy[], agent: Agent) => {
+ if (!agent?.policy_id) return null;
+
+ const outputsForPolicy = allOutputs?.data?.items.find(
+ (item) => item.agentPolicyId === agent?.policy_id
+ );
+ return ;
+ },
},
{
- field: VERSION_FIELD,
+ field: AGENTS_TABLE_FIELDS.VERSION,
sortable: true,
width: '220px',
name: i18n.translate('xpack.fleet.agentList.versionTitle', {
@@ -322,13 +386,7 @@ export const AgentListTable: React.FC = (props: Props) => {
data-test-subj="fleetAgentListTable"
loading={isLoading}
noItemsMessage={noItemsMessage}
- items={
- totalAgents
- ? showUpgradeable
- ? agents.filter((agent) => isAgentSelectable(agent) && isAgentUpgradeable(agent))
- : agents
- : []
- }
+ items={agentsShown}
itemId="id"
columns={columns}
pagination={{
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_policy_outputs_summary.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_policy_outputs_summary.test.tsx
new file mode 100644
index 0000000000000..255b2efb94026
--- /dev/null
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_policy_outputs_summary.test.tsx
@@ -0,0 +1,99 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { act, fireEvent } from '@testing-library/react';
+import React from 'react';
+
+import { createFleetTestRendererMock } from '../../../../../../mock';
+import type { TestRenderer } from '../../../../../../mock';
+
+import type { OutputsForAgentPolicy } from '../../../../../../../common/types';
+
+import { AgentPolicyOutputsSummary } from './agent_policy_outputs_summary';
+
+describe('MultipleAgentPolicySummaryLine', () => {
+ let testRenderer: TestRenderer;
+ const outputsForPolicy: OutputsForAgentPolicy = {
+ agentPolicyId: 'policy-1',
+ monitoring: {
+ output: {
+ id: 'elasticsearch1',
+ name: 'Elasticsearch1',
+ },
+ },
+ data: {
+ output: {
+ id: 'elasticsearch1',
+ name: 'Elasticsearch1',
+ },
+ },
+ };
+ const data = {
+ data: {
+ output: {
+ id: 'elasticsearch1',
+ name: 'Elasticsearch1',
+ },
+ integrations: [
+ {
+ id: 'remote_es1',
+ name: 'Remote ES',
+ pkgName: 'ngnix',
+ integrationPolicyName: 'Nginx-1',
+ },
+
+ {
+ id: 'logstash',
+ name: 'Logstash-1',
+ pkgName: 'apache',
+ integrationPolicyName: 'Apache-1',
+ },
+ ],
+ },
+ };
+
+ const render = (outputs?: OutputsForAgentPolicy, isMonitoring?: boolean) =>
+ testRenderer.render(
+
+ );
+
+ beforeEach(() => {
+ testRenderer = createFleetTestRendererMock();
+ });
+
+ test('it should render the name associated with the default output when the agent policy does not have custom outputs', async () => {
+ const results = render(outputsForPolicy);
+ expect(results.container.textContent).toBe('Elasticsearch1');
+ expect(results.queryByTestId('outputNameLink')).toBeInTheDocument();
+ expect(results.queryByTestId('outputsIntegrationsNumberBadge')).not.toBeInTheDocument();
+ });
+
+ test('it should render the first output name and the badge when there are multiple outputs associated with integrations', async () => {
+ const results = render({ ...outputsForPolicy, ...data });
+
+ expect(results.queryByTestId('outputNameLink')).toBeInTheDocument();
+ expect(results.queryByTestId('outputsIntegrationsNumberBadge')).toBeInTheDocument();
+
+ await act(async () => {
+ fireEvent.click(results.getByTestId('outputsIntegrationsNumberBadge'));
+ });
+ expect(results.queryByTestId('outputPopover')).toBeInTheDocument();
+ expect(results.queryByTestId('output-integration-0')?.textContent).toContain(
+ 'Nginx-1: Remote ES'
+ );
+ expect(results.queryByTestId('output-integration-1')?.textContent).toContain(
+ 'Apache-1: Logstash-1'
+ );
+ });
+
+ test('it should not render the badge when monitoring is true', async () => {
+ const results = render({ ...outputsForPolicy, ...data }, true);
+
+ expect(results.queryByTestId('outputNameLink')).toBeInTheDocument();
+ expect(results.queryByTestId('outputsIntegrationsNumberBadge')).not.toBeInTheDocument();
+ });
+});
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_policy_outputs_summary.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_policy_outputs_summary.tsx
new file mode 100644
index 0000000000000..c0b0e5fbfbccc
--- /dev/null
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_policy_outputs_summary.tsx
@@ -0,0 +1,115 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import styled from 'styled-components';
+import React, { useMemo, useState } from 'react';
+
+import type { EuiListGroupItemProps } from '@elastic/eui';
+import {
+ EuiBadge,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLink,
+ EuiListGroup,
+ EuiPopover,
+ EuiPopoverTitle,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+import { useLink } from '../../../../hooks';
+import type { OutputsForAgentPolicy } from '../../../../../../../common/types';
+
+const TruncatedEuiLink = styled(EuiLink)`
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 120px;
+`;
+
+export const AgentPolicyOutputsSummary: React.FC<{
+ outputs?: OutputsForAgentPolicy;
+ isMonitoring?: boolean;
+}> = ({ outputs, isMonitoring }) => {
+ const { getHref } = useLink();
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const closePopover = () => setIsPopoverOpen(false);
+
+ const monitoring = outputs?.monitoring;
+ const data = outputs?.data;
+
+ const listItems: EuiListGroupItemProps[] = useMemo(() => {
+ if (!data?.integrations) return [];
+
+ return (data?.integrations || []).map((integration, index) => {
+ return {
+ 'data-test-subj': `output-integration-${index}`,
+ label: `${integration.integrationPolicyName}: ${integration.name}`,
+ href: getHref('settings_edit_outputs', { outputId: integration?.id ?? '' }),
+ iconType: 'dot',
+ };
+ });
+ }, [getHref, data?.integrations]);
+
+ return (
+
+ {isMonitoring ? (
+
+
+ {monitoring?.output.name}
+
+
+ ) : (
+
+
+ {data?.output.name}
+
+
+ )}
+
+ {data?.integrations && data?.integrations.length >= 1 && !isMonitoring && (
+
+ setIsPopoverOpen(!isPopoverOpen)}
+ onClickAriaLabel="Open output integrations popover"
+ >
+ +{data?.integrations.length}
+
+
+
+ {i18n.translate('xpack.fleet.AgentPolicyOutputsSummary.popover.title', {
+ defaultMessage: 'Output for integrations',
+ })}
+
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/x-pack/plugins/fleet/public/hooks/use_request/agent_policy.ts b/x-pack/plugins/fleet/public/hooks/use_request/agent_policy.ts
index 9e4fb2344fc29..e130eae49c6eb 100644
--- a/x-pack/plugins/fleet/public/hooks/use_request/agent_policy.ts
+++ b/x-pack/plugins/fleet/public/hooks/use_request/agent_policy.ts
@@ -23,6 +23,9 @@ import type {
DeleteAgentPolicyRequest,
DeleteAgentPolicyResponse,
BulkGetAgentPoliciesResponse,
+ GetAgentPolicyOutputsResponse,
+ GetListAgentPolicyOutputsResponse,
+ GetListAgentPolicyOutputsRequest,
} from '../../types';
import { useRequest, sendRequest, useConditionalRequest, sendRequestForRq } from './use_request';
@@ -201,3 +204,21 @@ export const sendResetAllPreconfiguredAgentPolicies = () => {
version: API_VERSIONS.internal.v1,
});
};
+
+export const useGetListOutputsForPolicies = (body?: GetListAgentPolicyOutputsRequest['body']) => {
+ return useRequest({
+ path: agentPolicyRouteService.getListOutputsPath(),
+ method: 'post',
+ body: JSON.stringify(body),
+ version: API_VERSIONS.public.v1,
+ });
+};
+
+export const useGetInfoOutputsForPolicy = (agentPolicyId: string | undefined) => {
+ return useConditionalRequest({
+ path: agentPolicyId ? agentPolicyRouteService.getInfoOutputsPath(agentPolicyId) : undefined,
+ method: 'get',
+ shouldSendRequest: !!agentPolicyId,
+ version: API_VERSIONS.public.v1,
+ } as SendConditionalRequestConfig);
+};
diff --git a/x-pack/plugins/fleet/public/types/index.ts b/x-pack/plugins/fleet/public/types/index.ts
index 099df2ce5a34f..0f0adaba20b5d 100644
--- a/x-pack/plugins/fleet/public/types/index.ts
+++ b/x-pack/plugins/fleet/public/types/index.ts
@@ -147,6 +147,9 @@ export type {
GetEnrollmentSettingsRequest,
GetEnrollmentSettingsResponse,
GetSpaceSettingsResponse,
+ GetAgentPolicyOutputsResponse,
+ GetListAgentPolicyOutputsRequest,
+ GetListAgentPolicyOutputsResponse,
} from '../../common/types';
export {
entries,
diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts
index 2c93880c10609..49b5590a2e761 100644
--- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts
+++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts
@@ -33,6 +33,8 @@ import type {
BulkGetAgentPoliciesRequestSchema,
AgentPolicy,
FleetRequestHandlerContext,
+ GetAgentPolicyOutputsRequestSchema,
+ GetListAgentPolicyOutputsRequestSchema,
} from '../../types';
import type {
@@ -47,6 +49,8 @@ import type {
GetFullAgentConfigMapResponse,
GetFullAgentManifestResponse,
BulkGetAgentPoliciesResponse,
+ GetAgentPolicyOutputsResponse,
+ GetListAgentPolicyOutputsResponse,
} from '../../../common/types';
import {
defaultFleetErrorHandler,
@@ -678,3 +682,64 @@ export const downloadK8sManifest: FleetRequestHandler<
return defaultFleetErrorHandler({ error, response });
}
};
+
+export const GetAgentPolicyOutputsHandler: FleetRequestHandler<
+ TypeOf,
+ undefined
+> = async (context, request, response) => {
+ try {
+ const coreContext = await context.core;
+ const soClient = coreContext.savedObjects.client;
+ const agentPolicy = await agentPolicyService.get(soClient, request.params.agentPolicyId);
+
+ if (!agentPolicy) {
+ return response.customError({
+ statusCode: 404,
+ body: { message: 'Agent policy not found' },
+ });
+ }
+ const outputs = await agentPolicyService.getAllOutputsForPolicy(soClient, agentPolicy);
+
+ const body: GetAgentPolicyOutputsResponse = {
+ item: outputs,
+ };
+ return response.ok({
+ body,
+ });
+ } catch (error) {
+ return defaultFleetErrorHandler({ error, response });
+ }
+};
+
+export const GetListAgentPolicyOutputsHandler: FleetRequestHandler<
+ undefined,
+ undefined,
+ TypeOf
+> = async (context, request, response) => {
+ try {
+ const coreContext = await context.core;
+ const soClient = coreContext.savedObjects.client;
+ const { ids } = request.body;
+
+ if (!ids) {
+ return response.ok({
+ body: { items: [] },
+ });
+ }
+ const agentPolicies = await agentPolicyService.getByIDs(soClient, ids, {
+ withPackagePolicies: true,
+ });
+
+ const outputsList = await agentPolicyService.listAllOutputsForPolicies(soClient, agentPolicies);
+
+ const body: GetListAgentPolicyOutputsResponse = {
+ items: outputsList,
+ };
+
+ return response.ok({
+ body,
+ });
+ } catch (error) {
+ return defaultFleetErrorHandler({ error, response });
+ }
+};
diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/index.ts b/x-pack/plugins/fleet/server/routes/agent_policy/index.ts
index 2ed7079deceec..9311f0ae2acca 100644
--- a/x-pack/plugins/fleet/server/routes/agent_policy/index.ts
+++ b/x-pack/plugins/fleet/server/routes/agent_policy/index.ts
@@ -28,6 +28,10 @@ import {
GetFullAgentPolicyResponseSchema,
DownloadFullAgentPolicyResponseSchema,
GetK8sManifestResponseScheme,
+ GetAgentPolicyOutputsRequestSchema,
+ GetAgentPolicyOutputsResponseSchema,
+ GetListAgentPolicyOutputsResponseSchema,
+ GetListAgentPolicyOutputsRequestSchema,
} from '../../types';
import { K8S_API_ROUTES } from '../../../common/constants';
@@ -47,6 +51,8 @@ import {
downloadK8sManifest,
getK8sManifest,
bulkGetAgentPoliciesHandler,
+ GetAgentPolicyOutputsHandler,
+ GetListAgentPolicyOutputsHandler,
} from './handlers';
export const registerRoutes = (router: FleetAuthzRouter) => {
@@ -390,4 +396,62 @@ export const registerRoutes = (router: FleetAuthzRouter) => {
},
downloadK8sManifest
);
+
+ router.versioned
+ .post({
+ path: AGENT_POLICY_API_ROUTES.LIST_OUTPUTS_PATTERN,
+ fleetAuthz: (authz) => {
+ return authz.fleet.readAgentPolicies && authz.fleet.readSettings;
+ },
+ description: `Get list of outputs associated with agent policies`,
+ options: {
+ tags: ['oas-tag:Elastic Agent policies'],
+ },
+ })
+ .addVersion(
+ {
+ version: API_VERSIONS.public.v1,
+ validate: {
+ request: GetListAgentPolicyOutputsRequestSchema,
+ response: {
+ 200: {
+ body: () => GetListAgentPolicyOutputsResponseSchema,
+ },
+ 400: {
+ body: genericErrorResponse,
+ },
+ },
+ },
+ },
+ GetListAgentPolicyOutputsHandler
+ );
+
+ router.versioned
+ .get({
+ path: AGENT_POLICY_API_ROUTES.INFO_OUTPUTS_PATTERN,
+ fleetAuthz: (authz) => {
+ return authz.fleet.readAgentPolicies && authz.fleet.readSettings;
+ },
+ description: `Get list of outputs associated with agent policy by policy id`,
+ options: {
+ tags: ['oas-tag:Elastic Agent policies'],
+ },
+ })
+ .addVersion(
+ {
+ version: API_VERSIONS.public.v1,
+ validate: {
+ request: GetAgentPolicyOutputsRequestSchema,
+ response: {
+ 200: {
+ body: () => GetAgentPolicyOutputsResponseSchema,
+ },
+ 400: {
+ body: genericErrorResponse,
+ },
+ },
+ },
+ },
+ GetAgentPolicyOutputsHandler
+ );
};
diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts
index fa5522d50802b..609c560906de2 100644
--- a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts
+++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts
@@ -109,7 +109,7 @@ jest.mock('../output', () => {
getDefaultDataOutputId: async () => 'test-id',
getDefaultMonitoringOutputId: async () => 'test-id',
get: (soClient: any, id: string): Output => OUTPUTS[id] || OUTPUTS['test-id'],
- bulkGet: async (soClient: any, ids: string[]): Promise
);
diff --git a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/helpers/log_rate_analysis_query.test.ts b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/helpers/log_rate_analysis_query.test.ts
index 05a8c3eb67a35..996030cf890cf 100644
--- a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/helpers/log_rate_analysis_query.test.ts
+++ b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/helpers/log_rate_analysis_query.test.ts
@@ -6,6 +6,7 @@
*/
import { COMPARATORS } from '@kbn/alerting-comparators';
+import { ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils';
import { Aggregators } from '../../../../../../common/custom_threshold_rule/types';
import { CustomThresholdRuleTypeParams } from '../../../types';
import { getLogRateAnalysisEQQuery } from './log_rate_analysis_query';
@@ -50,74 +51,83 @@ describe('buildEsQuery', () => {
};
const testData: Array<{
title: string;
- params: CustomThresholdRuleTypeParams;
alert: any;
}> = [
{
title: 'rule with optional filer, count filter and group by',
- params: mockedParams,
alert: {
fields: {
'kibana.alert.group': [mockedAlertWithMultipleGroups.fields['kibana.alert.group'][0]],
+ [ALERT_RULE_PARAMETERS]: mockedParams,
},
},
},
{
title: 'rule with optional filer, count filter and multiple group by',
- params: mockedParams,
- alert: mockedAlertWithMultipleGroups,
+ alert: {
+ fields: {
+ ...mockedAlertWithMultipleGroups.fields,
+ [ALERT_RULE_PARAMETERS]: mockedParams,
+ },
+ },
},
{
title: 'rule with optional filer, count filter and WITHOUT group by',
- params: mockedParams,
- alert: {},
+ alert: {
+ fields: {
+ [ALERT_RULE_PARAMETERS]: mockedParams,
+ },
+ },
},
{
title: 'rule without filter and with group by',
- params: {
- groupBy: ['host.hostname'],
- searchConfiguration: {
- index,
- query: { query: '', language: 'kuery' },
- },
- criteria: [
- {
- metrics: [{ name: 'A', aggType: Aggregators.COUNT }],
- timeSize: 1,
- timeUnit: 'm',
- threshold: [90],
- comparator: COMPARATORS.GREATER_THAN,
- },
- ],
- },
alert: {
fields: {
'kibana.alert.group': [mockedAlertWithMultipleGroups.fields['kibana.alert.group'][0]],
+ [ALERT_RULE_PARAMETERS]: {
+ groupBy: ['host.hostname'],
+ searchConfiguration: {
+ index,
+ query: { query: '', language: 'kuery' },
+ },
+ criteria: [
+ {
+ metrics: [{ name: 'A', aggType: Aggregators.COUNT }],
+ timeSize: 1,
+ timeUnit: 'm',
+ threshold: [90],
+ comparator: COMPARATORS.GREATER_THAN,
+ },
+ ],
+ },
},
},
},
{
title: 'rule with multiple metrics',
- params: {
- ...mockedParams,
- criteria: [
- {
- metrics: [
- { name: 'A', aggType: Aggregators.COUNT, filter: 'host.name: host-1' },
- { name: 'B', aggType: Aggregators.AVERAGE, field: 'system.load.1' },
+ alert: {
+ fields: {
+ [ALERT_RULE_PARAMETERS]: {
+ ...mockedParams,
+ criteria: [
+ {
+ metrics: [
+ { name: 'A', aggType: Aggregators.COUNT, filter: 'host.name: host-1' },
+ { name: 'B', aggType: Aggregators.AVERAGE, field: 'system.load.1' },
+ ],
+ timeSize: 1,
+ timeUnit: 'm',
+ threshold: [90],
+ comparator: COMPARATORS.GREATER_THAN,
+ },
],
- timeSize: 1,
- timeUnit: 'm',
- threshold: [90],
- comparator: COMPARATORS.GREATER_THAN,
},
- ],
+ },
},
- alert: {},
},
];
- test.each(testData)('should generate correct es query for $title', ({ alert, params }) => {
- expect(getLogRateAnalysisEQQuery(alert, params)).toMatchSnapshot();
+ test.each(testData)('should generate correct es query for $title', ({ alert }) => {
+ expect(getLogRateAnalysisEQQuery(alert)).toMatchSnapshot();
});
});
diff --git a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/helpers/log_rate_analysis_query.ts b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/helpers/log_rate_analysis_query.ts
index 4bd0b16212e11..bea80bfb5ab5e 100644
--- a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/helpers/log_rate_analysis_query.ts
+++ b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/helpers/log_rate_analysis_query.ts
@@ -7,12 +7,12 @@
import { get } from 'lodash';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
+import { ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils';
+import { CustomThresholdAlert } from '../../types';
import { getGroupFilters } from '../../../../../../common/custom_threshold_rule/helpers/get_group';
import { Aggregators } from '../../../../../../common/custom_threshold_rule/types';
import { buildEsQuery } from '../../../../../utils/build_es_query';
import type { CustomThresholdExpressionMetric } from '../../../../../../common/custom_threshold_rule/types';
-import type { TopAlert } from '../../../../../typings/alerts';
-import type { CustomThresholdRuleTypeParams } from '../../../types';
import { Group } from '../../../../../../common/typings';
const getKuery = (metrics: CustomThresholdExpressionMetric[], filter?: string) => {
@@ -32,23 +32,23 @@ const getKuery = (metrics: CustomThresholdExpressionMetric[], filter?: string) =
};
export const getLogRateAnalysisEQQuery = (
- alert: TopAlert>,
- params: CustomThresholdRuleTypeParams
+ alert: CustomThresholdAlert
): QueryDslQueryContainer | undefined => {
+ const ruleParams = alert.fields[ALERT_RULE_PARAMETERS];
// We only show log rate analysis for one condition with one count aggregation
if (
- params.criteria.length !== 1 ||
- params.criteria[0].metrics.length !== 1 ||
- params.criteria[0].metrics[0].aggType !== Aggregators.COUNT
+ ruleParams.criteria.length !== 1 ||
+ ruleParams.criteria[0].metrics.length !== 1 ||
+ ruleParams.criteria[0].metrics[0].aggType !== Aggregators.COUNT
) {
return;
}
const group = get(alert, 'fields["kibana.alert.group"]') as Group[] | undefined;
- const optionalFilter = get(params.searchConfiguration, 'query.query') as string | undefined;
+ const optionalFilter = get(ruleParams.searchConfiguration, 'query.query') as string | undefined;
const groupByFilters = getGroupFilters(group);
const boolQuery = buildEsQuery({
- kuery: getKuery(params.criteria[0].metrics, optionalFilter),
+ kuery: getKuery(ruleParams.criteria[0].metrics, optionalFilter),
filters: groupByFilters,
});
diff --git a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/log_rate_analysis.tsx b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/log_rate_analysis.tsx
index f2285b3529f65..89e8cc5e2aa6a 100644
--- a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/log_rate_analysis.tsx
+++ b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/log_rate_analysis.tsx
@@ -19,17 +19,14 @@ import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Message } from '@kbn/observability-ai-assistant-plugin/public';
-import { Rule } from '@kbn/triggers-actions-ui-plugin/public';
-import { ALERT_END } from '@kbn/rule-data-utils';
-import { CustomThresholdRuleTypeParams } from '../../types';
-import { TopAlert } from '../../../..';
+import { ALERT_END, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils';
+import { CustomThresholdAlert } from '../types';
import { Color, colorTransformer } from '../../../../../common/custom_threshold_rule/color_palette';
import { getLogRateAnalysisEQQuery } from './helpers/log_rate_analysis_query';
export interface AlertDetailsLogRateAnalysisProps {
- alert: TopAlert>;
+ alert: CustomThresholdAlert;
dataView: any;
- rule: Rule;
services: any;
}
@@ -40,12 +37,7 @@ interface SignificantFieldValue {
pValue: number | null;
}
-export function LogRateAnalysis({
- alert,
- dataView,
- rule,
- services,
-}: AlertDetailsLogRateAnalysisProps) {
+export function LogRateAnalysis({ alert, dataView, services }: AlertDetailsLogRateAnalysisProps) {
const {
observabilityAIAssistant: {
ObservabilityAIAssistantContextualInsight,
@@ -57,22 +49,23 @@ export function LogRateAnalysis({
| { logRateAnalysisType: LogRateAnalysisType; significantFieldValues: SignificantFieldValue[] }
| undefined
>();
+ const ruleParams = alert.fields[ALERT_RULE_PARAMETERS];
useEffect(() => {
- const esSearchRequest = getLogRateAnalysisEQQuery(alert, rule.params);
+ const esSearchRequest = getLogRateAnalysisEQQuery(alert);
if (esSearchRequest) {
setEsSearchQuery(esSearchRequest);
}
- }, [alert, rule.params]);
+ }, [alert]);
const { timeRange, windowParameters } = useMemo(() => {
const alertStartedAt = moment(alert.start).toISOString();
const alertEndedAt = alert.fields[ALERT_END]
? moment(alert.fields[ALERT_END]).toISOString()
: undefined;
- const timeSize = rule.params.criteria[0]?.timeSize as number | undefined;
- const timeUnit = rule.params.criteria[0]?.timeUnit as
+ const timeSize = ruleParams.criteria[0]?.timeSize as number | undefined;
+ const timeUnit = ruleParams.criteria[0]?.timeUnit as
| moment.unitOfTime.DurationConstructor
| undefined;
@@ -82,7 +75,7 @@ export function LogRateAnalysis({
timeSize,
timeUnit,
});
- }, [alert, rule]);
+ }, [alert.fields, alert.start, ruleParams.criteria]);
const logRateAnalysisTitle = i18n.translate(
'xpack.observability.customThreshold.alertDetails.logRateAnalysisTitle',
diff --git a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/mocks/custom_threshold_rule.ts b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/mocks/custom_threshold_rule.ts
index c1db31d991c28..36f108b1db628 100644
--- a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/mocks/custom_threshold_rule.ts
+++ b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/mocks/custom_threshold_rule.ts
@@ -227,19 +227,23 @@ export const buildCustomThresholdAlert = (
{
name: 'B',
aggType: Aggregators.MAX,
- metric: 'system.cpu.user.pct',
+ field: 'system.cpu.user.pct',
},
],
threshold: [4],
timeSize: 15,
timeUnit: 'm',
- warningComparator: COMPARATORS.GREATER_THAN,
- warningThreshold: [2.2],
},
],
- sourceId: 'default',
alertOnNoData: true,
alertOnGroupDisappear: true,
+ searchConfiguration: {
+ query: {
+ query: '',
+ language: 'kuery',
+ },
+ index: 'b3eadf0e-1053-41d0-9672-dc1d7789dd68',
+ },
},
'kibana.alert.evaluation.values': [2500, 5],
'kibana.alert.group': [{ field: 'host.name', value: 'host-1' }],
diff --git a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/types.ts b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/types.ts
index 8d5b1260a809c..891661b6bc82a 100644
--- a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/types.ts
+++ b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/types.ts
@@ -8,7 +8,7 @@
import * as rt from 'io-ts';
import { CasesPublicStart } from '@kbn/cases-plugin/public';
import { ChartsPluginStart } from '@kbn/charts-plugin/public';
-import { DataPublicPluginStart, SerializedSearchSourceFields } from '@kbn/data-plugin/public';
+import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { DiscoverStart } from '@kbn/discover-plugin/public';
import { EmbeddableStart } from '@kbn/embeddable-plugin/public';
@@ -16,7 +16,7 @@ import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { LensPublicStart } from '@kbn/lens-plugin/public';
import { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public';
import { OsqueryPluginStart } from '@kbn/osquery-plugin/public';
-import { ALERT_GROUP } from '@kbn/rule-data-utils';
+import { ALERT_GROUP, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils';
import { SharePluginStart } from '@kbn/share-plugin/public';
import { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import {
@@ -87,11 +87,12 @@ export type RendererFunction = (args: Rende
export interface CustomThresholdRuleTypeParams extends RuleTypeParams {
criteria: CustomMetricExpressionParams[];
- searchConfiguration: SerializedSearchSourceFields;
+ searchConfiguration: CustomThresholdSearchSourceFields;
groupBy?: string | string[];
}
export interface CustomThresholdAlertFields {
[ALERT_GROUP]?: Array<{ field: string; value: string }>;
+ [ALERT_RULE_PARAMETERS]: CustomThresholdRuleTypeParams;
}
export const expressionTimestampsRT = rt.type({
diff --git a/x-pack/plugins/observability_solution/observability/public/utils/investigation_item_helper.ts b/x-pack/plugins/observability_solution/observability/public/utils/investigation_item_helper.ts
index cddf3290ed370..91bfcd2ab4bb1 100644
--- a/x-pack/plugins/observability_solution/observability/public/utils/investigation_item_helper.ts
+++ b/x-pack/plugins/observability_solution/observability/public/utils/investigation_item_helper.ts
@@ -24,9 +24,15 @@ const genLensEqForCustomThresholdRule = (criterion: MetricExpression) => {
criterion.metrics.forEach((metric: CustomThresholdExpressionMetric) => {
const metricFilter = metric.filter ? `kql='${metric.filter}'` : '';
- metricNameResolver[metric.name] = `${
- AggMappingForLens[metric.aggType] ? AggMappingForLens[metric.aggType] : metric.aggType
- }(${metric.field ? metric.field : metricFilter})`;
+ if (metric.aggType === 'rate') {
+ metricNameResolver[metric.name] = `counter_rate(max(${
+ metric.field ? metric.field : metricFilter
+ }))`;
+ } else {
+ metricNameResolver[metric.name] = `${
+ AggMappingForLens[metric.aggType] ? AggMappingForLens[metric.aggType] : metric.aggType
+ }(${metric.field ? metric.field : metricFilter})`;
+ }
});
let equation = criterion.equation
diff --git a/x-pack/plugins/saved_objects_tagging/public/utils.ts b/x-pack/plugins/saved_objects_tagging/public/utils.ts
index 38ae79f3ca033..fc8ec8ebd3029 100644
--- a/x-pack/plugins/saved_objects_tagging/public/utils.ts
+++ b/x-pack/plugins/saved_objects_tagging/public/utils.ts
@@ -45,7 +45,7 @@ export const getTagsFromReferences = (references: SavedObjectReference[], allTag
};
export const convertTagNameToId = (tagName: string, allTags: Tag[]): string | undefined => {
- const found = allTags.find((tag) => tag.name === tagName);
+ const found = allTags.find((tag) => tag.name.toLowerCase() === tagName.toLowerCase());
return found?.id;
};
diff --git a/x-pack/plugins/search_indices/public/components/indices/details_page.tsx b/x-pack/plugins/search_indices/public/components/indices/details_page.tsx
index e8868663a9a3f..ad5e174dd6e4a 100644
--- a/x-pack/plugins/search_indices/public/components/indices/details_page.tsx
+++ b/x-pack/plugins/search_indices/public/components/indices/details_page.tsx
@@ -190,7 +190,7 @@ export const SearchIndexDetailsPage = () => {
}, [isShowingDeleteModal]);
const { euiTheme } = useEuiTheme();
- if (isInitialLoading || isMappingsInitialLoading) {
+ if (isInitialLoading || isMappingsInitialLoading || indexDocumentsIsInitialLoading) {
return (
{i18n.translate('xpack.searchIndices.loadingDescription', {
@@ -209,7 +209,7 @@ export const SearchIndexDetailsPage = () => {
panelled
bottomBorder
>
- {isIndexError || isMappingsError || !index || !mappings ? (
+ {isIndexError || isMappingsError || !index || !mappings || !indexDocuments ? (
{
-
+
diff --git a/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx b/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx
index cece2b1d39910..32590cf3efa47 100644
--- a/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx
+++ b/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx
@@ -22,10 +22,12 @@ import { Mappings } from '../../types';
import { countVectorBasedTypesFromMappings } from './mappings_convertor';
import { QuickStat } from './quick_stat';
import { useKibana } from '../../hooks/use_kibana';
+import { IndexDocuments } from '../../hooks/api/use_document_search';
export interface QuickStatsProps {
index: Index;
mappings: Mappings;
+ indexDocuments: IndexDocuments;
}
export const SetupAISearchButton: React.FC = () => {
@@ -60,12 +62,13 @@ export const SetupAISearchButton: React.FC = () => {
);
};
-export const QuickStats: React.FC = ({ index, mappings }) => {
+export const QuickStats: React.FC = ({ index, mappings, indexDocuments }) => {
const [open, setOpen] = useState(false);
const { euiTheme } = useEuiTheme();
const mappingStats = useMemo(() => countVectorBasedTypesFromMappings(mappings), [mappings]);
const vectorFieldCount =
mappingStats.sparse_vector + mappingStats.dense_vector + mappingStats.semantic_text;
+ const docCount = indexDocuments?.results._meta.page.total ?? 0;
return (
= ({ index, mappings }) => {
defaultMessage: 'Document count',
})}
data-test-subj="QuickStatsDocumentCount"
- secondaryTitle={}
+ secondaryTitle={}
stats={[
{
title: i18n.translate('xpack.searchIndices.quickStats.documents.totalTitle', {
defaultMessage: 'Total',
}),
- description: ,
+ description: ,
},
{
title: i18n.translate('xpack.searchIndices.quickStats.documents.indexSize', {
diff --git a/x-pack/plugins/search_indices/public/hooks/api/use_delete_document.ts b/x-pack/plugins/search_indices/public/hooks/api/use_delete_document.ts
index bbf43d684de56..4c5a64b270f91 100644
--- a/x-pack/plugins/search_indices/public/hooks/api/use_delete_document.ts
+++ b/x-pack/plugins/search_indices/public/hooks/api/use_delete_document.ts
@@ -40,12 +40,18 @@ export const useDeleteDocument = (indexName: string) => {
queryClient.setQueryData(
[QueryKeys.SearchDocuments, indexName],
(snapshot: IndexDocuments | undefined) => {
- const oldData = snapshot ?? { results: { data: [] } };
+ const oldData = snapshot ?? { results: { data: [], _meta: { page: { total: 0 } } } };
return {
...oldData,
results: {
...oldData.results,
data: oldData.results.data.filter((doc: SearchHit) => doc._id !== id),
+ _meta: {
+ page: {
+ ...oldData.results._meta.page,
+ total: oldData.results._meta.page.total - 1,
+ },
+ },
},
} as IndexDocuments;
}
diff --git a/x-pack/plugins/search_indices/public/hooks/api/use_document_search.ts b/x-pack/plugins/search_indices/public/hooks/api/use_document_search.ts
index 7a74391809f60..d566b90916892 100644
--- a/x-pack/plugins/search_indices/public/hooks/api/use_document_search.ts
+++ b/x-pack/plugins/search_indices/public/hooks/api/use_document_search.ts
@@ -36,6 +36,7 @@ export const useIndexDocumentSearch = (indexName: string) => {
http.post(`/internal/serverless_search/indices/${indexName}/search`, {
body: JSON.stringify({
searchQuery: '',
+ trackTotalHits: true,
}),
query: {
page: 0,
diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx
index a8a2338e51e9d..626d621f61a30 100644
--- a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx
@@ -13,10 +13,6 @@ import { suggestUsers } from './api';
import { USER_PROFILES_FAILURE } from './translations';
import { useAppToasts } from '../../hooks/use_app_toasts';
-export interface SuggestUserProfilesArgs {
- searchTerm: string;
-}
-
export const bulkGetUserProfiles = async ({
searchTerm,
}: {
@@ -25,7 +21,21 @@ export const bulkGetUserProfiles = async ({
return suggestUsers({ searchTerm });
};
-export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => {
+export interface UseSuggestUsersParams {
+ /**
+ * Search term to filter user profiles
+ */
+ searchTerm: string;
+ /**
+ * Whether the query should be enabled
+ */
+ enabled?: boolean;
+}
+
+/**
+ * Fetches user profiles based on a search term
+ */
+export const useSuggestUsers = ({ enabled = true, searchTerm }: UseSuggestUsersParams) => {
const { addError } = useAppToasts();
return useQuery(
@@ -36,6 +46,7 @@ export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => {
{
retry: false,
staleTime: Infinity,
+ enabled,
onError: (e) => {
addError(e, { title: USER_PROFILES_FAILURE });
},
diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx
index be9546c77525b..447ade158306b 100644
--- a/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx
+++ b/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx
@@ -5,13 +5,14 @@
* 2.0.
*/
-import { fireEvent, render, screen } from '@testing-library/react';
+import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { SearchRow } from './search_row';
import { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids';
import { AssociatedFilter } from '../../../common/notes/constants';
import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
+import { TestProviders } from '../../common/mock';
jest.mock('../../common/components/user_profiles/use_suggest_users');
@@ -35,7 +36,11 @@ describe('SearchRow', () => {
});
it('should render the component', () => {
- const { getByTestId } = render();
+ const { getByTestId } = render(
+
+
+
+ );
expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument();
expect(getByTestId(USER_SELECT_TEST_ID)).toBeInTheDocument();
@@ -43,7 +48,11 @@ describe('SearchRow', () => {
});
it('should call the correct action when entering a value in the search bar', async () => {
- const { getByTestId } = render();
+ const { getByTestId } = render(
+
+
+
+ );
const searchBox = getByTestId(SEARCH_BAR_TEST_ID);
@@ -53,20 +62,12 @@ describe('SearchRow', () => {
expect(mockDispatch).toHaveBeenCalled();
});
- it('should call the correct action when select a user', async () => {
- const { getByTestId } = render();
-
- const userSelect = getByTestId('comboBoxSearchInput');
- userSelect.focus();
-
- const option = await screen.findByText('test');
- fireEvent.click(option);
-
- expect(mockDispatch).toHaveBeenCalled();
- });
-
it('should call the correct action when select a value in the associated note dropdown', async () => {
- const { getByTestId } = render();
+ const { getByTestId } = render(
+
+
+
+ );
const associatedNoteSelect = getByTestId(ASSOCIATED_NOT_SELECT_TEST_ID);
await userEvent.selectOptions(associatedNoteSelect, [AssociatedFilter.documentOnly]);
diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx
index d540a586814d8..f2f90b3ba7e0d 100644
--- a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx
+++ b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx
@@ -5,10 +5,9 @@
* 2.0.
*/
-import React, { useMemo, useCallback, useState } from 'react';
+import React, { useCallback } from 'react';
import type { EuiSelectOption } from '@elastic/eui';
import {
- EuiComboBox,
EuiFlexGroup,
EuiFlexItem,
EuiSearchBar,
@@ -16,17 +15,12 @@ import {
useGeneratedHtmlId,
} from '@elastic/eui';
import { useDispatch } from 'react-redux';
-import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import { i18n } from '@kbn/i18n';
-import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
-import { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids';
-import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
-import { userFilterAssociatedNotes, userFilterUsers, userSearchedNotes } from '..';
+import { UserFilterDropdown } from './user_filter_dropdown';
+import { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID } from './test_ids';
+import { userFilterAssociatedNotes, userSearchedNotes } from '..';
import { AssociatedFilter } from '../../../common/notes/constants';
-export const USERS_DROPDOWN = i18n.translate('xpack.securitySolution.notes.usersDropdownLabel', {
- defaultMessage: 'Users',
-});
const FILTER_SELECT = i18n.translate('xpack.securitySolution.notes.management.filterSelect', {
defaultMessage: 'Select filter',
});
@@ -55,26 +49,6 @@ export const SearchRow = React.memo(() => {
[dispatch]
);
- const { isLoading: isLoadingSuggestedUsers, data: userProfiles } = useSuggestUsers({
- searchTerm: '',
- });
- const users = useMemo(
- () =>
- (userProfiles || []).map((userProfile: UserProfileWithAvatar) => ({
- label: userProfile.user.full_name || userProfile.user.username,
- })),
- [userProfiles]
- );
-
- const [selectedUser, setSelectedUser] = useState>>();
- const onChange = useCallback(
- (user: Array>) => {
- setSelectedUser(user);
- dispatch(userFilterUsers(user.length > 0 ? user[0].label : ''));
- },
- [dispatch]
- );
-
const onAssociatedNoteSelectChange = useCallback(
(e: React.ChangeEvent) => {
dispatch(userFilterAssociatedNotes(e.target.value as AssociatedFilter));
@@ -88,15 +62,7 @@ export const SearchRow = React.memo(() => {
-
+
{
+ const original = jest.requireActual('react-redux');
+
+ return {
+ ...original,
+ useDispatch: () => mockDispatch,
+ };
+});
+
+describe('UserFilterDropdown', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useSuggestUsers as jest.Mock).mockReturnValue({
+ isLoading: false,
+ data: [{ user: { username: 'test' } }, { user: { username: 'elastic' } }],
+ });
+ (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true });
+ (useUpsellingMessage as jest.Mock).mockReturnValue('upsellingMessage');
+ });
+
+ it('should render the component enabled', () => {
+ const { getByTestId } = render();
+
+ const dropdown = getByTestId(USER_SELECT_TEST_ID);
+
+ expect(dropdown).toBeInTheDocument();
+ expect(dropdown).not.toHaveClass('euiComboBox-isDisabled');
+ });
+
+ it('should render the dropdown disabled', async () => {
+ (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false });
+
+ const { getByTestId } = render();
+
+ expect(getByTestId(USER_SELECT_TEST_ID)).toHaveClass('euiComboBox-isDisabled');
+ });
+
+ it('should call the correct action when select a user', async () => {
+ const { getByTestId } = render();
+
+ const userSelect = getByTestId('comboBoxSearchInput');
+ userSelect.focus();
+
+ const option = await screen.findByText('test');
+ fireEvent.click(option);
+
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/notes/components/user_filter_dropdown.tsx b/x-pack/plugins/security_solution/public/notes/components/user_filter_dropdown.tsx
new file mode 100644
index 0000000000000..78f4ef6dd2ac8
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/notes/components/user_filter_dropdown.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { useMemo, useCallback, useState } from 'react';
+import { EuiComboBox, EuiToolTip } from '@elastic/eui';
+import { useDispatch } from 'react-redux';
+import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
+import { i18n } from '@kbn/i18n';
+import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
+import { useLicense } from '../../common/hooks/use_license';
+import { useUpsellingMessage } from '../../common/hooks/use_upselling';
+import { USER_SELECT_TEST_ID } from './test_ids';
+import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
+import { userFilterUsers } from '..';
+
+export const USERS_DROPDOWN = i18n.translate('xpack.securitySolution.notes.usersDropdownLabel', {
+ defaultMessage: 'Users',
+});
+
+export const UserFilterDropdown = React.memo(() => {
+ const dispatch = useDispatch();
+ const isPlatinumPlus = useLicense().isPlatinumPlus();
+ const upsellingMessage = useUpsellingMessage('note_management_user_filter');
+
+ const { isLoading, data } = useSuggestUsers({
+ searchTerm: '',
+ enabled: isPlatinumPlus,
+ });
+ const users = useMemo(
+ () =>
+ (data || []).map((userProfile: UserProfileWithAvatar) => ({
+ label: userProfile.user.full_name || userProfile.user.username,
+ })),
+ [data]
+ );
+
+ const [selectedUser, setSelectedUser] = useState>>();
+ const onChange = useCallback(
+ (user: Array>) => {
+ setSelectedUser(user);
+ dispatch(userFilterUsers(user.length > 0 ? user[0].label : ''));
+ },
+ [dispatch]
+ );
+
+ const dropdown = useMemo(
+ () => (
+
+ ),
+ [isLoading, isPlatinumPlus, onChange, selectedUser, users]
+ );
+
+ return (
+ <>
+ {isPlatinumPlus ? (
+ <>{dropdown}>
+ ) : (
+
+ {dropdown}
+
+ )}
+ >
+ );
+});
+
+UserFilterDropdown.displayName = 'UserFilterDropdown';
diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_downloader_cli/agent_downloader.test.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_downloader_cli/agent_downloader.test.ts
new file mode 100644
index 0000000000000..a39ad186e62b6
--- /dev/null
+++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_downloader_cli/agent_downloader.test.ts
@@ -0,0 +1,203 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { getAgentDownloadUrl, getAgentFileName } from '../common/fleet_services';
+import { downloadAndStoreAgent } from '../common/agent_downloads_service';
+import type { ToolingLog } from '@kbn/tooling-log';
+import { agentDownloaderRunner } from './agent_downloader';
+import type { RunContext } from '@kbn/dev-cli-runner';
+
+jest.mock('../common/fleet_services');
+jest.mock('../common/agent_downloads_service');
+
+describe('agentDownloaderRunner', () => {
+ let log: ToolingLog;
+
+ beforeEach(() => {
+ log = {
+ info: jest.fn(),
+ error: jest.fn(),
+ } as unknown as ToolingLog;
+
+ jest.clearAllMocks();
+ });
+
+ const version = '8.15.0';
+ let closestMatch = false;
+ const url = 'http://example.com/agent.tar.gz';
+ const fileName = 'elastic-agent-8.15.0.tar.gz';
+
+ it('downloads and stores the specified version', async () => {
+ (getAgentDownloadUrl as jest.Mock).mockResolvedValue({ url });
+ (getAgentFileName as jest.Mock).mockReturnValue('elastic-agent-8.15.0');
+ (downloadAndStoreAgent as jest.Mock).mockResolvedValue(undefined);
+
+ await agentDownloaderRunner({
+ flags: { version, closestMatch },
+ log,
+ } as unknown as RunContext);
+
+ expect(getAgentDownloadUrl).toHaveBeenCalledWith(version, closestMatch, log);
+ expect(getAgentFileName).toHaveBeenCalledWith(version);
+ expect(downloadAndStoreAgent).toHaveBeenCalledWith(url, fileName);
+ expect(log.info).toHaveBeenCalledWith('Successfully downloaded and stored version 8.15.0');
+ });
+
+ it('logs an error if the download fails', async () => {
+ (getAgentDownloadUrl as jest.Mock).mockResolvedValue({ url });
+ (getAgentFileName as jest.Mock).mockReturnValue('elastic-agent-8.15.0');
+ (downloadAndStoreAgent as jest.Mock).mockRejectedValue(new Error('Download failed'));
+
+ await agentDownloaderRunner({
+ flags: { version, closestMatch },
+ log,
+ } as unknown as RunContext);
+
+ expect(getAgentDownloadUrl).toHaveBeenCalledWith(version, closestMatch, log);
+ expect(getAgentFileName).toHaveBeenCalledWith(version);
+ expect(downloadAndStoreAgent).toHaveBeenCalledWith(url, fileName);
+ expect(log.error).toHaveBeenCalledWith(
+ 'Failed to download or store version 8.15.0: Download failed'
+ );
+ });
+
+ it('downloads and stores the previous patch version if the specified version fails', async () => {
+ const fallbackVersion = '8.15.0';
+ const fallbackFileName = 'elastic-agent-8.15.0.tar.gz';
+
+ (getAgentDownloadUrl as jest.Mock)
+ .mockResolvedValueOnce({ url })
+ .mockResolvedValueOnce({ url });
+ (getAgentFileName as jest.Mock)
+ .mockReturnValueOnce('elastic-agent-8.15.1')
+ .mockReturnValueOnce('elastic-agent-8.15.0');
+ (downloadAndStoreAgent as jest.Mock)
+ .mockRejectedValueOnce(new Error('Download failed'))
+ .mockResolvedValueOnce(undefined);
+
+ await agentDownloaderRunner({
+ flags: { version: '8.15.1', closestMatch },
+ log,
+ } as unknown as RunContext);
+
+ expect(getAgentDownloadUrl).toHaveBeenCalledWith('8.15.1', closestMatch, log);
+ expect(getAgentDownloadUrl).toHaveBeenCalledWith(fallbackVersion, closestMatch, log);
+ expect(getAgentFileName).toHaveBeenCalledWith('8.15.1');
+ expect(getAgentFileName).toHaveBeenCalledWith(fallbackVersion);
+ expect(downloadAndStoreAgent).toHaveBeenCalledWith(url, 'elastic-agent-8.15.1.tar.gz');
+ expect(downloadAndStoreAgent).toHaveBeenCalledWith(url, fallbackFileName);
+ expect(log.error).toHaveBeenCalledWith(
+ 'Failed to download or store version 8.15.1: Download failed'
+ );
+ expect(log.info).toHaveBeenCalledWith('Successfully downloaded and stored version 8.15.0');
+ });
+
+ it('logs an error if all downloads fail', async () => {
+ (getAgentDownloadUrl as jest.Mock).mockResolvedValue({ url });
+ (getAgentFileName as jest.Mock)
+ .mockReturnValueOnce('elastic-agent-8.15.1')
+ .mockReturnValueOnce('elastic-agent-8.15.0');
+ (downloadAndStoreAgent as jest.Mock)
+ .mockRejectedValueOnce(new Error('Download failed'))
+ .mockRejectedValueOnce(new Error('Download failed'));
+
+ await agentDownloaderRunner({
+ flags: { version: '8.15.1', closestMatch },
+ log,
+ } as unknown as RunContext);
+
+ expect(getAgentDownloadUrl).toHaveBeenCalledWith('8.15.1', closestMatch, log);
+ expect(getAgentDownloadUrl).toHaveBeenCalledWith('8.15.0', closestMatch, log);
+ expect(getAgentFileName).toHaveBeenCalledWith('8.15.1');
+ expect(getAgentFileName).toHaveBeenCalledWith('8.15.0');
+ expect(downloadAndStoreAgent).toHaveBeenCalledWith(url, 'elastic-agent-8.15.1.tar.gz');
+ expect(downloadAndStoreAgent).toHaveBeenCalledWith(url, 'elastic-agent-8.15.0.tar.gz');
+ expect(log.error).toHaveBeenCalledWith(
+ 'Failed to download or store version 8.15.1: Download failed'
+ );
+ expect(log.error).toHaveBeenCalledWith(
+ 'Failed to download or store version 8.15.0: Download failed'
+ );
+ });
+
+ it('does not attempt fallback when patch version is 0', async () => {
+ (getAgentDownloadUrl as jest.Mock).mockResolvedValue({ url });
+ (getAgentFileName as jest.Mock).mockReturnValue('elastic-agent-8.15.0');
+ (downloadAndStoreAgent as jest.Mock).mockResolvedValue(undefined);
+
+ await agentDownloaderRunner({
+ flags: { version: '8.15.0', closestMatch },
+ log,
+ } as unknown as RunContext);
+
+ expect(getAgentDownloadUrl).toHaveBeenCalledTimes(1); // Only one call for 8.15.0
+ expect(getAgentFileName).toHaveBeenCalledTimes(1);
+ expect(downloadAndStoreAgent).toHaveBeenCalledWith(url, fileName);
+ expect(log.info).toHaveBeenCalledWith('Successfully downloaded and stored version 8.15.0');
+ });
+
+ it('logs an error for an invalid version format', async () => {
+ const invalidVersion = '7.x.x';
+
+ await expect(
+ agentDownloaderRunner({
+ flags: { version: invalidVersion, closestMatch },
+ log,
+ } as unknown as RunContext)
+ ).rejects.toThrow('Invalid version format');
+ });
+
+ it('passes the closestMatch flag correctly', async () => {
+ closestMatch = true;
+
+ (getAgentDownloadUrl as jest.Mock).mockResolvedValue({ url });
+ (getAgentFileName as jest.Mock).mockReturnValue('elastic-agent-8.15.0');
+ (downloadAndStoreAgent as jest.Mock).mockResolvedValue(undefined);
+
+ await agentDownloaderRunner({
+ flags: { version, closestMatch },
+ log,
+ } as unknown as RunContext);
+
+ expect(getAgentDownloadUrl).toHaveBeenCalledWith(version, closestMatch, log);
+ });
+
+ it('throws an error when version is not provided', async () => {
+ await expect(
+ agentDownloaderRunner({
+ flags: { closestMatch },
+ log,
+ } as unknown as RunContext)
+ ).rejects.toThrow('version argument is required');
+ });
+
+ it('logs the correct messages when both version and fallback version are processed', async () => {
+ const primaryVersion = '8.15.1';
+
+ (getAgentDownloadUrl as jest.Mock)
+ .mockResolvedValueOnce({ url })
+ .mockResolvedValueOnce({ url });
+
+ (getAgentFileName as jest.Mock)
+ .mockReturnValueOnce('elastic-agent-8.15.1')
+ .mockReturnValueOnce('elastic-agent-8.15.0');
+
+ (downloadAndStoreAgent as jest.Mock)
+ .mockRejectedValueOnce(new Error('Download failed')) // Fail on primary
+ .mockResolvedValueOnce(undefined); // Success on fallback
+
+ await agentDownloaderRunner({
+ flags: { version: primaryVersion, closestMatch },
+ log,
+ } as unknown as RunContext);
+
+ expect(log.error).toHaveBeenCalledWith(
+ 'Failed to download or store version 8.15.1: Download failed'
+ );
+ expect(log.info).toHaveBeenCalledWith('Successfully downloaded and stored version 8.15.0');
+ });
+});
diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_downloader_cli/agent_downloader.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_downloader_cli/agent_downloader.ts
index ab1da6a3f208f..8366c77575e70 100644
--- a/x-pack/plugins/security_solution/scripts/endpoint/agent_downloader_cli/agent_downloader.ts
+++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_downloader_cli/agent_downloader.ts
@@ -8,24 +8,72 @@
import { ok } from 'assert';
import type { RunFn } from '@kbn/dev-cli-runner';
import type { ToolingLog } from '@kbn/tooling-log';
+import semver from 'semver';
import { getAgentDownloadUrl, getAgentFileName } from '../common/fleet_services';
import { downloadAndStoreAgent } from '../common/agent_downloads_service';
+// Decrement the patch version by 1 and preserve pre-release tag (if any)
+const decrementPatchVersion = (version: string): string | null => {
+ const parsedVersion = semver.parse(version);
+ if (!parsedVersion) {
+ return null;
+ }
+ const newPatchVersion = parsedVersion.patch - 1;
+ // Create a new version string with the decremented patch - removing any possible pre-release tag
+ const newVersion = `${parsedVersion.major}.${parsedVersion.minor}.${newPatchVersion}`;
+ return semver.valid(newVersion) ? newVersion : null;
+};
+
+// Generate a list of versions to attempt downloading, including a fallback to the previous patch (GA)
+const getVersionsToDownload = (version: string): string[] => {
+ const parsedVersion = semver.parse(version);
+ if (!parsedVersion) return [];
+ // If patch version is 0, return only the current version.
+ if (parsedVersion.patch === 0) {
+ return [version];
+ }
+
+ const decrementedVersion = decrementPatchVersion(version);
+ return decrementedVersion ? [version, decrementedVersion] : [version];
+};
+
+// Download and store the Elastic Agent for the specified version(s)
const downloadAndStoreElasticAgent = async (
version: string,
closestMatch: boolean,
log: ToolingLog
-) => {
- const downloadUrlResponse = await getAgentDownloadUrl(version, closestMatch, log);
- const fileNameNoExtension = getAgentFileName(version);
- const agentFile = `${fileNameNoExtension}.tar.gz`;
- await downloadAndStoreAgent(downloadUrlResponse.url, agentFile);
+): Promise => {
+ const versionsToDownload = getVersionsToDownload(version);
+
+ // Although we have a list of versions to try downloading, we only need to download one, and will return as soon as it succeeds.
+ for (const versionToDownload of versionsToDownload) {
+ try {
+ const { url } = await getAgentDownloadUrl(versionToDownload, closestMatch, log);
+ const fileName = `${getAgentFileName(versionToDownload)}.tar.gz`;
+
+ await downloadAndStoreAgent(url, fileName);
+ log.info(`Successfully downloaded and stored version ${versionToDownload}`);
+ return; // Exit once successful
+ } catch (error) {
+ log.error(`Failed to download or store version ${versionToDownload}: ${error.message}`);
+ }
+ }
+
+ log.error(`Failed to download agent for any available version: ${versionsToDownload.join(', ')}`);
};
export const agentDownloaderRunner: RunFn = async (cliContext) => {
- ok(cliContext.flags.version, 'version argument is required');
+ const { version } = cliContext.flags;
+
+ ok(version, 'version argument is required');
+
+ // Validate version format
+ if (!semver.valid(version as string)) {
+ throw new Error('Invalid version format');
+ }
+
await downloadAndStoreElasticAgent(
- cliContext.flags.version as string,
+ version as string,
cliContext.flags.closestMatch as boolean,
cliContext.log
);
diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/agent_downloads_service.test.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/agent_downloads_service.test.ts
new file mode 100644
index 0000000000000..0a7a9d3104798
--- /dev/null
+++ b/x-pack/plugins/security_solution/scripts/endpoint/common/agent_downloads_service.test.ts
@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+// Adjust path if needed
+
+import { downloadAndStoreAgent, isAgentDownloadFromDiskAvailable } from './agent_downloads_service';
+import fs from 'fs';
+import nodeFetch from 'node-fetch';
+import { finished } from 'stream/promises';
+
+jest.mock('fs');
+jest.mock('node-fetch');
+jest.mock('stream/promises', () => ({
+ finished: jest.fn(),
+}));
+jest.mock('../../../common/endpoint/data_loaders/utils', () => ({
+ createToolingLogger: jest.fn(() => ({
+ debug: jest.fn(),
+ info: jest.fn(),
+ error: jest.fn(),
+ })),
+}));
+
+describe('AgentDownloadStorage', () => {
+ const url = 'http://example.com/agent.tar.gz';
+ const fileName = 'elastic-agent-7.10.0.tar.gz';
+ beforeEach(() => {
+ jest.clearAllMocks(); // Ensure no previous test state affects the current one
+ });
+
+ it('downloads and stores the agent if not cached', async () => {
+ (fs.existsSync as unknown as jest.Mock).mockReturnValue(false);
+ (fs.createWriteStream as unknown as jest.Mock).mockReturnValue({
+ on: jest.fn(),
+ end: jest.fn(),
+ });
+ (nodeFetch as unknown as jest.Mock).mockResolvedValue({ body: { pipe: jest.fn() } });
+ (finished as unknown as jest.Mock).mockResolvedValue(undefined);
+
+ const result = await downloadAndStoreAgent(url, fileName);
+
+ expect(result).toEqual({
+ url,
+ filename: fileName,
+ directory: expect.any(String),
+ fullFilePath: expect.stringContaining(fileName), // Dynamically match the file path
+ });
+ });
+
+ it('reuses cached agent if available', async () => {
+ (fs.existsSync as unknown as jest.Mock).mockReturnValue(true);
+
+ const result = await downloadAndStoreAgent(url, fileName);
+
+ expect(result).toEqual({
+ url,
+ filename: fileName,
+ directory: expect.any(String),
+ fullFilePath: expect.stringContaining(fileName), // Dynamically match the path
+ });
+ });
+
+ it('checks if agent download is available from disk', () => {
+ (fs.existsSync as unknown as jest.Mock).mockReturnValue(true);
+
+ const result = isAgentDownloadFromDiskAvailable(fileName);
+
+ expect(result).toEqual({
+ filename: fileName,
+ directory: expect.any(String),
+ fullFilePath: expect.stringContaining(fileName), // Dynamically match the path
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/agent_downloads_service.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/agent_downloads_service.ts
index 488e1b10160e8..4c963332ad0c2 100644
--- a/x-pack/plugins/security_solution/scripts/endpoint/common/agent_downloads_service.ts
+++ b/x-pack/plugins/security_solution/scripts/endpoint/common/agent_downloads_service.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
+import pRetry from 'p-retry';
import { mkdir, readdir, stat, unlink } from 'fs/promises';
import { join } from 'path';
import fs from 'fs';
@@ -24,7 +25,7 @@ export interface DownloadedAgentInfo {
interface AgentDownloadStorageSettings {
/**
- * Last time a cleanup was ran. Date in ISO format
+ * Last time a cleanup was performed. Date in ISO format
*/
lastCleanup: string;
@@ -47,7 +48,7 @@ class AgentDownloadStorage extends SettingsStorage
constructor() {
super('agent_download_storage_settings.json', {
defaultSettings: {
- maxFileAge: 1.728e8, // 2 days
+ maxFileAge: 1.728e8, // 2 days in milliseconds
lastCleanup: new Date().toISOString(),
},
});
@@ -55,20 +56,25 @@ class AgentDownloadStorage extends SettingsStorage
this.downloadsDirFullPath = this.buildPath(this.downloadsDirName);
}
+ /**
+ * Ensures the download directory exists on disk
+ */
protected async ensureExists(): Promise {
await super.ensureExists();
if (!this.downloadsFolderExists) {
await mkdir(this.downloadsDirFullPath, { recursive: true });
- this.log.debug(`Created directory [this.downloadsDirFullPath] for cached agent downloads`);
+ this.log.debug(`Created directory [${this.downloadsDirFullPath}] for cached agent downloads`);
this.downloadsFolderExists = true;
}
}
+ /**
+ * Gets the file paths for a given download URL and optional file name.
+ */
public getPathsForUrl(agentDownloadUrl: string, agentFileName?: string): DownloadedAgentInfo {
- const filename = agentFileName
- ? agentFileName
- : agentDownloadUrl.replace(/^https?:\/\//gi, '').replace(/\//g, '#');
+ const filename =
+ agentFileName || agentDownloadUrl.replace(/^https?:\/\//gi, '').replace(/\//g, '#');
const directory = this.downloadsDirFullPath;
const fullFilePath = this.buildPath(join(this.downloadsDirName, filename));
@@ -79,59 +85,67 @@ class AgentDownloadStorage extends SettingsStorage
};
}
+ /**
+ * Downloads the agent and stores it locally. Reuses existing downloads if available.
+ */
public async downloadAndStore(
agentDownloadUrl: string,
agentFileName?: string
): Promise {
- this.log.debug(`Downloading and storing: ${agentDownloadUrl}`);
-
- // TODO: should we add "retry" attempts to file downloads?
+ this.log.debug(`Starting download: ${agentDownloadUrl}`);
await this.ensureExists();
-
const newDownloadInfo = this.getPathsForUrl(agentDownloadUrl, agentFileName);
- // If download is already present on disk, then just return that info. No need to re-download it
+ // Return cached version if the file already exists
if (fs.existsSync(newDownloadInfo.fullFilePath)) {
this.log.debug(`Download already cached at [${newDownloadInfo.fullFilePath}]`);
return newDownloadInfo;
}
try {
- const outputStream = fs.createWriteStream(newDownloadInfo.fullFilePath);
-
- await handleProcessInterruptions(
- async () => {
- const { body } = await nodeFetch(agentDownloadUrl);
- await finished(body.pipe(outputStream));
+ await pRetry(
+ async (attempt) => {
+ this.log.info(
+ `Attempt ${attempt} - Downloading agent from [${agentDownloadUrl}] to [${newDownloadInfo.fullFilePath}]`
+ );
+ const outputStream = fs.createWriteStream(newDownloadInfo.fullFilePath);
+
+ await handleProcessInterruptions(
+ async () => {
+ const { body } = await nodeFetch(agentDownloadUrl);
+ await finished(body.pipe(outputStream));
+ },
+ () => fs.unlinkSync(newDownloadInfo.fullFilePath) // Clean up on interruption
+ );
+ this.log.info(`Successfully downloaded agent to [${newDownloadInfo.fullFilePath}]`);
},
- () => {
- fs.unlinkSync(newDownloadInfo.fullFilePath);
+ {
+ retries: 2, // 2 retries = 3 total attempts (1 initial + 2 retries)
+ onFailedAttempt: (error) => {
+ this.log.error(`Download attempt ${error.attemptNumber} failed: ${error.message}`);
+ // Cleanup failed download
+ return unlink(newDownloadInfo.fullFilePath);
+ },
}
);
- } catch (e) {
- // Try to clean up download case it failed halfway through
- await unlink(newDownloadInfo.fullFilePath);
-
- throw e;
+ } catch (error) {
+ throw new Error(`Download failed after multiple attempts: ${error.message}`);
}
await this.cleanupDownloads();
-
return newDownloadInfo;
}
public async cleanupDownloads(): Promise<{ deleted: string[] }> {
- this.log.debug(`Performing cleanup of cached Agent downlaods`);
+ this.log.debug('Performing cleanup of cached Agent downloads');
const settings = await this.get();
- const maxAgeDate = new Date();
+ const maxAgeDate = new Date(Date.now() - settings.maxFileAge);
const response: { deleted: string[] } = { deleted: [] };
- maxAgeDate.setMilliseconds(settings.maxFileAge * -1); // `* -1` to set time back
-
- // If cleanup already happen within the file age, then nothing to do. Exit.
if (settings.lastCleanup > maxAgeDate.toISOString()) {
+ this.log.debug('Skipping cleanup, as it was performed recently.');
return response;
}
@@ -140,41 +154,48 @@ class AgentDownloadStorage extends SettingsStorage
lastCleanup: new Date().toISOString(),
});
- const deleteFilePromises: Array> = [];
- const allFiles = await readdir(this.downloadsDirFullPath);
-
- for (const fileName of allFiles) {
- const filePath = join(this.downloadsDirFullPath, fileName);
- const fileStats = await stat(filePath);
+ try {
+ const allFiles = await readdir(this.downloadsDirFullPath);
+ const deleteFilePromises = allFiles.map(async (fileName) => {
+ const filePath = join(this.downloadsDirFullPath, fileName);
+ const fileStats = await stat(filePath);
+ if (fileStats.isFile() && fileStats.birthtime < maxAgeDate) {
+ try {
+ await unlink(filePath);
+ response.deleted.push(filePath);
+ } catch (err) {
+ this.log.error(`Failed to delete file [${filePath}]: ${err.message}`);
+ }
+ }
+ });
- if (fileStats.isFile() && fileStats.birthtime < maxAgeDate) {
- deleteFilePromises.push(unlink(filePath));
- response.deleted.push(filePath);
- }
+ await Promise.allSettled(deleteFilePromises);
+ this.log.debug(`Deleted ${response.deleted.length} file(s)`);
+ return response;
+ } catch (err) {
+ this.log.error(`Error during cleanup: ${err.message}`);
+ return response;
}
-
- await Promise.allSettled(deleteFilePromises);
-
- this.log.debug(`Deleted [${response.deleted.length}] file(s)`);
- this.log.verbose(`files deleted:\n`, response.deleted.join('\n'));
-
- return response;
}
+ /**
+ * Checks if a specific agent download is available locally.
+ */
public isAgentDownloadFromDiskAvailable(filename: string): DownloadedAgentInfo | undefined {
- if (fs.existsSync(join(this.downloadsDirFullPath, filename))) {
+ const filePath = join(this.downloadsDirFullPath, filename);
+ if (fs.existsSync(filePath)) {
return {
filename,
/** The local directory where downloads are stored */
directory: this.downloadsDirFullPath,
/** The full local file path and name */
- fullFilePath: join(this.downloadsDirFullPath, filename),
+ fullFilePath: filePath,
};
}
}
}
-const agentDownloadsClient = new AgentDownloadStorage();
+export const agentDownloadsClient = new AgentDownloadStorage();
export interface DownloadAndStoreAgentResponse extends DownloadedAgentInfo {
url: string;
@@ -203,12 +224,15 @@ export const downloadAndStoreAgent = async (
};
/**
- * Cleans up the old agent downloads on disk.
+ * Cleans up old agent downloads on disk.
*/
export const cleanupDownloads = async (): ReturnType => {
return agentDownloadsClient.cleanupDownloads();
};
+/**
+ * Checks if a specific agent download is available from disk.
+ */
export const isAgentDownloadFromDiskAvailable = (
fileName: string
): DownloadedAgentInfo | undefined => {
diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.test.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.test.ts
index 8079e54ac9ba6..858047952801d 100644
--- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.test.ts
@@ -39,23 +39,25 @@ describe('EntityStoreDataClient', () => {
sortOrder: 'asc' as SortOrder,
};
+ const emptySearchResponse = {
+ took: 0,
+ timed_out: false,
+ _shards: {
+ total: 0,
+ successful: 0,
+ skipped: 0,
+ failed: 0,
+ },
+ hits: {
+ total: 0,
+ hits: [],
+ },
+ };
+
describe('search entities', () => {
beforeEach(() => {
jest.resetAllMocks();
- esClientMock.search.mockResolvedValue({
- took: 0,
- timed_out: false,
- _shards: {
- total: 0,
- successful: 0,
- skipped: 0,
- failed: 0,
- },
- hits: {
- total: 0,
- hits: [],
- },
- });
+ esClientMock.search.mockResolvedValue(emptySearchResponse);
});
it('searches in the entities store indices', async () => {
@@ -133,5 +135,47 @@ describe('EntityStoreDataClient', () => {
expect(response.inspect).toMatchSnapshot();
});
+
+ it('returns searched entity record', async () => {
+ const fakeEntityRecord = { entity_record: true, asset: { criticality: 'low' } };
+
+ esClientMock.search.mockResolvedValue({
+ ...emptySearchResponse,
+ hits: {
+ total: 1,
+ hits: [
+ {
+ _index: '.entities.v1.latest.security_host_default',
+ _source: fakeEntityRecord,
+ },
+ ],
+ },
+ });
+
+ const response = await dataClient.searchEntities(defaultSearchParams);
+
+ expect(response.records[0]).toEqual(fakeEntityRecord);
+ });
+
+ it("returns empty asset criticality when criticality value is 'deleted'", async () => {
+ const fakeEntityRecord = { entity_record: true };
+
+ esClientMock.search.mockResolvedValue({
+ ...emptySearchResponse,
+ hits: {
+ total: 1,
+ hits: [
+ {
+ _index: '.entities.v1.latest.security_host_default',
+ _source: { asset: { criticality: 'deleted' }, ...fakeEntityRecord },
+ },
+ ],
+ },
+ });
+
+ const response = await dataClient.searchEntities(defaultSearchParams);
+
+ expect(response.records[0]).toEqual(fakeEntityRecord);
+ });
});
});
diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts
index 50e500fae40f2..2cb119e6d37fe 100644
--- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts
+++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts
@@ -53,6 +53,8 @@ import {
isPromiseFulfilled,
isPromiseRejected,
} from './utils';
+import type { EntityRecord } from './types';
+import { CRITICALITY_VALUES } from '../asset_criticality/constants';
interface EntityStoreClientOpts {
logger: Logger;
@@ -407,7 +409,7 @@ export class EntityStoreDataClient {
const sort = sortField ? [{ [sortField]: sortOrder }] : undefined;
const query = filterQuery ? JSON.parse(filterQuery) : undefined;
- const response = await this.esClient.search({
+ const response = await this.esClient.search({
index,
query,
size: Math.min(perPage, MAX_SEARCH_RESPONSE_SIZE),
@@ -419,7 +421,19 @@ export class EntityStoreDataClient {
const total = typeof hits.total === 'number' ? hits.total : hits.total?.value ?? 0;
- const records = hits.hits.map((hit) => hit._source as Entity);
+ const records = hits.hits.map((hit) => {
+ const { asset, ...source } = hit._source as EntityRecord;
+
+ const assetOverwrite: Pick =
+ asset && asset.criticality !== CRITICALITY_VALUES.DELETED
+ ? { asset: { criticality: asset.criticality } }
+ : {};
+
+ return {
+ ...source,
+ ...assetOverwrite,
+ };
+ });
const inspect: InspectQuery = {
dsl: [JSON.stringify({ index, body: query }, null, 2)],
diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/types.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/types.ts
new file mode 100644
index 0000000000000..e5f1e6db36bca
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/types.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { HostEntity, UserEntity } from '../../../../common/api/entity_analytics';
+import type { CriticalityValues } from '../asset_criticality/constants';
+
+export interface HostEntityRecord extends Omit {
+ asset?: {
+ criticality: CriticalityValues;
+ };
+}
+
+export interface UserEntityRecord extends Omit {
+ asset?: {
+ criticality: CriticalityValues;
+ };
+}
+
+/**
+ * It represents the data stored in the entity store index.
+ */
+export type EntityRecord = HostEntityRecord | UserEntityRecord;
diff --git a/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx b/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx
index b7fbdab3b5982..69f3c5dd4cc28 100644
--- a/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx
+++ b/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx
@@ -12,6 +12,7 @@ import {
ALERT_SUPPRESSION_RULE_FORM,
UPGRADE_ALERT_ASSIGNMENTS,
UPGRADE_INVESTIGATION_GUIDE,
+ UPGRADE_NOTES_MANAGEMENT_USER_FILTER,
} from '@kbn/security-solution-upselling/messages';
import type {
MessageUpsellings,
@@ -132,4 +133,9 @@ export const upsellingMessages: UpsellingMessages = [
minimumLicenseRequired: 'platinum',
message: ALERT_SUPPRESSION_RULE_DETAILS,
},
+ {
+ id: 'note_management_user_filter',
+ minimumLicenseRequired: 'platinum',
+ message: UPGRADE_NOTES_MANAGEMENT_USER_FILTER('Platinum'),
+ },
];
diff --git a/x-pack/plugins/serverless_search/server/routes/indices_routes.ts b/x-pack/plugins/serverless_search/server/routes/indices_routes.ts
index 6b7ba424fde22..ed68cc77fa41a 100644
--- a/x-pack/plugins/serverless_search/server/routes/indices_routes.ts
+++ b/x-pack/plugins/serverless_search/server/routes/indices_routes.ts
@@ -107,6 +107,7 @@ export const registerIndicesRoutes = ({ logger, router }: RouteDependencies) =>
searchQuery: schema.string({
defaultValue: '',
}),
+ trackTotalHits: schema.boolean({ defaultValue: false }),
}),
params: schema.object({
index_name: schema.string(),
@@ -126,8 +127,16 @@ export const registerIndicesRoutes = ({ logger, router }: RouteDependencies) =>
const searchQuery = request.body.searchQuery;
const { page = 0, size = DEFAULT_DOCS_PER_PAGE } = request.query;
const from = page * size;
+ const trackTotalHits = request.body.trackTotalHits;
- const searchResults = await fetchSearchResults(client, indexName, searchQuery, from, size);
+ const searchResults = await fetchSearchResults(
+ client,
+ indexName,
+ searchQuery,
+ from,
+ size,
+ trackTotalHits
+ );
return response.ok({
body: {
diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy_outputs.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy_outputs.ts
new file mode 100644
index 0000000000000..74c5af6b0d811
--- /dev/null
+++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy_outputs.ts
@@ -0,0 +1,282 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import expect from '@kbn/expect';
+import { CreateAgentPolicyResponse } from '@kbn/fleet-plugin/common';
+import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
+
+export default function (providerContext: FtrProviderContext) {
+ const { getService } = providerContext;
+ const supertest = getService('supertest');
+ const esArchiver = getService('esArchiver');
+ const kibanaServer = getService('kibanaServer');
+ const fleetAndAgents = getService('fleetAndAgents');
+
+ const createOutput = async ({
+ name,
+ id,
+ type,
+ hosts,
+ }: {
+ name: string;
+ id: string;
+ type: string;
+ hosts: string[];
+ }): Promise => {
+ const res = await supertest
+ .post(`/api/fleet/outputs`)
+ .set('kbn-xsrf', 'xxxx')
+ .send({
+ id,
+ name,
+ type,
+ hosts,
+ })
+ .expect(200);
+ return res.body.item.id;
+ };
+
+ const createAgentPolicy = async (
+ name: string,
+ id: string,
+ dataOutputId?: string,
+ monitoringOutputId?: string
+ ): Promise => {
+ const res = await supertest
+ .post(`/api/fleet/agent_policies`)
+ .set('kbn-xsrf', 'xxxx')
+ .send({
+ name,
+ id,
+ namespace: 'default',
+ ...(dataOutputId ? { data_output_id: dataOutputId } : {}),
+ ...(monitoringOutputId ? { monitoring_output_id: monitoringOutputId } : {}),
+ })
+ .expect(200);
+ return res.body.item;
+ };
+
+ const createAgentPolicyWithPackagePolicy = async ({
+ name,
+ id,
+ outputId,
+ }: {
+ name: string;
+ id: string;
+ outputId?: string;
+ }): Promise => {
+ const { body: res } = await supertest
+ .post(`/api/fleet/agent_policies`)
+ .set('kbn-xsrf', 'xxxx')
+ .send({
+ name,
+ namespace: 'default',
+ id,
+ })
+ .expect(200);
+
+ const agentPolicyWithPPId = res.item.id;
+ // package policy needs to have a custom output_id
+ await supertest
+ .post(`/api/fleet/package_policies`)
+ .set('kbn-xsrf', 'xxxx')
+ .send({
+ name: 'filetest-1',
+ description: '',
+ namespace: 'default',
+ ...(outputId ? { output_id: outputId } : {}),
+ policy_id: agentPolicyWithPPId,
+ inputs: [],
+ package: {
+ name: 'filetest',
+ title: 'For File Tests',
+ version: '0.1.0',
+ },
+ })
+ .expect(200);
+ return res.item;
+ };
+
+ let output1Id = '';
+ describe('fleet_agent_policies_outputs', () => {
+ describe('POST /api/fleet/agent_policies/outputs', () => {
+ before(async () => {
+ await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
+ await kibanaServer.savedObjects.cleanStandardList();
+ await fleetAndAgents.setup();
+
+ output1Id = await createOutput({
+ name: 'Output 1',
+ id: 'logstash-output-1',
+ type: 'logstash',
+ hosts: ['test.fr:443'],
+ });
+ });
+ after(async () => {
+ await supertest
+ .delete(`/api/fleet/outputs/${output1Id}`)
+ .set('kbn-xsrf', 'xxxx')
+ .expect(200);
+ });
+
+ it('should get a list of outputs by agent policies', async () => {
+ await createAgentPolicy('Agent policy with default output', 'agent-policy-1');
+ await createAgentPolicy(
+ 'Agent policy with custom output',
+ 'agent-policy-2',
+ output1Id,
+ output1Id
+ );
+
+ const outputsPerPoliciesRes = await supertest
+ .post(`/api/fleet/agent_policies/outputs`)
+ .set('kbn-xsrf', 'xxxx')
+ .send({
+ ids: ['agent-policy-1', 'agent-policy-2'],
+ })
+ .expect(200);
+ expect(outputsPerPoliciesRes.body.items).to.eql([
+ {
+ agentPolicyId: 'agent-policy-1',
+ monitoring: {
+ output: {
+ name: 'default',
+ id: 'fleet-default-output',
+ },
+ },
+ data: {
+ output: {
+ name: 'default',
+ id: 'fleet-default-output',
+ },
+ integrations: [],
+ },
+ },
+ {
+ agentPolicyId: 'agent-policy-2',
+ monitoring: {
+ output: {
+ name: 'Output 1',
+ id: 'logstash-output-1',
+ },
+ },
+ data: {
+ output: {
+ name: 'Output 1',
+ id: 'logstash-output-1',
+ },
+ integrations: [],
+ },
+ },
+ ]);
+ // clean up policies
+ await supertest
+ .post(`/api/fleet/agent_policies/delete`)
+ .send({ agentPolicyId: 'agent-policy-1' })
+ .set('kbn-xsrf', 'xxxx')
+ .expect(200);
+ await supertest
+ .post(`/api/fleet/agent_policies/delete`)
+ .send({ agentPolicyId: 'agent-policy-2' })
+ .set('kbn-xsrf', 'xxxx')
+ .expect(200);
+ });
+ });
+
+ let output2Id = '';
+ describe('GET /api/fleet/agent_policies/{agentPolicyId}/outputs', () => {
+ before(async () => {
+ await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
+ await kibanaServer.savedObjects.cleanStandardList();
+ await fleetAndAgents.setup();
+
+ output2Id = await createOutput({
+ name: 'ES Output 1',
+ id: 'es-output-1',
+ type: 'elasticsearch',
+ hosts: ['https://test.fr:8080'],
+ });
+ });
+ after(async () => {
+ await supertest
+ .delete(`/api/fleet/outputs/${output2Id}`)
+ .set('kbn-xsrf', 'xxxx')
+ .expect(200);
+ });
+
+ it('should get the list of outputs related to an agentPolicy id', async () => {
+ await createAgentPolicy('Agent policy with ES output', 'agent-policy-custom', output2Id);
+
+ const outputsPerPoliciesRes = await supertest
+ .get(`/api/fleet/agent_policies/agent-policy-custom/outputs`)
+ .set('kbn-xsrf', 'xxxx')
+ .expect(200);
+ expect(outputsPerPoliciesRes.body.item).to.eql({
+ monitoring: {
+ output: {
+ name: 'default',
+ id: 'fleet-default-output',
+ },
+ },
+ data: {
+ output: {
+ name: 'ES Output 1',
+ id: 'es-output-1',
+ },
+ integrations: [],
+ },
+ });
+
+ await supertest
+ .post(`/api/fleet/agent_policies/delete`)
+ .send({ agentPolicyId: 'agent-policy-custom' })
+ .set('kbn-xsrf', 'xxxx')
+ .expect(200);
+ });
+
+ it('should also list the outputs set on integrations if any', async () => {
+ await createAgentPolicyWithPackagePolicy({
+ name: 'Agent Policy with package policy',
+ id: 'agent-policy-custom-2',
+ outputId: output2Id,
+ });
+
+ const outputsPerPoliciesRes = await supertest
+ .get(`/api/fleet/agent_policies/agent-policy-custom-2/outputs`)
+ .set('kbn-xsrf', 'xxxx')
+ .expect(200);
+ expect(outputsPerPoliciesRes.body.item).to.eql({
+ monitoring: {
+ output: {
+ name: 'default',
+ id: 'fleet-default-output',
+ },
+ },
+ data: {
+ output: {
+ name: 'default',
+ id: 'fleet-default-output',
+ },
+ integrations: [
+ {
+ id: 'es-output-1',
+ integrationPolicyName: 'filetest-1',
+ name: 'ES Output 1',
+ },
+ ],
+ },
+ });
+
+ await supertest
+ .post(`/api/fleet/agent_policies/delete`)
+ .send({ agentPolicyId: 'agent-policy-custom-2' })
+ .set('kbn-xsrf', 'xxxx')
+ .expect(200);
+ });
+ });
+ });
+}
diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/index.js b/x-pack/test/fleet_api_integration/apis/agent_policy/index.js
index 9ae58b0089942..b036ab9d8103d 100644
--- a/x-pack/test/fleet_api_integration/apis/agent_policy/index.js
+++ b/x-pack/test/fleet_api_integration/apis/agent_policy/index.js
@@ -13,5 +13,6 @@ export default function loadTests({ loadTestFile }) {
loadTestFile(require.resolve('./privileges'));
loadTestFile(require.resolve('./agent_policy_root_integrations'));
loadTestFile(require.resolve('./create_standalone_api_key'));
+ loadTestFile(require.resolve('./agent_policy_outputs'));
});
}
diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/field_retention_operators.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/field_retention_operators.ts
index ced0327194364..6e8f7dc3f6578 100644
--- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/field_retention_operators.ts
+++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/field_retention_operators.ts
@@ -11,6 +11,7 @@ import {
fieldOperatorToIngestProcessor,
} from '@kbn/security-solution-plugin/server/lib/entity_analytics/entity_store/field_retention_definition';
import { FtrProviderContext } from '../../../../ftr_provider_context';
+import { applyIngestProcessorToDoc } from '../utils/ingest';
export default ({ getService }: FtrProviderContext) => {
const es = getService('es');
const log = getService('log');
@@ -26,31 +27,8 @@ export default ({ getService }: FtrProviderContext) => {
docSource: any
): Promise => {
const step = fieldOperatorToIngestProcessor(operator, { enrichField: 'historical' });
- const doc = {
- _index: 'index',
- _id: 'id',
- _source: docSource,
- };
-
- const res = await es.ingest.simulate({
- pipeline: {
- description: 'test',
- processors: [step],
- },
- docs: [doc],
- });
-
- const firstDoc = res.docs?.[0];
-
- // @ts-expect-error error is not in the types
- const error = firstDoc?.error;
- if (error) {
- log.error('Full painless error below: ');
- log.error(JSON.stringify(error, null, 2));
- throw new Error('Painless error running pipelie see logs for full detail : ' + error?.type);
- }
- return firstDoc?.doc?._source;
+ return applyIngestProcessorToDoc([step], docSource, es, log);
};
describe('@ess @serverless @skipInServerlessMKI Entity store - Field Retention Pipeline Steps', () => {
@@ -90,7 +68,7 @@ export default ({ getService }: FtrProviderContext) => {
expectArraysMatchAnyOrder(resultDoc.test_field, ['foo']);
});
- it('should take from history if latest field doesnt have maxLength values', async () => {
+ it("should take from history if latest field doesn't have maxLength values", async () => {
const op: FieldRetentionOperator = {
operation: 'collect_values',
field: 'test_field',
diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/utils/ingest.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/utils/ingest.ts
new file mode 100644
index 0000000000000..24f7d759190b5
--- /dev/null
+++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/utils/ingest.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { Client } from '@elastic/elasticsearch';
+import { IngestProcessorContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
+import { ToolingLog } from '@kbn/tooling-log';
+
+export const applyIngestProcessorToDoc = async (
+ steps: IngestProcessorContainer[],
+ docSource: any,
+ es: Client,
+ log: ToolingLog
+): Promise => {
+ const doc = {
+ _index: 'index',
+ _id: 'id',
+ _source: docSource,
+ };
+
+ const res = await es.ingest.simulate({
+ pipeline: {
+ description: 'test',
+ processors: steps,
+ },
+ docs: [doc],
+ });
+
+ const firstDoc = res.docs?.[0];
+
+ // @ts-expect-error error is not in the types
+ const error = firstDoc?.error;
+ if (error) {
+ log.error('Full painless error below: ');
+ log.error(JSON.stringify(error, null, 2));
+ throw new Error('Painless error running pipeline see logs for full detail : ' + error?.type);
+ }
+
+ return firstDoc?.doc?._source;
+};
diff --git a/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts b/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts
index 161be50cae410..656c75fb308bc 100644
--- a/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts
+++ b/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts
@@ -42,6 +42,15 @@ export function SvlSearchIndexDetailPageProvider({ getService }: FtrProviderCont
await quickStatsDocumentElem.click();
expect(await quickStatsDocumentElem.getVisibleText()).to.contain('Index Size\n0b');
},
+
+ async expectQuickStatsToHaveDocumentCount(count: number) {
+ const quickStatsElem = await testSubjects.find('quickStats');
+ const quickStatsDocumentElem = await quickStatsElem.findByTestSubject(
+ 'QuickStatsDocumentCount'
+ );
+ expect(await quickStatsDocumentElem.getVisibleText()).to.contain(`Document count\n${count}`);
+ },
+
async expectQuickStatsAIMappings() {
await testSubjects.existOrFail('quickStats', { timeout: 2000 });
const quickStatsElem = await testSubjects.find('quickStats');
diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_cell_renderers.ts b/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_cell_renderers.ts
index 0cf8aeedd257a..b8503e0f8dcab 100644
--- a/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_cell_renderers.ts
+++ b/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_cell_renderers.ts
@@ -105,8 +105,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const firstCell = await dataGrid.getCellElementExcludingControlColumns(0, 0);
const lastCell = await dataGrid.getCellElementExcludingControlColumns(2, 0);
- const firstServiceNameCell = await firstCell.findByTestSubject('serviceNameCell-java');
- const lastServiceNameCell = await lastCell.findByTestSubject('serviceNameCell-unknown');
+ const firstServiceNameCell = await firstCell.findByTestSubject(
+ 'dataTableCellActionsPopover_service.name'
+ );
+ const lastServiceNameCell = await lastCell.findByTestSubject(
+ 'dataTableCellActionsPopover_service.name'
+ );
expect(await firstServiceNameCell.getVisibleText()).to.be('product');
expect(await lastServiceNameCell.getVisibleText()).to.be('accounting');
});
@@ -130,7 +134,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await retry.try(async () => {
const firstCell = await dataGrid.getCellElementExcludingControlColumns(0, 0);
expect(await firstCell.getVisibleText()).to.be('product');
- await testSubjects.missingOrFail('*serviceNameCell*');
+ await testSubjects.missingOrFail('dataTableCellActionsPopover_service.name');
});
});
});
@@ -277,8 +281,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await retry.try(async () => {
firstCell = await dataGrid.getCellElementExcludingControlColumns(0, 1);
lastCell = await dataGrid.getCellElementExcludingControlColumns(2, 1);
- const firstServiceNameCell = await firstCell.findByTestSubject('serviceNameCell-java');
- const lastServiceNameCell = await lastCell.findByTestSubject('serviceNameCell-unknown');
+ const firstServiceNameCell = await firstCell.findByTestSubject(
+ 'dataTableCellActionsPopover_service.name'
+ );
+ const lastServiceNameCell = await lastCell.findByTestSubject(
+ 'dataTableCellActionsPopover_service.name'
+ );
expect(await firstServiceNameCell.getVisibleText()).to.be('product');
expect(await lastServiceNameCell.getVisibleText()).to.be('accounting');
});
@@ -308,7 +316,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await firstCell.getVisibleText()).to.be('product');
expect(await lastCell.getVisibleText()).to.be('accounting');
- await testSubjects.missingOrFail('*serviceNameCell*');
+ await testSubjects.missingOrFail('dataTableCellActionsPopover_service.name');
});
});
});
diff --git a/x-pack/test_serverless/functional/test_suites/common/platform_security/navigation/management_nav_cards.ts b/x-pack/test_serverless/functional/test_suites/common/platform_security/navigation/management_nav_cards.ts
index 1dfa1a5dff79c..0f175f4c812a2 100644
--- a/x-pack/test_serverless/functional/test_suites/common/platform_security/navigation/management_nav_cards.ts
+++ b/x-pack/test_serverless/functional/test_suites/common/platform_security/navigation/management_nav_cards.ts
@@ -56,13 +56,18 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const url = await browser.getCurrentUrl();
expect(url).to.contain('/management/security/roles');
});
+ });
+
+ describe('organization members', function () {
+ // Observability will not support custom roles
+ // Cannot test cloud link on MKI (will redirect to login)
+ this.tags(['skipSvlOblt', 'skipMKI']);
it('displays the Organization members management card, and will navigate to the cloud organization URL', async () => {
await pageObjects.svlManagementPage.assertOrgMembersManagementCardExists();
await pageObjects.svlManagementPage.clickOrgMembersManagementCard();
const url = await browser.getCurrentUrl();
- // `--xpack.cloud.organization_url: '/account/members'`,
expect(url).to.contain('/account/members');
});
});
@@ -102,7 +107,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
describe('Organization members', function () {
- this.tags('skipSvlOblt'); // Observability will not support custom roles
+ // Observability will not support custom roles
+ // Cannot test cloud link on MKI (will redirect to login)
+ this.tags(['skipSvlOblt', 'skipMKI']);
+
it('displays the organization members management card, and will navigate to the cloud organization URL', async () => {
// The org members nav card is always visible because there is no way to check if a user has approprite privileges
await pageObjects.svlManagementPage.assertOrgMembersManagementCardExists();
diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selector.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selector.ts
index f571fe4e0e462..ba12ebc153ca8 100644
--- a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selector.ts
+++ b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selector.ts
@@ -214,25 +214,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should sort the integrations list by the clicked sorting option', async () => {
// Test ascending order
- await PageObjects.observabilityLogsExplorer.clickSortButtonBy('asc');
-
await retry.try(async () => {
+ await PageObjects.observabilityLogsExplorer.clickSortButtonBy('desc');
+ await PageObjects.observabilityLogsExplorer.clickSortButtonBy('asc');
const { integrations } = await PageObjects.observabilityLogsExplorer.getIntegrations();
expect(integrations).to.eql(initialPackagesTexts);
});
// Test descending order
- await PageObjects.observabilityLogsExplorer.clickSortButtonBy('desc');
-
await retry.try(async () => {
+ await PageObjects.observabilityLogsExplorer.clickSortButtonBy('asc');
+ await PageObjects.observabilityLogsExplorer.clickSortButtonBy('desc');
const { integrations } = await PageObjects.observabilityLogsExplorer.getIntegrations();
expect(integrations).to.eql(initialPackagesTexts.slice().reverse());
});
// Test back ascending order
- await PageObjects.observabilityLogsExplorer.clickSortButtonBy('asc');
-
await retry.try(async () => {
+ await PageObjects.observabilityLogsExplorer.clickSortButtonBy('desc');
+ await PageObjects.observabilityLogsExplorer.clickSortButtonBy('asc');
const { integrations } = await PageObjects.observabilityLogsExplorer.getIntegrations();
expect(integrations).to.eql(initialPackagesTexts);
});
diff --git a/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts b/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts
index 9e6a46e76d8f0..e7831d1f91b3a 100644
--- a/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts
+++ b/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts
@@ -130,6 +130,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
it('should have index documents', async () => {
await pageObjects.svlSearchIndexDetailPage.expectHasIndexDocuments();
});
+ it('should have one document in quick stats', async () => {
+ await pageObjects.svlSearchIndexDetailPage.expectQuickStatsToHaveDocumentCount(1);
+ });
it('should have with data tabs', async () => {
await pageObjects.svlSearchIndexDetailPage.expectWithDataTabsExists();
await pageObjects.svlSearchIndexDetailPage.expectShouldDefaultToDataTab();
@@ -148,6 +151,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await pageObjects.svlSearchIndexDetailPage.withDataChangeTabs('dataTab');
await pageObjects.svlSearchIndexDetailPage.clickFirstDocumentDeleteAction();
await pageObjects.svlSearchIndexDetailPage.expectAddDocumentCodeExamples();
+ await pageObjects.svlSearchIndexDetailPage.expectQuickStatsToHaveDocumentCount(0);
});
});
diff --git a/yarn.lock b/yarn.lock
index 0e0d6afb677c2..b5b1294c39f7e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5671,6 +5671,10 @@
version "0.0.0"
uid ""
+"@kbn/manifest@link:packages/kbn-manifest":
+ version "0.0.0"
+ uid ""
+
"@kbn/mapbox-gl@link:packages/kbn-mapbox-gl":
version "0.0.0"
uid ""