diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml index 0a7bf8822f1ca..da1a2913d259b 100644 --- a/oas_docs/output/kibana.serverless.staging.yaml +++ b/oas_docs/output/kibana.serverless.staging.yaml @@ -13395,6 +13395,13 @@ paths: properties: additional_yaml_config: type: string + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean fleet_server_hosts: description: Protocol and path must be the same for each URL items: @@ -21844,6 +21851,13 @@ components: title: Settings type: object properties: + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean fleet_server_hosts: deprecated: true items: diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 0a7bf8822f1ca..da1a2913d259b 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -13395,6 +13395,13 @@ paths: properties: additional_yaml_config: type: string + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean fleet_server_hosts: description: Protocol and path must be the same for each URL items: @@ -21844,6 +21851,13 @@ components: title: Settings type: object properties: + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean fleet_server_hosts: deprecated: true items: diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml index b4e55411da7de..6f524a3685299 100644 --- a/oas_docs/output/kibana.staging.yaml +++ b/oas_docs/output/kibana.staging.yaml @@ -16823,6 +16823,13 @@ paths: properties: additional_yaml_config: type: string + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean fleet_server_hosts: description: Protocol and path must be the same for each URL items: @@ -29623,6 +29630,13 @@ components: title: Settings type: object properties: + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean fleet_server_hosts: deprecated: true items: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index b4e55411da7de..6f524a3685299 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -16823,6 +16823,13 @@ paths: properties: additional_yaml_config: type: string + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean fleet_server_hosts: description: Protocol and path must be the same for each URL items: @@ -29623,6 +29630,13 @@ components: title: Settings type: object properties: + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean fleet_server_hosts: deprecated: true items: diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index cc8056442beda..5dabd353a8a9a 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 523b794d1ad1f..414faf88b304f 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 53df943fe058e..55e85871de6bd 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/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index c77ccc0c227d2..49af4d56fa180 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -222,6 +222,17 @@ }, "additional_yaml_config": { "type": "string" + }, + "delete_unenrolled_agents": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "is_preconfigured": { + "type": "boolean" + } + } } } } @@ -6187,6 +6198,17 @@ }, "prerelease_integrations_enabled": { "type": "boolean" + }, + "delete_unenrolled_agents": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "is_preconfigured": { + "type": "boolean" + } + } } }, "required": [ diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 0197e7e925550..b955943cad876 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -128,6 +128,13 @@ paths: type: boolean additional_yaml_config: type: string + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean responses: '200': description: OK @@ -3866,6 +3873,13 @@ components: type: string prerelease_integrations_enabled: type: boolean + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean required: - fleet_server_hosts - id diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/settings.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/settings.yaml index bd078039d7f76..7fbf760429960 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/settings.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/settings.yaml @@ -12,6 +12,14 @@ properties: type: string prerelease_integrations_enabled: type: boolean + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean + required: - fleet_server_hosts - id diff --git a/x-pack/plugins/fleet/common/openapi/paths/settings.yaml b/x-pack/plugins/fleet/common/openapi/paths/settings.yaml index 4e3d1b3af4bb7..91048fcac8b21 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/settings.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/settings.yaml @@ -31,6 +31,13 @@ put: type: boolean additional_yaml_config: type: string + delete_unenrolled_agents: + type: object + properties: + enabled: + type: boolean + is_preconfigured: + type: boolean responses: '200': description: OK 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(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 ( + <> + +

+ +

+
+ + + + + + } + description={ +

+ + + + ), + }} + /> +

+ } + > + + + + } + checked={deleteUnenrolledAgentsChecked} + onChange={(e) => updateSettings(e.target.checked)} + disabled={!authz.fleet.allSettings || isPreconfigured} + /> + + +
+
+ + + + ); +}; 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 = ({ /> + + ); }; 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 526d8a1a016ab..4c5cdf7070530 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 { registerFieldsMetadataExtractors } from './services/register_fields_metadata_extractors'; 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 extractors registerFieldsMetadataExtractors({ 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/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 { - 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> = { + 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> = { + 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 68829b734eeaf..afa7ff9802363 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'; @@ -39,6 +41,7 @@ export async function getSettings(soClient: SavedObjectsClientContract): Promise ...settingsSo.attributes, fleet_server_hosts: fleetServerHosts.items.flatMap((item) => item.host_urls), preconfigured_fields: getConfigFleetServerHosts() ? ['fleet_server_hosts'] : [], + delete_unenrolled_agents: settingsSo.attributes.delete_unenrolled_agents, }; } @@ -72,7 +75,10 @@ export async function settingsSetup(soClient: SavedObjectsClientContract) { export async function saveSettings( soClient: SavedObjectsClientContract, newData: Partial>, - options?: SavedObjectsUpdateOptions & { createWithOverwrite?: boolean } + options?: SavedObjectsUpdateOptions & { + createWithOverwrite?: boolean; + fromSetup?: boolean; + } ): Promise & Pick> { const data = { ...newData }; if (data.fleet_server_hosts) { @@ -83,6 +89,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; + let mockTask: DeleteUnenrolledAgentsTask; + let mockCore: CoreSetup; + let mockTaskManagerSetup: jest.Mocked; + const mockSettingsService = settingsService as jest.Mocked; + + 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 { + 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 10db7b0f4def7..60fd93fcd666a 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts @@ -36,6 +36,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(), + }) + ), }), }; diff --git a/x-pack/plugins/fleet/server/types/so_attributes.ts b/x-pack/plugins/fleet/server/types/so_attributes.ts index 1bb954cf990d2..0b879819d1390 100644 --- a/x-pack/plugins/fleet/server/types/so_attributes.ts +++ b/x-pack/plugins/fleet/server/types/so_attributes.ts @@ -234,11 +234,15 @@ 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; + 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',