Skip to content

Commit

Permalink
[Fleet] Create task that periodically unenrolls inactive agents (elas…
Browse files Browse the repository at this point in the history
…tic#189861)

Closes elastic#179399

## Summary

Create a new periodic task that unenrolls inactive agents based on
`unenroll_timeout` set on agent policies

In the agent policy settings there is now a new section:

![Screenshot 2024-08-06 at 12 31
37](https://github.com/user-attachments/assets/f66164c5-3eff-442d-91bc-367387cefe3d)



### Testing
- Create a policy with `unenroll_timeout` set to any value
- Enroll many agents to a policy and make them inactive - you can use
Horde or the script in `fleet/scripts/create_agents' that can directly
create inactive agents
- Leave the local env running for at least 10 minutes
- You should see logs that indicate that the task ran successfully and
remove the inactive agents
![Screenshot 2024-08-06 at 12 14
13](https://github.com/user-attachments/assets/573f32fb-eedb-4bee-918c-f26fedec9e0b)
Note that the executed unenroll action is also visible in the UI:
![Screenshot 2024-08-06 at 12 19
52](https://github.com/user-attachments/assets/942932ac-70dd-4d77-bf47-20007ac54748)
- If there are no agent policies with `unenroll_timeout` set or there
are no inactive agents on those policies, you should see logs like
these:
![Screenshot 2024-08-06 at 12 13
49](https://github.com/user-attachments/assets/8868c228-fd09-4ecf-ad02-e07a94812638)





### Checklist

- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Elastic Machine <[email protected]>
  • Loading branch information
criamico and elasticmachine authored Aug 19, 2024
1 parent 0299a7a commit 1565753
Show file tree
Hide file tree
Showing 12 changed files with 415 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiBetaBadge,
EuiBadge,
EuiSwitch,
} from '@elastic/eui';
Expand Down Expand Up @@ -796,29 +795,14 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> =
<h3>
<FormattedMessage
id="xpack.fleet.agentPolicyForm.unenrollmentTimeoutLabel"
defaultMessage="Unenrollment timeout"
defaultMessage="Inactive agent unenrollment timeout"
/>
&nbsp;
<EuiToolTip
content={i18n.translate('xpack.fleet.agentPolicyForm.unenrollmentTimeoutTooltip', {
defaultMessage:
'This setting is deprecated and will be removed in a future release. Consider using inactivity timeout instead',
})}
>
<EuiBetaBadge
label={i18n.translate(
'xpack.fleet.agentPolicyForm.unenrollmentTimeoutDeprecatedLabel',
{ defaultMessage: 'Deprecated' }
)}
size="s"
/>
</EuiToolTip>
</h3>
}
description={
<FormattedMessage
id="xpack.fleet.agentPolicyForm.unenrollmentTimeoutDescription"
defaultMessage="An optional timeout in seconds. If provided, and fleet server is below version 8.7.0, an agent will automatically unenroll after being gone for this period of time."
defaultMessage="An optional timeout in seconds. If configured, inactive agents will be automatically unenrolled and their API keys will be invalidated after they've been inactive for this value in seconds. This can be useful for policies containing ephemeral agents, such as those in a Docker or Kubernetes environment."
/>
}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const ActivityItem: React.FunctionComponent<{
? action.nbAgentsAck
: action.nbAgentsAck + ' of ' + action.nbAgentsActioned,
agents: action.nbAgentsActioned === 1 ? 'agent' : 'agents',
completedText: getAction(action.type).completedText,
completedText: getAction(action.type, action.actionId).completedText,
offlineText:
action.status === 'ROLLOUT_PASSED' && action.nbAgentsActioned - action.nbAgentsAck > 0
? `, ${
Expand Down Expand Up @@ -175,7 +175,7 @@ export const ActivityItem: React.FunctionComponent<{
id="xpack.fleet.agentActivityFlyout.cancelledTitle"
defaultMessage="Agent {cancelledText} cancelled"
values={{
cancelledText: getAction(action.type).cancelledText,
cancelledText: getAction(action.type, action.actionId).cancelledText,
}}
/>
</EuiText>
Expand All @@ -201,7 +201,7 @@ export const ActivityItem: React.FunctionComponent<{
id="xpack.fleet.agentActivityFlyout.expiredTitle"
defaultMessage="Agent {expiredText} expired"
values={{
expiredText: getAction(action.type).cancelledText,
expiredText: getAction(action.type, action.actionId).cancelledText,
}}
/>
</EuiText>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ const actionNames: {
completedText: 'force unenrolled',
cancelledText: 'force unenrollment',
},
AUTOMATIC_FORCE_UNENROLL: {
inProgressText: 'Automatic unenrolling',
completedText: 'automatically unenrolled',
cancelledText: 'automatic unenrollment',
},
UPDATE_TAGS: {
inProgressText: 'Updating tags of',
completedText: 'updated tags',
Expand Down Expand Up @@ -60,7 +65,13 @@ const actionNames: {
ACTION: { inProgressText: 'Actioning', completedText: 'actioned', cancelledText: 'action' },
};

export const getAction = (type?: string) => actionNames[type ?? 'ACTION'] ?? actionNames.ACTION;
export const getAction = (type?: string, actionId?: string) => {
// handling a special case of force unenrollment coming from an automatic task
// we know what kind of action is from the actionId prefix
if (actionId?.includes('UnenrollInactiveAgentsTask-'))
return actionNames.AUTOMATICAL_FORCE_UNENROLL;
return actionNames[type ?? 'ACTION'] ?? actionNames.ACTION;
};

export const inProgressTitle = (action: ActionStatus) => (
<FormattedMessage
Expand All @@ -74,7 +85,7 @@ export const inProgressTitle = (action: ActionStatus) => (
? action.nbAgentsActioned
: action.nbAgentsActioned - action.nbAgentsAck + ' of ' + action.nbAgentsActioned,
agents: action.nbAgentsActioned === 1 ? 'agent' : 'agents',
inProgressText: getAction(action.type).inProgressText,
inProgressText: getAction(action.type, action.actionId).inProgressText,
reassignText:
action.type === 'POLICY_REASSIGN' && action.newPolicyId ? `to ${action.newPolicyId}` : '',
upgradeText: action.type === 'UPGRADE' ? `to version ${action.version}` : '',
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/fleet/server/mocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const createAppContextStartContractMock = (
},
}
: {}),
unenrollInactiveAgentsTask: {} as any,
};
};

Expand Down
10 changes: 10 additions & 0 deletions x-pack/plugins/fleet/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import type { PackagePolicyService } from './services/package_policy_service';
import { PackagePolicyServiceImpl } from './services/package_policy';
import { registerFleetUsageLogger, startFleetUsageLogger } from './services/fleet_usage_logger';
import { CheckDeletedFilesTask } from './tasks/check_deleted_files_task';
import { UnenrollInactiveAgentsTask } from './tasks/unenroll_inactive_agents_task';
import {
UninstallTokenService,
type UninstallTokenServiceInterface,
Expand Down Expand Up @@ -178,6 +179,7 @@ export interface FleetAppContext {
messageSigningService: MessageSigningServiceInterface;
auditLogger?: AuditLogger;
uninstallTokenService: UninstallTokenServiceInterface;
unenrollInactiveAgentsTask: UnenrollInactiveAgentsTask;
}

export type FleetSetupContract = void;
Expand Down Expand Up @@ -266,6 +268,7 @@ export class FleetPlugin
private fleetUsageSender?: FleetUsageSender;
private checkDeletedFilesTask?: CheckDeletedFilesTask;
private fleetMetricsTask?: FleetMetricsTask;
private unenrollInactiveAgentsTask?: UnenrollInactiveAgentsTask;

private agentService?: AgentService;
private packageService?: PackageService;
Expand Down Expand Up @@ -599,6 +602,11 @@ export class FleetPlugin
taskManager: deps.taskManager,
logFactory: this.initializerContext.logger,
});
this.unenrollInactiveAgentsTask = new UnenrollInactiveAgentsTask({
core,
taskManager: deps.taskManager,
logFactory: this.initializerContext.logger,
});

// Register fields metadata extractor
registerIntegrationFieldsExtractor({ core, fieldsMetadata: deps.fieldsMetadata });
Expand Down Expand Up @@ -644,12 +652,14 @@ export class FleetPlugin
bulkActionsResolver: this.bulkActionsResolver!,
messageSigningService,
uninstallTokenService,
unenrollInactiveAgentsTask: this.unenrollInactiveAgentsTask!,
});
licenseService.start(plugins.licensing.license$);
this.telemetryEventsSender.start(plugins.telemetry, core).catch(() => {});
this.bulkActionsResolver?.start(plugins.taskManager).catch(() => {});
this.fleetUsageSender?.start(plugins.taskManager).catch(() => {});
this.checkDeletedFilesTask?.start({ taskManager: plugins.taskManager }).catch(() => {});
this.unenrollInactiveAgentsTask?.start({ taskManager: plugins.taskManager }).catch(() => {});
startFleetUsageLogger(plugins.taskManager).catch(() => {});
this.fleetMetricsTask
?.start(plugins.taskManager, core.elasticsearch.client.asInternalUser)
Expand Down
4 changes: 0 additions & 4 deletions x-pack/plugins/fleet/server/services/agent_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1245,10 +1245,6 @@ class AgentPolicyService {
default_fleet_server: policy.is_default_fleet_server === true,
};

if (policy.unenroll_timeout) {
fleetServerPolicy.unenroll_timeout = policy.unenroll_timeout;
}

acc.push(fleetServerPolicy);
return acc;
}, [] as FleetServerPolicy[]);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 { 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 { agentPolicyService } from '../services';
import { createAgentPolicyMock } from '../../common/mocks';
import { createAppContextStartContractMock } from '../mocks';
import { getAgentsByKuery } from '../services/agents';

import { appContextService } from '../services';

import { unenrollBatch } from '../services/agents/unenroll_action_runner';

import type { AgentPolicy } from '../types';

import { UnenrollInactiveAgentsTask, TYPE, VERSION } from './unenroll_inactive_agents_task';

jest.mock('../services');
jest.mock('../services/agents');
jest.mock('../services/agents/unenroll_action_runner');

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

const mockAgentPolicyService = agentPolicyService as jest.Mocked<typeof agentPolicyService>;
const mockedGetAgentsByKuery = getAgentsByKuery as jest.MockedFunction<typeof getAgentsByKuery>;

describe('UnenrollInactiveAgentsTask', () => {
const { createSetup: coreSetupMock } = coreMock;
const { createSetup: tmSetupMock, createStart: tmStartMock } = taskManagerMock;

let mockContract: ReturnType<typeof createAppContextStartContractMock>;
let mockTask: UnenrollInactiveAgentsTask;
let mockCore: CoreSetup;
let mockTaskManagerSetup: jest.Mocked<TaskManagerSetupContract>;
const mockedUnenrollBatch = jest.mocked(unenrollBatch);

const agents = [
{
id: 'agent-1',
policy_id: 'agent-policy-2',
status: 'inactive',
},
{
id: 'agent-2',
policy_id: 'agent-policy-1',
status: 'inactive',
},
{
id: 'agent-3',
policy_id: 'agent-policy-1',
status: 'active',
},
];

const getMockAgentPolicyFetchAllAgentPolicies = (items: AgentPolicy[]) =>
jest.fn().mockResolvedValue(
jest.fn(async function* () {
yield items;
})()
);

beforeEach(() => {
mockContract = createAppContextStartContractMock();
appContextService.start(mockContract);
mockCore = coreSetupMock();
mockTaskManagerSetup = tmSetupMock();
mockTask = new UnenrollInactiveAgentsTask({
core: mockCore,
taskManager: mockTaskManagerSetup,
logFactory: loggingSystemMock.create(),
});
});

afterEach(() => {
jest.clearAllMocks();
});

describe('Task lifecycle', () => {
it('Should create task', () => {
expect(mockTask).toBeInstanceOf(UnenrollInactiveAgentsTask);
});

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', () => {
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(() => {
mockAgentPolicyService.fetchAllAgentPolicies = getMockAgentPolicyFetchAllAgentPolicies([
createAgentPolicyMock({ unenroll_timeout: 3000 }),
createAgentPolicyMock({ id: 'agent-policy-2', unenroll_timeout: 1000 }),
]);

mockedGetAgentsByKuery.mockResolvedValue({
agents,
} as any);
});

afterEach(() => {
jest.clearAllMocks();
});

it('Should unenroll eligible agents', async () => {
mockedUnenrollBatch.mockResolvedValueOnce({ actionId: 'actionid-01' });
await runTask();
expect(mockedUnenrollBatch).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
agents,
{
force: true,
revoke: true,
actionId: expect.stringContaining('UnenrollInactiveAgentsTask-'),
}
);
});

it('Should not run if task is outdated', async () => {
const result = await runTask({ ...MOCK_TASK_INSTANCE, id: 'old-id' });

expect(mockedUnenrollBatch).not.toHaveBeenCalled();
expect(result).toEqual(getDeleteTaskRunResult());
});

it('Should exit if there are no agents policies with unenroll_timeout set', async () => {
mockAgentPolicyService.list.mockResolvedValue({
items: [],
total: 0,
page: 1,
perPage: 1,
});
expect(mockedUnenrollBatch).not.toHaveBeenCalled();
});

it('Should exit if there are no eligible agents to unenroll', async () => {
mockedGetAgentsByKuery.mockResolvedValue({
agents: [],
} as any);
expect(mockedUnenrollBatch).not.toHaveBeenCalled();
});
});
});
Loading

0 comments on commit 1565753

Please sign in to comment.