diff --git a/oas_docs/bundle.json b/oas_docs/bundle.json index e52362ff13a6a..8fdffea9bac41 100644 --- a/oas_docs/bundle.json +++ b/oas_docs/bundle.json @@ -26150,7 +26150,7 @@ }, "/api/fleet/logstash_api_keys": { "post": { - "description": "Generate Logstash API keyy", + "description": "Generate Logstash API key", "operationId": "%2Fapi%2Ffleet%2Flogstash_api_keys#0", "parameters": [ { @@ -40259,6 +40259,22 @@ "item": { "additionalProperties": false, "properties": { + "delete_unenrolled_agents": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "is_preconfigured": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "is_preconfigured" + ], + "type": "object" + }, "fleet_server_hosts": { "items": { "type": "string" @@ -40305,7 +40321,6 @@ } }, "required": [ - "prerelease_integrations_enabled", "id" ], "type": "object" @@ -40404,6 +40419,22 @@ "additional_yaml_config": { "type": "string" }, + "delete_unenrolled_agents": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "is_preconfigured": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "is_preconfigured" + ], + "type": "object" + }, "fleet_server_hosts": { "items": { "format": "uri", @@ -40443,6 +40474,22 @@ "item": { "additionalProperties": false, "properties": { + "delete_unenrolled_agents": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "is_preconfigured": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "is_preconfigured" + ], + "type": "object" + }, "fleet_server_hosts": { "items": { "type": "string" @@ -40489,7 +40536,6 @@ } }, "required": [ - "prerelease_integrations_enabled", "id" ], "type": "object" diff --git a/oas_docs/bundle.serverless.json b/oas_docs/bundle.serverless.json index 531ab412ce1bf..4719fcb479bb5 100644 --- a/oas_docs/bundle.serverless.json +++ b/oas_docs/bundle.serverless.json @@ -26150,7 +26150,7 @@ }, "/api/fleet/logstash_api_keys": { "post": { - "description": "Generate Logstash API keyy", + "description": "Generate Logstash API key", "operationId": "%2Fapi%2Ffleet%2Flogstash_api_keys#0", "parameters": [ { @@ -40259,6 +40259,22 @@ "item": { "additionalProperties": false, "properties": { + "delete_unenrolled_agents": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "is_preconfigured": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "is_preconfigured" + ], + "type": "object" + }, "fleet_server_hosts": { "items": { "type": "string" @@ -40305,7 +40321,6 @@ } }, "required": [ - "prerelease_integrations_enabled", "id" ], "type": "object" @@ -40404,6 +40419,22 @@ "additional_yaml_config": { "type": "string" }, + "delete_unenrolled_agents": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "is_preconfigured": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "is_preconfigured" + ], + "type": "object" + }, "fleet_server_hosts": { "items": { "format": "uri", @@ -40443,6 +40474,22 @@ "item": { "additionalProperties": false, "properties": { + "delete_unenrolled_agents": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "is_preconfigured": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "is_preconfigured" + ], + "type": "object" + }, "fleet_server_hosts": { "items": { "type": "string" @@ -40489,7 +40536,6 @@ } }, "required": [ - "prerelease_integrations_enabled", "id" ], "type": "object" diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml index dc95e1014073a..aad5256524f4a 100644 --- a/oas_docs/output/kibana.serverless.staging.yaml +++ b/oas_docs/output/kibana.serverless.staging.yaml @@ -23652,7 +23652,7 @@ paths: - Elastic Agent policies /api/fleet/logstash_api_keys: post: - description: Generate Logstash API keyy + description: Generate Logstash API key operationId: '%2Fapi%2Ffleet%2Flogstash_api_keys#0' parameters: - description: The version of the API to use @@ -33283,6 +33283,17 @@ paths: additionalProperties: false type: object properties: + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: type: string @@ -33314,7 +33325,6 @@ paths: version: type: string required: - - prerelease_integrations_enabled - id required: - item @@ -33376,6 +33386,17 @@ paths: properties: additional_yaml_config: type: string + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: format: uri @@ -33404,6 +33425,17 @@ paths: additionalProperties: false type: object properties: + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: type: string @@ -33435,7 +33467,6 @@ paths: version: type: string required: - - prerelease_integrations_enabled - id required: - item diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index dc95e1014073a..aad5256524f4a 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -23652,7 +23652,7 @@ paths: - Elastic Agent policies /api/fleet/logstash_api_keys: post: - description: Generate Logstash API keyy + description: Generate Logstash API key operationId: '%2Fapi%2Ffleet%2Flogstash_api_keys#0' parameters: - description: The version of the API to use @@ -33283,6 +33283,17 @@ paths: additionalProperties: false type: object properties: + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: type: string @@ -33314,7 +33325,6 @@ paths: version: type: string required: - - prerelease_integrations_enabled - id required: - item @@ -33376,6 +33386,17 @@ paths: properties: additional_yaml_config: type: string + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: format: uri @@ -33404,6 +33425,17 @@ paths: additionalProperties: false type: object properties: + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: type: string @@ -33435,7 +33467,6 @@ paths: version: type: string required: - - prerelease_integrations_enabled - id required: - item diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml index 222a1c5d162b8..d8310ef797387 100644 --- a/oas_docs/output/kibana.staging.yaml +++ b/oas_docs/output/kibana.staging.yaml @@ -27081,7 +27081,7 @@ paths: - Elastic Agent policies /api/fleet/logstash_api_keys: post: - description: Generate Logstash API keyy + description: Generate Logstash API key operationId: '%2Fapi%2Ffleet%2Flogstash_api_keys#0' parameters: - description: The version of the API to use @@ -36712,6 +36712,17 @@ paths: additionalProperties: false type: object properties: + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: type: string @@ -36743,7 +36754,6 @@ paths: version: type: string required: - - prerelease_integrations_enabled - id required: - item @@ -36805,6 +36815,17 @@ paths: properties: additional_yaml_config: type: string + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: format: uri @@ -36833,6 +36854,17 @@ paths: additionalProperties: false type: object properties: + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: type: string @@ -36864,7 +36896,6 @@ paths: version: type: string required: - - prerelease_integrations_enabled - id required: - item diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 222a1c5d162b8..d8310ef797387 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -27081,7 +27081,7 @@ paths: - Elastic Agent policies /api/fleet/logstash_api_keys: post: - description: Generate Logstash API keyy + description: Generate Logstash API key operationId: '%2Fapi%2Ffleet%2Flogstash_api_keys#0' parameters: - description: The version of the API to use @@ -36712,6 +36712,17 @@ paths: additionalProperties: false type: object properties: + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: type: string @@ -36743,7 +36754,6 @@ paths: version: type: string required: - - prerelease_integrations_enabled - id required: - item @@ -36805,6 +36815,17 @@ paths: properties: additional_yaml_config: type: string + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: format: uri @@ -36833,6 +36854,17 @@ paths: additionalProperties: false type: object properties: + delete_unenrolled_agents: + additionalProperties: false + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: + - enabled + - is_preconfigured fleet_server_hosts: items: type: string @@ -36864,7 +36896,6 @@ paths: version: type: string required: - - prerelease_integrations_enabled - id required: - item diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 3bd084fc677a6..fa55bd4800c8b 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -233,9 +233,7 @@ "payload.connector.type", "type" ], - "cloud-security-posture-settings": [ - "rules" - ], + "cloud-security-posture-settings": [], "config": [ "buildNum" ], @@ -718,6 +716,9 @@ "vars" ], "ingest_manager_settings": [ + "delete_unenrolled_agents", + "delete_unenrolled_agents.enabled", + "delete_unenrolled_agents.is_preconfigured", "fleet_server_hosts", "has_seen_add_data_notice", "output_secret_storage_requirements_met", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 9eb4375f082da..9f94d36af50f7 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -2373,6 +2373,18 @@ }, "ingest_manager_settings": { "properties": { + "delete_unenrolled_agents": { + "properties": { + "enabled": { + "index": false, + "type": "boolean" + }, + "is_preconfigured": { + "index": false, + "type": "boolean" + } + } + }, "fleet_server_hosts": { "type": "keyword" }, diff --git a/packages/kbn-check-mappings-update-cli/src/compatibility/check_incompatible_mappings.ts b/packages/kbn-check-mappings-update-cli/src/compatibility/check_incompatible_mappings.ts index 1355e8547250e..ce9e2acef8d4c 100644 --- a/packages/kbn-check-mappings-update-cli/src/compatibility/check_incompatible_mappings.ts +++ b/packages/kbn-check-mappings-update-cli/src/compatibility/check_incompatible_mappings.ts @@ -54,5 +54,7 @@ export async function checkIncompatibleMappings({ throw createFailError( `Only mappings changes that are compatible with current mappings are allowed. Consider reaching out to the Kibana core team if you are stuck.` ); + } finally { + await esClient.indices.delete({ index: TEST_INDEX_NAME }).catch(() => {}); } } diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index d6cfcae558b2c..c662bf9e5f753 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -124,7 +124,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ingest-download-sources": "279a68147e62e4d8858c09ad1cf03bd5551ce58d", "ingest-outputs": "daafff49255ab700e07491376fe89f04fc998b91", "ingest-package-policies": "53a94064674835fdb35e5186233bcd7052eabd22", - "ingest_manager_settings": "e794576a05d19dd5306a1e23cbb82c09bffabd65", + "ingest_manager_settings": "111a616eb72627c002029c19feb9e6c439a10505", "inventory-view": "b8683c8e352a286b4aca1ab21003115a4800af83", "kql-telemetry": "93c1d16c1a0dfca9c8842062cf5ef8f62ae401ad", "legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8", diff --git a/x-pack/plugins/fleet/common/types/models/settings.ts b/x-pack/plugins/fleet/common/types/models/settings.ts index 9a5166e41df96..a63211ef14c55 100644 --- a/x-pack/plugins/fleet/common/types/models/settings.ts +++ b/x-pack/plugins/fleet/common/types/models/settings.ts @@ -8,7 +8,7 @@ export interface BaseSettings { has_seen_add_data_notice?: boolean; fleet_server_hosts?: string[]; - prerelease_integrations_enabled: boolean; + prerelease_integrations_enabled?: boolean; } export interface Settings extends BaseSettings { @@ -19,4 +19,8 @@ export interface Settings extends BaseSettings { output_secret_storage_requirements_met?: boolean; use_space_awareness_migration_status?: 'pending' | 'success' | 'error'; use_space_awareness_migration_started_at?: string | null; + delete_unenrolled_agents?: { + enabled: boolean; + is_preconfigured: boolean; + }; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/advanced_section.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/advanced_section.tsx new file mode 100644 index 0000000000000..c4e9478e0762e --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/advanced_section.tsx @@ -0,0 +1,143 @@ +/* + * 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, { useCallback, useEffect } from 'react'; + +import { + EuiTitle, + EuiLink, + EuiSpacer, + EuiDescribedFormGroup, + EuiSwitch, + EuiForm, + EuiFormRow, + EuiToolTip, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { i18n } from '@kbn/i18n'; + +import { + useAuthz, + useGetSettings, + usePutSettingsMutation, + useStartServices, +} from '../../../../hooks'; + +export const AdvancedSection: React.FunctionComponent<{}> = ({}) => { + const authz = useAuthz(); + const { docLinks, notifications } = useStartServices(); + const deleteUnenrolledAgents = + useGetSettings().data?.item?.delete_unenrolled_agents?.enabled ?? false; + const isPreconfigured = + useGetSettings().data?.item?.delete_unenrolled_agents?.is_preconfigured ?? false; + const [deleteUnenrolledAgentsChecked, setDeleteUnenrolledAgentsChecked] = + React.useState<boolean>(deleteUnenrolledAgents); + const { mutateAsync: mutateSettingsAsync } = usePutSettingsMutation(); + + useEffect(() => { + if (deleteUnenrolledAgents) { + setDeleteUnenrolledAgentsChecked(deleteUnenrolledAgents); + } + }, [deleteUnenrolledAgents]); + + const updateSettings = useCallback( + async (deleteFlag: boolean) => { + try { + setDeleteUnenrolledAgentsChecked(deleteFlag); + const res = await mutateSettingsAsync({ + delete_unenrolled_agents: { + enabled: deleteFlag, + is_preconfigured: false, + }, + }); + + if (res.error) { + throw res.error; + } + } catch (error) { + setDeleteUnenrolledAgentsChecked(!deleteFlag); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.errorUpdatingSettings', { + defaultMessage: 'Error updating settings', + }), + }); + } + }, + [mutateSettingsAsync, notifications.toasts] + ); + + return ( + <> + <EuiTitle size="s"> + <h4 data-test-subj="advancedHeader"> + <FormattedMessage + id="xpack.fleet.settings.advancedSectionTitle" + defaultMessage="Advanced Settings" + /> + </h4> + </EuiTitle> + <EuiSpacer size="m" /> + <EuiForm component="form"> + <EuiDescribedFormGroup + title={ + <h3> + <FormattedMessage + id="xpack.fleet.settings.deleteUnenrolledAgentsLabel" + defaultMessage="Delete unenrolled agents" + /> + </h3> + } + description={ + <p> + <FormattedMessage + id="xpack.fleet.settings.advancedSection.switchLabel" + defaultMessage="Switching on this setting will enable auto deletion of unenrolled agents. For more information see our {docLink}." + values={{ + docLink: ( + <EuiLink target="_blank" external href={docLinks.links.fleet.settings}> + <FormattedMessage + id="xpack.fleet.settings.advancedSection.link" + defaultMessage="docs" + /> + </EuiLink> + ), + }} + /> + </p> + } + > + <EuiFormRow label=""> + <EuiToolTip + content={ + isPreconfigured + ? i18n.translate('xpack.fleet.settings.advancedSection.preconfiguredTitle', { + defaultMessage: 'This setting is preconfigured and cannot be updated.', + }) + : undefined + } + > + <EuiSwitch + label={ + <FormattedMessage + id="xpack.fleet.settings.deleteUnenrolledAgentsLabel" + defaultMessage="Delete unenrolled agents" + /> + } + checked={deleteUnenrolledAgentsChecked} + onChange={(e) => updateSettings(e.target.checked)} + disabled={!authz.fleet.allSettings || isPreconfigured} + /> + </EuiToolTip> + </EuiFormRow> + </EuiDescribedFormGroup> + </EuiForm> + + <EuiSpacer size="m" /> + </> + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/index.tsx index 809639ecae692..cfeb76ba0d240 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/index.tsx @@ -14,6 +14,7 @@ import { FleetServerHostsSection } from './fleet_server_hosts_section'; import { OutputSection } from './output_section'; import { AgentBinarySection } from './agent_binary_section'; import { FleetProxiesSection } from './fleet_proxies_section'; +import { AdvancedSection } from './advanced_section'; export interface SettingsPageProps { outputs: Output[]; @@ -52,6 +53,8 @@ export const SettingsPage: React.FunctionComponent<SettingsPageProps> = ({ /> <EuiSpacer size="m" /> <FleetProxiesSection proxies={proxies} deleteFleetProxy={deleteFleetProxy} /> + <EuiSpacer size="m" /> + <AdvancedSection /> </> ); }; diff --git a/x-pack/plugins/fleet/server/config.ts b/x-pack/plugins/fleet/server/config.ts index 6df693096f7c6..1797c30d15f4d 100644 --- a/x-pack/plugins/fleet/server/config.ts +++ b/x-pack/plugins/fleet/server/config.ts @@ -130,6 +130,7 @@ export const config: PluginConfigDescriptor = { schema: schema.object( { isAirGapped: schema.maybe(schema.boolean({ defaultValue: false })), + enableDeleteUnenrolledAgents: schema.maybe(schema.boolean({ defaultValue: false })), registryUrl: schema.maybe(schema.uri({ scheme: ['http', 'https'] })), registryProxyUrl: schema.maybe(schema.uri({ scheme: ['http', 'https'] })), agents: schema.object({ diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index 09b387e7a5cee..6782b8122a552 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -100,6 +100,7 @@ export class OutputUnauthorizedError extends FleetError {} export class OutputInvalidError extends FleetError {} export class OutputLicenceError extends FleetError {} export class DownloadSourceError extends FleetError {} +export class DeleteUnenrolledAgentsPreconfiguredError extends FleetError {} // Not found errors export class AgentNotFoundError extends FleetNotFoundError {} diff --git a/x-pack/plugins/fleet/server/mocks/index.ts b/x-pack/plugins/fleet/server/mocks/index.ts index 17f85cd252c2b..43b113899072e 100644 --- a/x-pack/plugins/fleet/server/mocks/index.ts +++ b/x-pack/plugins/fleet/server/mocks/index.ts @@ -139,6 +139,7 @@ export const createAppContextStartContractMock = ( } : {}), unenrollInactiveAgentsTask: {} as any, + deleteUnenrolledAgentsTask: {} as any, }; }; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index c767424cef36a..ced8da63e9703 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -142,6 +142,7 @@ import { fetchAgentMetrics } from './services/metrics/fetch_agent_metrics'; import { registerIntegrationFieldsExtractor } from './services/register_integration_fields_extractor'; import { registerUpgradeManagedPackagePoliciesTask } from './services/setup/managed_package_policies'; import { registerDeployAgentPoliciesTask } from './services/agent_policies/deploy_agent_policies_task'; +import { DeleteUnenrolledAgentsTask } from './tasks/delete_unenrolled_agents_task'; export interface FleetSetupDeps { security: SecurityPluginSetup; @@ -192,6 +193,7 @@ export interface FleetAppContext { auditLogger?: AuditLogger; uninstallTokenService: UninstallTokenServiceInterface; unenrollInactiveAgentsTask: UnenrollInactiveAgentsTask; + deleteUnenrolledAgentsTask: DeleteUnenrolledAgentsTask; taskManagerStart?: TaskManagerStartContract; } @@ -284,6 +286,7 @@ export class FleetPlugin private checkDeletedFilesTask?: CheckDeletedFilesTask; private fleetMetricsTask?: FleetMetricsTask; private unenrollInactiveAgentsTask?: UnenrollInactiveAgentsTask; + private deleteUnenrolledAgentsTask?: DeleteUnenrolledAgentsTask; private agentService?: AgentService; private packageService?: PackageService; @@ -628,6 +631,11 @@ export class FleetPlugin taskManager: deps.taskManager, logFactory: this.initializerContext.logger, }); + this.deleteUnenrolledAgentsTask = new DeleteUnenrolledAgentsTask({ + core, + taskManager: deps.taskManager, + logFactory: this.initializerContext.logger, + }); // Register fields metadata extractor registerIntegrationFieldsExtractor({ core, fieldsMetadata: deps.fieldsMetadata }); @@ -674,6 +682,7 @@ export class FleetPlugin messageSigningService, uninstallTokenService, unenrollInactiveAgentsTask: this.unenrollInactiveAgentsTask!, + deleteUnenrolledAgentsTask: this.deleteUnenrolledAgentsTask!, taskManagerStart: plugins.taskManager, }); licenseService.start(plugins.licensing.license$); @@ -682,6 +691,7 @@ export class FleetPlugin this.fleetUsageSender?.start(plugins.taskManager).catch(() => {}); this.checkDeletedFilesTask?.start({ taskManager: plugins.taskManager }).catch(() => {}); this.unenrollInactiveAgentsTask?.start({ taskManager: plugins.taskManager }).catch(() => {}); + this.deleteUnenrolledAgentsTask?.start({ taskManager: plugins.taskManager }).catch(() => {}); startFleetUsageLogger(plugins.taskManager).catch(() => {}); this.fleetMetricsTask ?.start(plugins.taskManager, core.elasticsearch.client.asInternalUser) diff --git a/x-pack/plugins/fleet/server/routes/output/index.ts b/x-pack/plugins/fleet/server/routes/output/index.ts index a90735f053208..c9d5b6acdd7d3 100644 --- a/x-pack/plugins/fleet/server/routes/output/index.ts +++ b/x-pack/plugins/fleet/server/routes/output/index.ts @@ -189,7 +189,7 @@ export const registerRoutes = (router: FleetAuthzRouter) => { fleetAuthz: { fleet: { allSettings: true }, }, - description: 'Generate Logstash API keyy', + description: 'Generate Logstash API key', options: { tags: ['oas-tag:Fleet outputs'], }, diff --git a/x-pack/plugins/fleet/server/routes/settings/settings_handler.test.ts b/x-pack/plugins/fleet/server/routes/settings/settings_handler.test.ts index 0b52c44cde269..73641bfaed71a 100644 --- a/x-pack/plugins/fleet/server/routes/settings/settings_handler.test.ts +++ b/x-pack/plugins/fleet/server/routes/settings/settings_handler.test.ts @@ -31,6 +31,10 @@ jest.mock('../../services', () => ({ has_seen_add_data_notice: true, fleet_server_hosts: ['http://localhost:8220'], prerelease_integrations_enabled: true, + delete_unenrolled_agents: { + enabled: true, + is_preconfigured: false, + }, }), }, appContextService: { @@ -74,6 +78,10 @@ describe('SettingsHandler', () => { has_seen_add_data_notice: true, fleet_server_hosts: ['http://localhost:8220'], prerelease_integrations_enabled: true, + delete_unenrolled_agents: { + enabled: true, + is_preconfigured: false, + }, }, }; expect(response.ok).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 0eb6c86df01e2..ffb9381f8b30c 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -161,6 +161,12 @@ export const getSavedObjectTypes = ( output_secret_storage_requirements_met: { type: 'boolean' }, use_space_awareness_migration_status: { type: 'keyword', index: false }, use_space_awareness_migration_started_at: { type: 'date', index: false }, + delete_unenrolled_agents: { + properties: { + enabled: { type: 'boolean', index: false }, + is_preconfigured: { type: 'boolean', index: false }, + }, + }, }, }, migrations: { @@ -181,6 +187,21 @@ export const getSavedObjectTypes = ( }, ], }, + 3: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + delete_unenrolled_agents: { + properties: { + enabled: { type: 'boolean', index: false }, + is_preconfigured: { type: 'boolean', index: false }, + }, + }, + }, + }, + ], + }, }, }, [LEGACY_AGENT_POLICY_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get_prerelease_setting.ts b/x-pack/plugins/fleet/server/services/epm/packages/get_prerelease_setting.ts index df4b47d13ef2f..48783fa7a6b54 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get_prerelease_setting.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get_prerelease_setting.ts @@ -13,7 +13,7 @@ import { getSettings } from '../../settings'; export async function getPrereleaseFromSettings( savedObjectsClient: SavedObjectsClientContract ): Promise<boolean> { - let prerelease: boolean = false; + let prerelease: boolean | undefined = false; try { ({ prerelease_integrations_enabled: prerelease } = await getSettings(savedObjectsClient)); } catch (err) { @@ -21,5 +21,5 @@ export async function getPrereleaseFromSettings( .getLogger() .warn('Error while trying to load prerelease flag from settings, defaulting to false', err); } - return prerelease; + return prerelease ?? false; } diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/delete_unenrolled_agent_setting.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration/delete_unenrolled_agent_setting.test.ts new file mode 100644 index 0000000000000..aa1a7573f225b --- /dev/null +++ b/x-pack/plugins/fleet/server/services/preconfiguration/delete_unenrolled_agent_setting.test.ts @@ -0,0 +1,71 @@ +/* + * 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 { settingsService } from '..'; + +import { ensureDeleteUnenrolledAgentsSetting } from './delete_unenrolled_agent_setting'; + +jest.mock('..', () => ({ + settingsService: { + getSettingsOrUndefined: jest.fn(), + saveSettings: jest.fn(), + }, +})); + +describe('delete_unenrolled_agent_setting', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should update settings with delete_unenrolled_agents enabled', async () => { + await ensureDeleteUnenrolledAgentsSetting({} as any, true); + + expect(settingsService.saveSettings).toHaveBeenCalledWith( + expect.anything(), + { delete_unenrolled_agents: { enabled: true, is_preconfigured: true } }, + { fromSetup: true } + ); + }); + + it('should update settings with delete_unenrolled_agents disabled', async () => { + await ensureDeleteUnenrolledAgentsSetting({} as any, false); + + expect(settingsService.saveSettings).toHaveBeenCalledWith( + expect.anything(), + { delete_unenrolled_agents: { enabled: false, is_preconfigured: true } }, + { fromSetup: true } + ); + }); + + it('should update settings when previously preconfigured', async () => { + (settingsService.getSettingsOrUndefined as jest.Mock).mockResolvedValue({ + delete_unenrolled_agents: { + enabled: false, + is_preconfigured: true, + }, + }); + await ensureDeleteUnenrolledAgentsSetting({} as any); + + expect(settingsService.saveSettings).toHaveBeenCalledWith( + expect.anything(), + { delete_unenrolled_agents: { enabled: false, is_preconfigured: false } }, + { fromSetup: true } + ); + }); + + it('should not update settings when previously not preconfigured', async () => { + (settingsService.getSettingsOrUndefined as jest.Mock).mockResolvedValue({ + delete_unenrolled_agents: { + enabled: false, + is_preconfigured: false, + }, + }); + await ensureDeleteUnenrolledAgentsSetting({} as any); + + expect(settingsService.saveSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/delete_unenrolled_agent_setting.ts b/x-pack/plugins/fleet/server/services/preconfiguration/delete_unenrolled_agent_setting.ts new file mode 100644 index 0000000000000..ba54e1c5146b2 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/preconfiguration/delete_unenrolled_agent_setting.ts @@ -0,0 +1,39 @@ +/* + * 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 { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; + +import { settingsService } from '..'; +import type { FleetConfigType } from '../../config'; + +export function getPreconfiguredDeleteUnenrolledAgentsSettingFromConfig( + config?: FleetConfigType +): boolean | undefined { + return config.enableDeleteUnenrolledAgents; +} + +export async function ensureDeleteUnenrolledAgentsSetting( + soClient: SavedObjectsClientContract, + enableDeleteUnenrolledAgents?: boolean +) { + if (enableDeleteUnenrolledAgents === undefined) { + const settings = await settingsService.getSettingsOrUndefined(soClient); + if (!settings?.delete_unenrolled_agents?.is_preconfigured) { + return; + } + } + await settingsService.saveSettings( + soClient, + { + delete_unenrolled_agents: { + enabled: !!enableDeleteUnenrolledAgents, + is_preconfigured: enableDeleteUnenrolledAgents !== undefined, + }, + }, + { fromSetup: true } + ); +} diff --git a/x-pack/plugins/fleet/server/services/settings.test.ts b/x-pack/plugins/fleet/server/services/settings.test.ts index 33926b0ec12f7..92fb85a335775 100644 --- a/x-pack/plugins/fleet/server/services/settings.test.ts +++ b/x-pack/plugins/fleet/server/services/settings.test.ts @@ -14,6 +14,8 @@ import { GLOBAL_SETTINGS_ID, GLOBAL_SETTINGS_SAVED_OBJECT_TYPE } from '../../com import type { Settings } from '../types'; +import { DeleteUnenrolledAgentsPreconfiguredError } from '../errors'; + import { appContextService } from './app_context'; import { getSettings, saveSettings, settingsSetup } from './settings'; import { auditLoggingService } from './audit_logging'; @@ -225,4 +227,119 @@ describe('saveSettings', () => { }); }); }); + + it('should allow updating preconfigured setting if called from setup', async () => { + const soClient = savedObjectsClientMock.create(); + + const newData: Partial<Omit<Settings, 'id'>> = { + delete_unenrolled_agents: { + enabled: true, + is_preconfigured: true, + }, + }; + + soClient.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: GLOBAL_SETTINGS_ID, + attributes: { + delete_unenrolled_agents: { + enabled: false, + is_preconfigured: true, + }, + }, + references: [], + type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + score: 0, + }, + ], + page: 1, + per_page: 10, + total: 1, + }); + mockListFleetServerHosts.mockResolvedValueOnce({ + items: [ + { + id: 'fleet-server-host', + name: 'Fleet Server Host', + is_default: true, + is_preconfigured: false, + host_urls: ['http://localhost:8220'], + }, + ], + page: 1, + perPage: 10, + total: 1, + }); + + soClient.update.mockResolvedValueOnce({ + id: GLOBAL_SETTINGS_ID, + attributes: {}, + references: [], + type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + }); + + await saveSettings(soClient, newData, { fromSetup: true }); + + expect(soClient.update).toHaveBeenCalled(); + }); + + it('should not allow updating preconfigured setting if not called from setup', async () => { + const soClient = savedObjectsClientMock.create(); + + const newData: Partial<Omit<Settings, 'id'>> = { + delete_unenrolled_agents: { + enabled: true, + is_preconfigured: true, + }, + }; + + soClient.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: GLOBAL_SETTINGS_ID, + attributes: { + delete_unenrolled_agents: { + enabled: false, + is_preconfigured: true, + }, + }, + references: [], + type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + score: 0, + }, + ], + page: 1, + per_page: 10, + total: 1, + }); + mockListFleetServerHosts.mockResolvedValueOnce({ + items: [ + { + id: 'fleet-server-host', + name: 'Fleet Server Host', + is_default: true, + is_preconfigured: false, + host_urls: ['http://localhost:8220'], + }, + ], + page: 1, + perPage: 10, + total: 1, + }); + + soClient.update.mockResolvedValueOnce({ + id: GLOBAL_SETTINGS_ID, + attributes: {}, + references: [], + type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + }); + + try { + await saveSettings(soClient, newData); + fail('Expected to throw'); + } catch (e) { + expect(e).toBeInstanceOf(DeleteUnenrolledAgentsPreconfiguredError); + } + }); }); diff --git a/x-pack/plugins/fleet/server/services/settings.ts b/x-pack/plugins/fleet/server/services/settings.ts index 394a2365f3c03..5f7433403c4e6 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -13,6 +13,8 @@ import { GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, GLOBAL_SETTINGS_ID } from '../../com import type { Settings, BaseSettings } from '../../common/types'; import type { SettingsSOAttributes } from '../types'; +import { DeleteUnenrolledAgentsPreconfiguredError } from '../errors'; + import { appContextService } from './app_context'; import { listFleetServerHosts } from './fleet_server_host'; import { auditLoggingService } from './audit_logging'; @@ -47,6 +49,7 @@ export async function getSettings(soClient: SavedObjectsClientContract): Promise settingsSo.attributes.use_space_awareness_migration_started_at, fleet_server_hosts: fleetServerHosts.items.flatMap((item) => item.host_urls), preconfigured_fields: getConfigFleetServerHosts() ? ['fleet_server_hosts'] : [], + delete_unenrolled_agents: settingsSo.attributes.delete_unenrolled_agents, }; } @@ -80,7 +83,10 @@ export async function settingsSetup(soClient: SavedObjectsClientContract) { export async function saveSettings( soClient: SavedObjectsClientContract, newData: Partial<Omit<Settings, 'id'>>, - options?: SavedObjectsUpdateOptions<SettingsSOAttributes> & { createWithOverwrite?: boolean } + options?: SavedObjectsUpdateOptions<SettingsSOAttributes> & { + createWithOverwrite?: boolean; + fromSetup?: boolean; + } ): Promise<Partial<Settings> & Pick<Settings, 'id'>> { const data = { ...newData }; if (data.fleet_server_hosts) { @@ -91,6 +97,16 @@ export async function saveSettings( try { const settings = await getSettings(soClient); + if ( + !options?.fromSetup && + settings.delete_unenrolled_agents?.is_preconfigured && + data.delete_unenrolled_agents + ) { + throw new DeleteUnenrolledAgentsPreconfiguredError( + `Setting delete_unenrolled_agents is preconfigured as 'enableDeleteUnenrolledAgents' and cannot be updated outside of kibana config file.` + ); + } + auditLoggingService.writeCustomSoAuditLog({ action: 'update', id: settings.id, diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index c0b86d6394769..0d6ec183531a4 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -53,6 +53,10 @@ import { cleanUpOldFileIndices } from './setup/clean_old_fleet_indices'; import type { UninstallTokenInvalidError } from './security/uninstall_token_service'; import { ensureAgentPoliciesFleetServerKeysAndPolicies } from './setup/fleet_server_policies_enrollment_keys'; import { ensureSpaceSettings } from './preconfiguration/space_settings'; +import { + ensureDeleteUnenrolledAgentsSetting, + getPreconfiguredDeleteUnenrolledAgentsSettingFromConfig, +} from './preconfiguration/delete_unenrolled_agent_setting'; export interface SetupStatus { isInitialized: boolean; @@ -195,6 +199,12 @@ async function createSetupSideEffects( logger.debug('Setting up Space settings'); await ensureSpaceSettings(appContextService.getConfig()?.spaceSettings ?? []); + logger.debug('Setting up delete unenrolled agents setting'); + await ensureDeleteUnenrolledAgentsSetting( + soClient, + getPreconfiguredDeleteUnenrolledAgentsSettingFromConfig(appContextService.getConfig()) + ); + logger.debug('Setting up Fleet outputs'); await Promise.all([ ensurePreconfiguredOutputs( diff --git a/x-pack/plugins/fleet/server/tasks/delete_unenrolled_agents_task.test.ts b/x-pack/plugins/fleet/server/tasks/delete_unenrolled_agents_task.test.ts new file mode 100644 index 0000000000000..54996c27975e3 --- /dev/null +++ b/x-pack/plugins/fleet/server/tasks/delete_unenrolled_agents_task.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { ElasticsearchClientMock } from '@kbn/core/server/mocks'; +import { coreMock } from '@kbn/core/server/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; +import { TaskStatus } from '@kbn/task-manager-plugin/server'; +import { getDeleteTaskRunResult } from '@kbn/task-manager-plugin/server/task'; +import type { CoreSetup } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; + +import { settingsService } from '../services'; +import { createAppContextStartContractMock } from '../mocks'; + +import { appContextService } from '../services'; + +import { DeleteUnenrolledAgentsTask, TYPE, VERSION } from './delete_unenrolled_agents_task'; + +jest.mock('../services'); + +const MOCK_TASK_INSTANCE = { + id: `${TYPE}:${VERSION}`, + runAt: new Date(), + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: new Date(), + scheduledAt: new Date(), + retryAt: new Date(), + params: {}, + state: {}, + taskType: TYPE, +}; + +describe('DeleteUnenrolledAgentsTask', () => { + const { createSetup: coreSetupMock } = coreMock; + const { createSetup: tmSetupMock, createStart: tmStartMock } = taskManagerMock; + + let mockContract: ReturnType<typeof createAppContextStartContractMock>; + let mockTask: DeleteUnenrolledAgentsTask; + let mockCore: CoreSetup; + let mockTaskManagerSetup: jest.Mocked<TaskManagerSetupContract>; + const mockSettingsService = settingsService as jest.Mocked<typeof settingsService>; + + beforeEach(() => { + mockContract = createAppContextStartContractMock(); + appContextService.start(mockContract); + mockCore = coreSetupMock(); + mockTaskManagerSetup = tmSetupMock(); + mockTask = new DeleteUnenrolledAgentsTask({ + core: mockCore, + taskManager: mockTaskManagerSetup, + logFactory: loggingSystemMock.create(), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Task lifecycle', () => { + it('Should create task', () => { + expect(mockTask).toBeInstanceOf(DeleteUnenrolledAgentsTask); + }); + + it('Should register task', () => { + expect(mockTaskManagerSetup.registerTaskDefinitions).toHaveBeenCalled(); + }); + + it('Should schedule task', async () => { + const mockTaskManagerStart = tmStartMock(); + await mockTask.start({ taskManager: mockTaskManagerStart }); + expect(mockTaskManagerStart.ensureScheduled).toHaveBeenCalled(); + }); + }); + + describe('Task logic', () => { + let esClient: ElasticsearchClientMock; + const runTask = async (taskInstance = MOCK_TASK_INSTANCE) => { + const mockTaskManagerStart = tmStartMock(); + await mockTask.start({ taskManager: mockTaskManagerStart }); + const createTaskRunner = + mockTaskManagerSetup.registerTaskDefinitions.mock.calls[0][0][TYPE].createTaskRunner; + const taskRunner = createTaskRunner({ taskInstance }); + return taskRunner.run(); + }; + + beforeEach(async () => { + const [{ elasticsearch }] = await mockCore.getStartServices(); + esClient = elasticsearch.client.asInternalUser as ElasticsearchClientMock; + esClient.deleteByQuery.mockResolvedValue({ deleted: 10 }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Should delete unenrolled agents', async () => { + mockSettingsService.getSettingsOrUndefined.mockResolvedValue({ + delete_unenrolled_agents: { + enabled: true, + is_preconfigured: false, + }, + id: '1', + }); + + await runTask(); + + expect(esClient.deleteByQuery).toHaveBeenCalled(); + }); + + it('Should not run if task is outdated', async () => { + const result = await runTask({ ...MOCK_TASK_INSTANCE, id: 'old-id' }); + + expect(esClient.deleteByQuery).not.toHaveBeenCalled(); + expect(result).toEqual(getDeleteTaskRunResult()); + }); + + it('Should exit if delete unenrolled agents flag is false', async () => { + mockSettingsService.getSettingsOrUndefined.mockResolvedValue({ + delete_unenrolled_agents: { + enabled: false, + is_preconfigured: false, + }, + id: '1', + }); + + await runTask(); + + expect(esClient.deleteByQuery).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/tasks/delete_unenrolled_agents_task.ts b/x-pack/plugins/fleet/server/tasks/delete_unenrolled_agents_task.ts new file mode 100644 index 0000000000000..440567effac7d --- /dev/null +++ b/x-pack/plugins/fleet/server/tasks/delete_unenrolled_agents_task.ts @@ -0,0 +1,181 @@ +/* + * 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 { SavedObjectsClient } from '@kbn/core/server'; +import type { + CoreSetup, + ElasticsearchClient, + Logger, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import type { + ConcreteTaskInstance, + TaskManagerSetupContract, + TaskManagerStartContract, +} from '@kbn/task-manager-plugin/server'; +import { getDeleteTaskRunResult } from '@kbn/task-manager-plugin/server/task'; +import type { LoggerFactory } from '@kbn/core/server'; +import { errors } from '@elastic/elasticsearch'; + +import { AGENTS_INDEX } from '../../common/constants'; + +import { settingsService } from '../services'; + +export const TYPE = 'fleet:delete-unenrolled-agents-task'; +export const VERSION = '1.0.0'; +const TITLE = 'Fleet Delete Unenrolled Agents Task'; +const SCOPE = ['fleet']; +const INTERVAL = '1h'; +const TIMEOUT = '1m'; + +interface DeleteUnenrolledAgentsTaskSetupContract { + core: CoreSetup; + taskManager: TaskManagerSetupContract; + logFactory: LoggerFactory; +} + +interface DeleteUnenrolledAgentsTaskStartContract { + taskManager: TaskManagerStartContract; +} + +export class DeleteUnenrolledAgentsTask { + private logger: Logger; + private wasStarted: boolean = false; + private abortController = new AbortController(); + + constructor(setupContract: DeleteUnenrolledAgentsTaskSetupContract) { + const { core, taskManager, logFactory } = setupContract; + this.logger = logFactory.get(this.taskId); + + taskManager.registerTaskDefinitions({ + [TYPE]: { + title: TITLE, + timeout: TIMEOUT, + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + return { + run: async () => { + return this.runTask(taskInstance, core); + }, + cancel: async () => { + this.abortController.abort('Task timed out'); + }, + }; + }, + }, + }); + } + + public start = async ({ taskManager }: DeleteUnenrolledAgentsTaskStartContract) => { + if (!taskManager) { + this.logger.error('[DeleteUnenrolledAgentsTask] Missing required service during start'); + return; + } + + this.wasStarted = true; + this.logger.info(`[DeleteUnenrolledAgentsTask] Started with interval of [${INTERVAL}]`); + + try { + await taskManager.ensureScheduled({ + id: this.taskId, + taskType: TYPE, + scope: SCOPE, + schedule: { + interval: INTERVAL, + }, + state: {}, + params: { version: VERSION }, + }); + } catch (e) { + this.logger.error(`Error scheduling task DeleteUnenrolledAgentsTask, error: ${e.message}`, e); + } + }; + + private get taskId(): string { + return `${TYPE}:${VERSION}`; + } + + private endRun(msg: string = '') { + this.logger.info(`[DeleteUnenrolledAgentsTask] runTask ended${msg ? ': ' + msg : ''}`); + } + + public async deleteUnenrolledAgents(esClient: ElasticsearchClient) { + this.logger.debug(`[DeleteUnenrolledAgentsTask] Fetching unenrolled agents`); + + const response = await esClient.deleteByQuery( + { + index: AGENTS_INDEX, + body: { + query: { + bool: { + filter: [ + { + term: { + active: false, + }, + }, + { exists: { field: 'unenrolled_at' } }, + ], + }, + }, + }, + }, + { signal: this.abortController.signal } + ); + + this.logger.debug( + `[DeleteUnenrolledAgentsTask] Executed deletion of ${response.deleted} unenrolled agents` + ); + } + + public async isDeleteUnenrolledAgentsEnabled( + soClient: SavedObjectsClientContract + ): Promise<boolean> { + const settings = await settingsService.getSettingsOrUndefined(soClient); + return settings?.delete_unenrolled_agents?.enabled ?? false; + } + + public runTask = async (taskInstance: ConcreteTaskInstance, core: CoreSetup) => { + if (!this.wasStarted) { + this.logger.debug('[DeleteUnenrolledAgentsTask] runTask Aborted. Task not started yet'); + return; + } + // Check that this task is current + if (taskInstance.id !== this.taskId) { + this.logger.debug( + `[DeleteUnenrolledAgentsTask] Outdated task version: Got [${taskInstance.id}] from task instance. Current version is [${this.taskId}]` + ); + return getDeleteTaskRunResult(); + } + + this.logger.info(`[runTask()] started`); + + const [coreStart] = await core.getStartServices(); + const esClient = coreStart.elasticsearch.client.asInternalUser; + const soClient = new SavedObjectsClient(coreStart.savedObjects.createInternalRepository()); + + try { + if (!(await this.isDeleteUnenrolledAgentsEnabled(soClient))) { + this.logger.debug( + '[DeleteUnenrolledAgentsTask] Delete unenrolled agents flag is disabled, returning.' + ); + this.endRun('Delete unenrolled agents is disabled'); + return; + } + await this.deleteUnenrolledAgents(esClient); + + this.endRun('success'); + } catch (err) { + if (err instanceof errors.RequestAbortedError) { + this.logger.warn(`[DeleteUnenrolledAgentsTask] request aborted due to timeout: ${err}`); + this.endRun(); + return; + } + this.logger.error(`[DeleteUnenrolledAgentsTask] error: ${err}`); + this.endRun('error'); + } + }; +} diff --git a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts index 0adaa69f1d30a..459070fa9591a 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts @@ -40,6 +40,12 @@ export const PutSettingsRequestSchema = { ), kibana_ca_sha256: schema.maybe(schema.string()), prerelease_integrations_enabled: schema.maybe(schema.boolean()), + delete_unenrolled_agents: schema.maybe( + schema.object({ + enabled: schema.boolean(), + is_preconfigured: schema.boolean(), + }) + ), }), }; @@ -56,7 +62,7 @@ export const SettingsResponseSchema = schema.object({ item: schema.object({ has_seen_add_data_notice: schema.maybe(schema.boolean()), fleet_server_hosts: schema.maybe(schema.arrayOf(schema.string())), - prerelease_integrations_enabled: schema.boolean(), + prerelease_integrations_enabled: schema.maybe(schema.boolean()), id: schema.string(), version: schema.maybe(schema.string()), preconfigured_fields: schema.maybe(schema.arrayOf(schema.literal('fleet_server_hosts'))), @@ -66,6 +72,12 @@ export const SettingsResponseSchema = schema.object({ schema.oneOf([schema.literal('pending'), schema.literal('success'), schema.literal('error')]) ), use_space_awareness_migration_started_at: schema.maybe(schema.string()), + delete_unenrolled_agents: schema.maybe( + schema.object({ + enabled: schema.boolean(), + is_preconfigured: schema.boolean(), + }) + ), }), }); diff --git a/x-pack/plugins/fleet/server/types/so_attributes.ts b/x-pack/plugins/fleet/server/types/so_attributes.ts index 9be09fe4ee554..31207dda64bc8 100644 --- a/x-pack/plugins/fleet/server/types/so_attributes.ts +++ b/x-pack/plugins/fleet/server/types/so_attributes.ts @@ -234,13 +234,17 @@ export type OutputSOAttributes = | OutputSoKafkaAttributes; export interface SettingsSOAttributes { - prerelease_integrations_enabled: boolean; + prerelease_integrations_enabled?: boolean; has_seen_add_data_notice?: boolean; fleet_server_hosts?: string[]; secret_storage_requirements_met?: boolean; output_secret_storage_requirements_met?: boolean; use_space_awareness_migration_status?: 'pending' | 'success' | 'error'; use_space_awareness_migration_started_at?: string | null; + delete_unenrolled_agents?: { + enabled: boolean; + is_preconfigured: boolean; + }; } export interface SpaceSettingsSOAttributes { diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index f5ccfcc3ca56f..eeb8b6e3474c9 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -139,6 +139,7 @@ export default function ({ getService }: FtrProviderContext) { 'endpoint:metadata-check-transforms-task', 'endpoint:user-artifact-packager', 'fleet:check-deleted-files-task', + 'fleet:delete-unenrolled-agents-task', 'fleet:deploy_agent_policies', 'fleet:reassign_action:retry', 'fleet:request_diagnostics:retry',