[8.x] [Fleet] delete unenrolled agents task (#196072)

Backport https://github.com/elastic/kibana/pull/195544 to 8.x

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia Bardi 2024-10-14 14:26:56 +02:00 committed by GitHub
parent 6378ff3ac9
commit 759501133e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 898 additions and 9 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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",

View file

@ -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"
},

View file

@ -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(() => {});
}
}

View file

@ -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",

View file

@ -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": [

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;
};
}

View file

@ -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" />
</>
);
};

View file

@ -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 />
</>
);
};

View file

@ -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({

View file

@ -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 {}

View file

@ -139,6 +139,7 @@ export const createAppContextStartContractMock = (
}
: {}),
unenrollInactiveAgentsTask: {} as any,
deleteUnenrolledAgentsTask: {} as any,
};
};

View file

@ -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)

View file

@ -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]: {

View file

@ -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;
}

View file

@ -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();
});
});

View file

@ -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 }
);
}

View file

@ -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);
}
});
});

View file

@ -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<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) {
@ -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,

View file

@ -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(

View file

@ -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();
});
});
});

View file

@ -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');
}
};
}

View file

@ -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(),
})
),
}),
};

View file

@ -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 {

View file

@ -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',