[Fleet] Add single agent migration endpoint (#220601)

## Summary

Closes #217617 

Adds a new endpoint `POST kbn:/api/fleet/agents/{agentId}/migrate`
allowing a user to migrate a single agent to another cluster.

Required parameters are: `enrollment_token` and `uri`

- Adds `MIGRATE` as an action type and is reflected in UI
- Includes unit tests as well as integration tests 



### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[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
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Identify risks

N/A

# Release Note

- Added endpoint allowing a user to migrate an individual agent to
another cluster by specifying the URL and Enrollment Token. Note: tamper
protected and fleet agents can not be migrated and attempting to do so
will return a `403` status code.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Mason Herron 2025-05-28 11:04:13 -06:00 committed by GitHub
parent 5b2fa54b4e
commit ca2770f10f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1238 additions and 5 deletions

View file

@ -20181,7 +20181,8 @@
"REQUEST_DIAGNOSTICS",
"UPDATE_TAGS",
"POLICY_CHANGE",
"INPUT_ACTION"
"INPUT_ACTION",
"MIGRATE"
],
"type": "string"
},
@ -22678,6 +22679,163 @@
]
}
},
"/api/fleet/agents/{agentId}/migrate": {
"post": {
"description": "Migrate a single agent to another cluster.<br/><br/>[Required authorization] Route required privileges: fleet-agents-all.",
"operationId": "post-fleet-agents-agentid-migrate",
"parameters": [
{
"description": "A required header to protect against CSRF attacks",
"in": "header",
"name": "kbn-xsrf",
"required": true,
"schema": {
"example": "true",
"type": "string"
}
},
{
"in": "path",
"name": "agentId",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"enrollment_token": {
"type": "string"
},
"settings": {
"additionalProperties": false,
"properties": {
"ca_sha256": {
"type": "string"
},
"certificate_authorities": {
"type": "string"
},
"elastic_agent_cert": {
"type": "string"
},
"elastic_agent_cert_key": {
"type": "string"
},
"elastic_agent_cert_key_passphrase": {
"type": "string"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"insecure": {
"type": "boolean"
},
"proxy_disabled": {
"type": "boolean"
},
"proxy_headers": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"proxy_url": {
"type": "string"
},
"replace_token": {
"type": "boolean"
},
"staging": {
"type": "boolean"
},
"tags": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"uri": {
"format": "uri",
"type": "string"
}
},
"required": [
"uri",
"enrollment_token"
],
"type": "object"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"actionId": {
"type": "string"
}
},
"required": [
"actionId"
],
"type": "object"
}
}
}
},
"400": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"description": "Generic Error",
"properties": {
"attributes": {},
"error": {
"type": "string"
},
"errorType": {
"type": "string"
},
"message": {
"type": "string"
},
"statusCode": {
"type": "number"
}
},
"required": [
"message",
"attributes"
],
"type": "object"
}
}
}
}
},
"summary": "Migrate a single agent",
"tags": [
"Elastic Agents"
]
}
},
"/api/fleet/agents/{agentId}/reassign": {
"post": {
"description": "[Required authorization] Route required privileges: fleet-agents-all.",

View file

@ -20181,7 +20181,8 @@
"REQUEST_DIAGNOSTICS",
"UPDATE_TAGS",
"POLICY_CHANGE",
"INPUT_ACTION"
"INPUT_ACTION",
"MIGRATE"
],
"type": "string"
},
@ -22678,6 +22679,163 @@
]
}
},
"/api/fleet/agents/{agentId}/migrate": {
"post": {
"description": "Migrate a single agent to another cluster.<br/><br/>[Required authorization] Route required privileges: fleet-agents-all.",
"operationId": "post-fleet-agents-agentid-migrate",
"parameters": [
{
"description": "A required header to protect against CSRF attacks",
"in": "header",
"name": "kbn-xsrf",
"required": true,
"schema": {
"example": "true",
"type": "string"
}
},
{
"in": "path",
"name": "agentId",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"enrollment_token": {
"type": "string"
},
"settings": {
"additionalProperties": false,
"properties": {
"ca_sha256": {
"type": "string"
},
"certificate_authorities": {
"type": "string"
},
"elastic_agent_cert": {
"type": "string"
},
"elastic_agent_cert_key": {
"type": "string"
},
"elastic_agent_cert_key_passphrase": {
"type": "string"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"insecure": {
"type": "boolean"
},
"proxy_disabled": {
"type": "boolean"
},
"proxy_headers": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"proxy_url": {
"type": "string"
},
"replace_token": {
"type": "boolean"
},
"staging": {
"type": "boolean"
},
"tags": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"uri": {
"format": "uri",
"type": "string"
}
},
"required": [
"uri",
"enrollment_token"
],
"type": "object"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"actionId": {
"type": "string"
}
},
"required": [
"actionId"
],
"type": "object"
}
}
}
},
"400": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"description": "Generic Error",
"properties": {
"attributes": {},
"error": {
"type": "string"
},
"errorType": {
"type": "string"
},
"message": {
"type": "string"
},
"statusCode": {
"type": "number"
}
},
"required": [
"message",
"attributes"
],
"type": "object"
}
}
}
}
},
"summary": "Migrate a single agent",
"tags": [
"Elastic Agents"
]
}
},
"/api/fleet/agents/{agentId}/reassign": {
"post": {
"description": "[Required authorization] Route required privileges: fleet-agents-all.",

View file

@ -23811,6 +23811,109 @@ paths:
summary: Create an agent action
tags:
- Elastic Agent actions
/api/fleet/agents/{agentId}/migrate:
post:
description: 'Migrate a single agent to another cluster.<br/><br/>[Required authorization] Route required privileges: fleet-agents-all.'
operationId: post-fleet-agents-agentid-migrate
parameters:
- description: A required header to protect against CSRF attacks
in: header
name: kbn-xsrf
required: true
schema:
example: 'true'
type: string
- in: path
name: agentId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
additionalProperties: false
type: object
properties:
enrollment_token:
type: string
settings:
additionalProperties: false
type: object
properties:
ca_sha256:
type: string
certificate_authorities:
type: string
elastic_agent_cert:
type: string
elastic_agent_cert_key:
type: string
elastic_agent_cert_key_passphrase:
type: string
headers:
additionalProperties:
type: string
type: object
insecure:
type: boolean
proxy_disabled:
type: boolean
proxy_headers:
additionalProperties:
type: string
type: object
proxy_url:
type: string
replace_token:
type: boolean
staging:
type: boolean
tags:
items:
type: string
type: array
uri:
format: uri
type: string
required:
- uri
- enrollment_token
responses:
'200':
content:
application/json:
schema:
additionalProperties: false
type: object
properties:
actionId:
type: string
required:
- actionId
'400':
content:
application/json:
schema:
additionalProperties: false
description: Generic Error
type: object
properties:
attributes: {}
error:
type: string
errorType:
type: string
message:
type: string
statusCode:
type: number
required:
- message
- attributes
summary: Migrate a single agent
tags:
- Elastic Agents
/api/fleet/agents/{agentId}/reassign:
post:
description: '[Required authorization] Route required privileges: fleet-agents-all.'
@ -24236,6 +24339,7 @@ paths:
- UPDATE_TAGS
- POLICY_CHANGE
- INPUT_ACTION
- MIGRATE
type: string
version:
description: agent version number (UPGRADE action)

View file

@ -26053,6 +26053,109 @@ paths:
summary: Create an agent action
tags:
- Elastic Agent actions
/api/fleet/agents/{agentId}/migrate:
post:
description: 'Migrate a single agent to another cluster.<br/><br/>[Required authorization] Route required privileges: fleet-agents-all.'
operationId: post-fleet-agents-agentid-migrate
parameters:
- description: A required header to protect against CSRF attacks
in: header
name: kbn-xsrf
required: true
schema:
example: 'true'
type: string
- in: path
name: agentId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
additionalProperties: false
type: object
properties:
enrollment_token:
type: string
settings:
additionalProperties: false
type: object
properties:
ca_sha256:
type: string
certificate_authorities:
type: string
elastic_agent_cert:
type: string
elastic_agent_cert_key:
type: string
elastic_agent_cert_key_passphrase:
type: string
headers:
additionalProperties:
type: string
type: object
insecure:
type: boolean
proxy_disabled:
type: boolean
proxy_headers:
additionalProperties:
type: string
type: object
proxy_url:
type: string
replace_token:
type: boolean
staging:
type: boolean
tags:
items:
type: string
type: array
uri:
format: uri
type: string
required:
- uri
- enrollment_token
responses:
'200':
content:
application/json:
schema:
additionalProperties: false
type: object
properties:
actionId:
type: string
required:
- actionId
'400':
content:
application/json:
schema:
additionalProperties: false
description: Generic Error
type: object
properties:
attributes: {}
error:
type: string
errorType:
type: string
message:
type: string
statusCode:
type: number
required:
- message
- attributes
summary: Migrate a single agent
tags:
- Elastic Agents
/api/fleet/agents/{agentId}/reassign:
post:
description: '[Required authorization] Route required privileges: fleet-agents-all.'
@ -26478,6 +26581,7 @@ paths:
- UPDATE_TAGS
- POLICY_CHANGE
- INPUT_ACTION
- MIGRATE
type: string
version:
description: agent version number (UPGRADE action)

View file

@ -152,6 +152,7 @@ export const AGENT_API_ROUTES = {
CHECKIN_PATTERN: `${API_ROOT}/agents/{agentId}/checkin`,
ACKS_PATTERN: `${API_ROOT}/agents/{agentId}/acks`,
ACTIONS_PATTERN: `${API_ROOT}/agents/{agentId}/actions`,
MIGRATE_PATTERN: `${API_ROOT}/agents/{agentId}/migrate`,
CANCEL_ACTIONS_PATTERN: `${API_ROOT}/agents/actions/{actionId}/cancel`,
UNENROLL_PATTERN: `${API_ROOT}/agents/{agentId}/unenroll`,
BULK_UNENROLL_PATTERN: `${API_ROOT}/agents/bulk_unenroll`,

View file

@ -41,7 +41,8 @@ export type AgentActionType =
| 'UPDATE_TAGS'
| 'REQUEST_DIAGNOSTICS'
| 'POLICY_CHANGE'
| 'INPUT_ACTION';
| 'INPUT_ACTION'
| 'MIGRATE';
export type AgentUpgradeStateType =
| 'UPG_REQUESTED'
@ -60,6 +61,9 @@ export type FleetServerAgentComponentStatus = FleetServerAgentComponentStatusTup
export interface NewAgentAction {
type: AgentActionType;
data?: any;
enrollment_token?: string;
target_uri?: string;
additionalSettings?: string;
ack_data?: any;
sent_at?: string;
agents: string[];

View file

@ -61,6 +61,11 @@ const actionNames: {
completedText: 'input action completed',
cancelledText: 'input action',
},
MIGRATE: {
inProgressText: 'Migrating',
completedText: 'migrated',
cancelledText: 'migration',
},
ACTION: { inProgressText: 'Actioning', completedText: 'actioned', cancelledText: 'action' },
};

View file

@ -20,6 +20,7 @@ import {
GetAgentsResponseSchema,
GetAvailableAgentVersionsResponseSchema,
ListAgentUploadsResponseSchema,
MigrateSingleAgentResponseSchema,
PostBulkActionResponseSchema,
PostNewAgentActionResponseSchema,
PostRetrieveAgentsByActionsResponseSchema,
@ -56,6 +57,7 @@ import {
import { postNewAgentActionHandlerBuilder } from './actions_handlers';
import { migrateSingleAgentHandler } from './migrate_handlers';
jest.mock('./handlers', () => ({
...jest.requireActual('./handlers'),
getAgentHandler: jest.fn(),
@ -75,6 +77,10 @@ jest.mock('./handlers', () => ({
postRetrieveAgentsByActionsHandler: jest.fn(),
}));
jest.mock('./migrate_handlers', () => ({
migrateSingleAgentHandler: jest.fn(),
}));
jest.mock('./actions_handlers', () => ({
postNewAgentActionHandlerBuilder: jest.fn(),
}));
@ -476,4 +482,20 @@ describe('schema validation', () => {
const validationResp = GetAvailableAgentVersionsResponseSchema.validate(expectedResponse);
expect(validationResp).toEqual(expectedResponse);
});
it('migrate single agent should return valid response', async () => {
const expectedResponse = {
actionId: 'migrate-action-123',
};
(migrateSingleAgentHandler as jest.Mock).mockImplementation((ctx, request, res) => {
return res.ok({ body: expectedResponse });
});
await migrateSingleAgentHandler(context, {} as any, response);
expect(response.ok).toHaveBeenCalledWith({
body: expectedResponse,
});
const validationResp = MigrateSingleAgentResponseSchema.validate(expectedResponse);
expect(validationResp).toEqual(expectedResponse);
});
});

View file

@ -16,6 +16,7 @@ import {
GetTagsRequestSchema,
GetOneAgentRequestSchema,
UpdateAgentRequestSchema,
MigrateSingleAgentRequestSchema,
DeleteAgentRequestSchema,
PostAgentUnenrollRequestSchema,
PostBulkAgentUnenrollRequestSchema,
@ -34,6 +35,7 @@ import {
PostRetrieveAgentsByActionsRequestSchema,
DeleteAgentUploadFileRequestSchema,
GetTagsResponseSchema,
MigrateSingleAgentResponseSchema,
} from '../../types';
import * as AgentService from '../../services/agents';
import type { FleetConfigType } from '../..';
@ -88,6 +90,7 @@ import {
bulkRequestDiagnosticsHandler,
requestDiagnosticsHandler,
} from './request_diagnostics_handler';
import { migrateSingleAgentHandler } from './migrate_handlers';
export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigType) => {
// Get one
@ -123,6 +126,38 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT
getAgentHandler
);
// Migrate
router.versioned
.post({
path: AGENT_API_ROUTES.MIGRATE_PATTERN,
security: {
authz: {
requiredPrivileges: [FLEET_API_PRIVILEGES.AGENTS.ALL],
},
},
summary: `Migrate a single agent`,
description: `Migrate a single agent to another cluster.`,
options: {
tags: ['oas-tag:Elastic Agents'],
},
})
.addVersion(
{
version: API_VERSIONS.public.v1,
validate: {
request: MigrateSingleAgentRequestSchema,
response: {
200: {
body: () => MigrateSingleAgentResponseSchema,
},
400: {
body: genericErrorResponse,
},
},
},
},
migrateSingleAgentHandler
);
// Update
router.versioned
.put({

View file

@ -0,0 +1,163 @@
/*
* 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 {
httpServerMock,
elasticsearchServiceMock,
savedObjectsClientMock,
} from '@kbn/core/server/mocks';
import type {
KibanaResponseFactory,
ElasticsearchClient,
SavedObjectsClientContract,
} from '@kbn/core/server';
import * as AgentService from '../../services/agents';
import { AgentNotFoundError, FleetUnauthorizedError } from '../../errors';
import { migrateSingleAgentHandler } from './migrate_handlers';
// Mock the agent service functions
jest.mock('../../services/agents', () => {
return {
getAgentById: jest.fn(),
getAgentPolicyForAgent: jest.fn(),
migrateSingleAgent: jest.fn(),
};
});
jest.mock('../../services/app_context', () => {
const { loggerMock } = jest.requireActual('@kbn/logging-mocks');
return {
appContextService: {
getLogger: () => loggerMock.create(),
},
};
});
describe('Migrate handlers', () => {
describe('migrateSingleAgentHandler', () => {
let mockResponse: jest.Mocked<KibanaResponseFactory>;
let mockRequest: any;
let mockSavedObjectsClient: jest.Mocked<SavedObjectsClientContract>;
let mockElasticsearchClient: jest.Mocked<ElasticsearchClient>;
let mockContext: any;
const agentId = 'agent-id';
const mockAgent = { id: agentId, components: [] };
const mockAgentPolicy = { id: 'policy-id', is_protected: false };
const mockSettings = { enrollment_token: 'token123', uri: 'https://example.com' };
const mockActionResponse = { id: 'action-id' };
beforeEach(() => {
jest.clearAllMocks();
mockResponse = httpServerMock.createResponseFactory();
mockSavedObjectsClient = savedObjectsClientMock.create();
mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
mockRequest = {
params: { agentId },
body: mockSettings,
};
// Setup the context with correct structure
mockContext = {
core: {
elasticsearch: {
client: {
asInternalUser: mockElasticsearchClient,
},
},
savedObjects: {
client: mockSavedObjectsClient,
},
},
fleet: {},
};
// Default mock returns
(AgentService.getAgentById as jest.Mock).mockResolvedValue(mockAgent);
(AgentService.getAgentPolicyForAgent as jest.Mock).mockResolvedValue(mockAgentPolicy);
(AgentService.migrateSingleAgent as jest.Mock).mockResolvedValue({
actionId: mockActionResponse.id,
});
});
it('calls migrateSingleAgent with correct parameters and returns success', async () => {
await migrateSingleAgentHandler(mockContext, mockRequest, mockResponse);
// Verify services were called with correct parameters
expect(AgentService.getAgentById).toHaveBeenCalledWith(
mockElasticsearchClient,
mockSavedObjectsClient,
agentId
);
expect(AgentService.getAgentPolicyForAgent).toHaveBeenCalledWith(
mockSavedObjectsClient,
mockElasticsearchClient,
agentId
);
expect(AgentService.migrateSingleAgent).toHaveBeenCalledWith(
mockElasticsearchClient,
agentId,
mockAgentPolicy,
mockAgent,
{
...mockSettings,
policyId: mockAgentPolicy.id,
}
);
// Verify response was returned correctly
expect(mockResponse.ok).toHaveBeenCalledWith({
body: { actionId: mockActionResponse.id },
});
});
it('returns error when agent belongs to a protected policy', async () => {
// Mock agent policy as protected
(AgentService.getAgentPolicyForAgent as jest.Mock).mockResolvedValue({
...mockAgentPolicy,
is_protected: true,
});
// Change the migrateSingleAgent mock to be an error
(AgentService.migrateSingleAgent as jest.Mock).mockRejectedValue(
new FleetUnauthorizedError('Agent is protected and cannot be migrated')
);
await expect(
migrateSingleAgentHandler(mockContext, mockRequest, mockResponse)
).rejects.toThrow('Agent is protected and cannot be migrated');
});
it('returns error when agent is a fleet-server agent', async () => {
// Mock agent as fleet-server agent
(AgentService.getAgentById as jest.Mock).mockResolvedValue({
...mockAgent,
components: [{ type: 'fleet-server' }],
});
// Change the migrateSingleAgent mock to be an error
(AgentService.migrateSingleAgent as jest.Mock).mockRejectedValue(
new FleetUnauthorizedError('Agent is protected and cannot be migrated')
);
await expect(
migrateSingleAgentHandler(mockContext, mockRequest, mockResponse)
).rejects.toThrow('Agent is protected and cannot be migrated');
});
it('returns error when agent is not found', async () => {
const agentError = new AgentNotFoundError('Agent not found');
(AgentService.getAgentById as jest.Mock).mockRejectedValue(agentError);
await expect(
migrateSingleAgentHandler(mockContext, mockRequest, mockResponse)
).rejects.toThrow(agentError.message);
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { TypeOf } from '@kbn/config-schema';
import type { FleetRequestHandler, MigrateSingleAgentRequestSchema } from '../../types';
import * as AgentService from '../../services/agents';
export const migrateSingleAgentHandler: FleetRequestHandler<
TypeOf<typeof MigrateSingleAgentRequestSchema.params>,
undefined,
TypeOf<typeof MigrateSingleAgentRequestSchema.body>
> = async (context, request, response) => {
const [coreContext] = await Promise.all([context.core, context.fleet]);
const esClient = coreContext.elasticsearch.client.asInternalUser;
const soClient = coreContext.savedObjects.client;
const options = request.body;
// First validate the agent exists
const agent = await AgentService.getAgentById(esClient, soClient, request.params.agentId);
// Using the agent id, get the agent policy
const agentPolicy = await AgentService.getAgentPolicyForAgent(
soClient,
esClient,
request.params.agentId
);
const body = await AgentService.migrateSingleAgent(
esClient,
request.params.agentId,
agentPolicy,
agent,
{
...options,
policyId: agentPolicy?.id,
}
);
return response.ok({ body });
};

View file

@ -69,6 +69,9 @@ export async function createAgentAction(
traceparent: apm.currentTraceparent,
is_automatic: newAgentAction.is_automatic,
policyId: newAgentAction.policyId,
enrollment_token: newAgentAction.enrollment_token,
target_uri: newAgentAction.target_uri,
settings: newAgentAction.additionalSettings ? newAgentAction.additionalSettings : undefined,
};
const messageSigningService = appContextService.getMessageSigningService();
@ -79,7 +82,6 @@ export async function createAgentAction(
signature: signedBody.signature,
};
}
await esClient.create({
index: AGENT_ACTIONS_INDEX,
id: uuidv4(),

View file

@ -15,6 +15,7 @@ export * from './reassign';
export * from './update_agent_tags';
export * from './action_status';
export * from './request_diagnostics';
export * from './migrate';
export { getAgentUploads, getAgentUploadFile, deleteAgentUploadFile } from './uploads';
export { AgentServiceImpl } from './agent_service';
export type { AgentClient, AgentService } from './agent_service';

View file

@ -0,0 +1,132 @@
/*
* 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 { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import type { AgentPolicy, Agent } from '../../types';
import { migrateSingleAgent } from './migrate';
import { createAgentAction } from './actions';
// Mock the imported functions
jest.mock('./actions');
const mockedCreateAgentAction = createAgentAction as jest.MockedFunction<typeof createAgentAction>;
const mockedAgent: Agent = {
id: 'agent-123',
policy_id: 'policy-456',
last_checkin: new Date().toISOString(),
components: [],
local_metadata: {
elastic: {
agent: {
version: '1.0.0',
},
},
},
enrolled_at: new Date().toISOString(),
active: true,
packages: [],
type: 'PERMANENT',
};
const mockedPolicy: AgentPolicy = {
id: 'policy-456',
is_protected: false,
status: 'active',
is_managed: false,
updated_at: new Date().toISOString(),
updated_by: 'kibana',
revision: 1,
name: 'Test Policy',
namespace: 'default',
};
describe('Agent migration', () => {
let esClientMock: ReturnType<typeof elasticsearchServiceMock.createInternalClient>;
beforeEach(() => {
// Reset mocks before each test
jest.resetAllMocks();
esClientMock = elasticsearchServiceMock.createInternalClient();
// Mock the createAgentAction response
mockedCreateAgentAction.mockResolvedValue({
id: 'test-action-id',
type: 'MIGRATE',
agents: ['agent-123'],
created_at: new Date().toISOString(),
});
});
describe('migrateSingleAgent', () => {
it('should create a MIGRATE action for the specified agent', async () => {
const agentId = 'agent-123';
const options = {
policyId: 'policy-456',
enrollment_token: 'test-enrollment-token',
uri: 'https://test-fleet-server.example.com',
settings: { timeout: 300 },
};
const result = await migrateSingleAgent(
esClientMock,
agentId,
mockedPolicy,
mockedAgent,
options
);
// Verify createAgentAction was called with correct params
expect(mockedCreateAgentAction).toHaveBeenCalledTimes(1);
expect(mockedCreateAgentAction).toHaveBeenCalledWith(esClientMock, {
agents: [agentId],
created_at: expect.any(String),
type: 'MIGRATE',
policyId: options.policyId,
enrollment_token: options.enrollment_token,
target_uri: options.uri,
additionalSettings: options.settings,
});
// Verify result contains the action ID from createAgentAction
expect(result).toEqual({ actionId: 'test-action-id' });
});
it('should handle empty additional settings', async () => {
const agentId = 'agent-123';
const options = {
policyId: 'policy-456',
enrollment_token: 'test-enrollment-token',
uri: 'https://test-fleet-server.example.com',
};
await migrateSingleAgent(esClientMock, agentId, mockedPolicy, mockedAgent, options);
// Verify createAgentAction was called with correct params and undefined additionalSettings
expect(mockedCreateAgentAction).toHaveBeenCalledWith(
esClientMock,
expect.objectContaining({
additionalSettings: undefined,
})
);
});
it('should throw an error if the agent is protected', async () => {
const agentId = 'agent-123';
const options = {
policyId: 'policy-456',
enrollment_token: 'test-enrollment-token',
uri: 'https://test-fleet-server.example.com',
};
mockedPolicy.is_protected = true;
await expect(
migrateSingleAgent(esClientMock, agentId, mockedPolicy, mockedAgent, options)
).rejects.toThrowError('Agent is protected and cannot be migrated');
});
});
});

View file

@ -0,0 +1,36 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core/server';
import { FleetUnauthorizedError } from '../../errors';
import type { AgentPolicy, Agent } from '../../types';
import { createAgentAction } from './actions';
export async function migrateSingleAgent(
esClient: ElasticsearchClient,
agentId: string,
agentPolicy: AgentPolicy | undefined,
agent: Agent,
options: any
) {
// If the agent belongs to a policy that is protected or has fleet-server as a component meaning its a fleet server agent, throw an error
if (agentPolicy?.is_protected || agent.components?.some((c) => c.type === 'fleet-server')) {
throw new FleetUnauthorizedError(`Agent is protected and cannot be migrated`);
}
const response = await createAgentAction(esClient, {
agents: [agentId],
created_at: new Date().toISOString(),
type: 'MIGRATE',
policyId: options.policyId,
enrollment_token: options.enrollment_token,
target_uri: options.uri,
additionalSettings: options.settings,
});
return { actionId: response.id };
}

View file

@ -86,7 +86,21 @@ export const GetAgentsRequestSchema = {
}
),
};
export const MigrateOptionsSchema = {
ca_sha256: schema.maybe(schema.string()),
certificate_authorities: schema.maybe(schema.string()),
elastic_agent_cert: schema.maybe(schema.string()),
elastic_agent_cert_key: schema.maybe(schema.string()),
elastic_agent_cert_key_passphrase: schema.maybe(schema.string()),
headers: schema.maybe(schema.recordOf(schema.string(), schema.string())),
insecure: schema.maybe(schema.boolean()),
proxy_disabled: schema.maybe(schema.boolean()),
proxy_headers: schema.maybe(schema.recordOf(schema.string(), schema.string())),
proxy_url: schema.maybe(schema.string()),
staging: schema.maybe(schema.boolean()),
tags: schema.maybe(schema.arrayOf(schema.string())),
replace_token: schema.maybe(schema.boolean()),
};
export const AgentComponentStateSchema = schema.oneOf([
schema.literal('STARTING'),
schema.literal('CONFIGURING'),
@ -523,6 +537,19 @@ export const UpdateAgentRequestSchema = {
tags: schema.maybe(schema.arrayOf(schema.string())),
}),
};
export const MigrateSingleAgentRequestSchema = {
params: schema.object({
agentId: schema.string(),
}),
body: schema.object({
uri: schema.uri(),
enrollment_token: schema.string(),
settings: schema.maybe(schema.object(MigrateOptionsSchema)),
}),
};
export const MigrateSingleAgentResponseSchema = schema.object({
actionId: schema.string(),
});
export const PostBulkUpdateAgentTagsRequestSchema = {
body: schema.object({
@ -657,6 +684,7 @@ export const GetActionStatusResponseSchema = schema.object({
schema.literal('UPDATE_TAGS'),
schema.literal('POLICY_CHANGE'),
schema.literal('INPUT_ACTION'),
schema.literal('MIGRATE'),
]),
nbAgentsActioned: schema.number({
meta: {

View file

@ -27,5 +27,6 @@ export default function loadTests({ loadTestFile, getService }) {
loadTestFile(require.resolve('./uploads'));
loadTestFile(require.resolve('./get_agents_by_actions'));
loadTestFile(require.resolve('./privileges'));
loadTestFile(require.resolve('./migrate'));
});
}

View file

@ -0,0 +1,226 @@
/*
* 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 { AGENTS_INDEX } from '@kbn/fleet-plugin/common';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const esArchiver = getService('esArchiver');
const supertest = getService('supertest');
const es = getService('es');
describe('fleet_agents_migrate', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/fleet/agents');
// Create agent policies using the Fleet API
// Policy 1 - regular policy without tamper protection
const policy1Response = await supertest
.post(`/api/fleet/agent_policies`)
.set('kbn-xsrf', 'xx')
.send({
name: 'Policy 1',
namespace: 'default',
description: 'Test policy 1',
monitoring_enabled: ['logs', 'metrics'],
})
.expect(200);
const policy1 = policy1Response.body.item;
// Policy 2 - with tamper protection
const policy2Response = await supertest
.post(`/api/fleet/agent_policies`)
.set('kbn-xsrf', 'xx')
.send({
name: 'Policy 2',
namespace: 'default',
description: 'Test policy 2 with tamper protection',
monitoring_enabled: ['logs', 'metrics'],
})
.expect(200);
const policy2 = policy2Response.body.item;
// First, install the endpoint package which is required for the endpoint package policy
await supertest
.post('/api/fleet/epm/packages/endpoint')
.set('kbn-xsrf', 'xx')
.send({ force: true })
.expect(200);
// Fetch the installed package to get its current version
const packageInfoResponse = await supertest
.get('/api/fleet/epm/packages/endpoint')
.set('kbn-xsrf', 'xx')
.expect(200);
const endpointPackageVersion = packageInfoResponse.body.item.version;
// Create Elastic Defend package policy for policy2 with proper configuration
await supertest
.post(`/api/fleet/package_policies`)
.set('kbn-xsrf', 'xx')
.send({
name: 'endpoint-1',
description: 'Endpoint Security Integration',
namespace: 'default',
policy_id: policy2.id,
enabled: true,
inputs: [
{
type: 'endpoint',
enabled: true,
streams: [],
config: {
policy: {
value: {
windows: {
events: {
dll_and_driver_load: true,
dns: true,
file: true,
network: true,
process: true,
registry: true,
security: true,
},
malware: { mode: 'prevent' },
ransomware: { mode: 'prevent' },
memory_protection: { mode: 'prevent' },
behavior_protection: { mode: 'prevent' },
popup: {
malware: { enabled: true, message: '' },
ransomware: { enabled: true, message: '' },
},
},
mac: {
events: { file: true, network: true, process: true },
malware: { mode: 'prevent' },
behavior_protection: { mode: 'prevent' },
popup: { malware: { enabled: true, message: '' } },
},
linux: {
events: { file: true, network: true, process: true },
malware: { mode: 'prevent' },
behavior_protection: { mode: 'prevent' },
popup: { malware: { enabled: true, message: '' } },
},
},
},
},
},
],
package: {
name: 'endpoint',
title: 'Elastic Defend',
version: endpointPackageVersion, // Use the actual installed version
},
})
.expect(200);
// Now enable tamper protection on policy2
await supertest
.put(`/api/fleet/agent_policies/${policy2.id}`)
.set('kbn-xsrf', 'xx')
.send({
name: policy2.name,
namespace: 'default',
description: policy2.description,
is_protected: true, // Enable tamper protection
})
.expect(200);
// Create agents in Elasticsearch
await es.index({
refresh: 'wait_for',
index: AGENTS_INDEX,
id: 'agent1',
document: {
policy_id: policy1.id,
},
});
await es.index({
refresh: 'wait_for',
index: AGENTS_INDEX,
id: 'agent2',
document: {
policy_id: policy2.id, // Policy 2 is tamper protected
},
});
await es.index({
refresh: 'wait_for',
index: AGENTS_INDEX,
id: 'agent3',
document: {
policy_id: policy1.id,
components: [
{
type: 'fleet-server',
id: 'fleet-server',
revision: 1,
},
],
},
});
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents');
// Cleanup will be handled automatically by Fleet API
});
describe('POST /agents/{agentId}/migrate', () => {
it('should return a 200 if the migration action is successful', async () => {
const {} = await supertest
.post(`/api/fleet/agents/agent1/migrate`)
.set('kbn-xsrf', 'xx')
.send({
enrollment_token: '1234',
uri: 'https://example.com',
})
.expect(200);
});
it('should return a 403 if the agent is tamper protected', async () => {
const {} = await supertest
.post(`/api/fleet/agents/agent2/migrate`)
.set('kbn-xsrf', 'xx')
.send({
enrollment_token: '1234',
uri: 'https://example.com',
})
.expect(403);
});
it('should return a 403 if the agent is a fleet-agent', async () => {
const {} = await supertest
.post(`/api/fleet/agents/agent3/migrate`)
.set('kbn-xsrf', 'xx')
.send({
enrollment_token: '1234',
uri: 'https://example.com',
})
.expect(403);
});
it('should return a 404 when agent does not exist', async () => {
await supertest
.post(`/api/fleet/agents/agent100/migrate`)
.set('kbn-xsrf', 'xx')
.send({
enrollment_token: '1234',
uri: 'https://example.com',
})
.expect(404);
});
});
});
}

View file

@ -314,6 +314,17 @@ export default function (providerContext: FtrProviderContext) {
beforeEach: updateAgentBeforeEach,
afterEach: updateAgentAfterEach,
},
{
method: 'POST',
path: '/api/fleet/agents/agent1/migrate',
scenarios: ALL_SCENARIOS,
send: {
enrollment_token: '1234',
uri: 'https://example.com',
},
beforeEach: updateAgentBeforeEach,
afterEach: updateAgentAfterEach,
},
{
method: 'DELETE',
path: '/api/fleet/agents/agent1',