Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fleet] Create task that periodically unenrolls inactive agents #189861

Merged
merged 23 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6796369
[Fleet] Create task that periodically unenrolls inactive agents
criamico Aug 1, 2024
3edd45b
Improve query and other parts of the task
criamico Aug 5, 2024
47db25c
Merge branch 'main' into 179399_inactive_unenrollment_timeout
criamico Aug 5, 2024
3501a07
Batch sizes and better logging
criamico Aug 5, 2024
e0d928d
Add unit tests
criamico Aug 5, 2024
7326d1f
Remove comment
criamico Aug 5, 2024
04783df
Move all logic inside unenrollActiveAgents function
criamico Aug 5, 2024
68ccf52
fix i18n
criamico Aug 5, 2024
e06e3ed
small fixes
criamico Aug 6, 2024
dc397ab
Address code review comments
criamico Aug 6, 2024
e5b5897
Add loop for agent policies query
criamico Aug 6, 2024
51a8335
Add agents counter
criamico Aug 6, 2024
739b8ad
Merge branch 'main' into 179399_inactive_unenrollment_timeout
elasticmachine Aug 7, 2024
2aa500e
Use fetchAllAgentPolicies instead of list
criamico Aug 8, 2024
a45a9ef
Merge branch 'main' into 179399_inactive_unenrollment_timeout
elasticmachine Aug 8, 2024
6758e8c
Add action type to agent activity flyout to mark unenrollment coming …
criamico Aug 8, 2024
d35d228
Fix copy
criamico Aug 8, 2024
5c1eab3
Update action name
criamico Aug 8, 2024
16f6562
Merge branch 'main' into 179399_inactive_unenrollment_timeout
elasticmachine Aug 19, 2024
dafca57
Fix linter
criamico Aug 19, 2024
ca81433
fix test
criamico Aug 19, 2024
2de2dc9
Merge branch 'main' into 179399_inactive_unenrollment_timeout
elasticmachine Aug 19, 2024
08ffcfc
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Aug 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
criamico marked this conversation as resolved.
Show resolved Hide resolved
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,179 @@
/*
* 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, SavedObjectsClientContract } 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(async function* (soClient: SavedObjectsClientContract) {
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