Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -55,6 +55,7 @@ import { scheduleBumpAgentPoliciesByIdTask } from './agent_policies/bump_agent_p
import { createAgentPolicyWithPackages } from './agent_policy_create';
import { reassignAgentsFromVersionSpecificPolicies } from './utils/version_specific_policies';
import { agentlessAgentService } from './agents/agentless_agent';
import { unenrollForAgentPolicyId } from './agents';
import { getPackageInfo } from './epm/packages';
import { ensureInstalledPackage } from './epm/packages/install';

Expand Down Expand Up @@ -1014,6 +1015,66 @@ describe('Agent policy', () => {
]
);
});

it('should force-revoke agents before deleting an agentless policy', async () => {
const agentlessSoClient = getSavedObjectMock({
revision: 1,
name: 'Test agentless',
package_policies: ['package-1'],
supports_agentless: true,
});
// agentless policies are allowed to have active agents; the count check is bypassed
esClient.count.mockResolvedValue({ count: 1 } as any);
const deleteAgentlessAgentSpy = jest
.spyOn(agentlessAgentService, 'deleteAgentlessAgent')
.mockResolvedValue(undefined as any);

try {
await agentPolicyService.delete(agentlessSoClient, esClient, 'mocked');

expect(jest.mocked(unenrollForAgentPolicyId)).toHaveBeenCalledWith(
agentlessSoClient,
esClient,
'mocked',
{ revoke: true }
);
} finally {
deleteAgentlessAgentSpy.mockRestore();
}
});

it('should force-revoke agents before calling deleteAgentlessAgent', async () => {
const agentlessSoClient = getSavedObjectMock({
revision: 1,
name: 'Test agentless',
package_policies: ['package-1'],
supports_agentless: true,
});
esClient.count.mockResolvedValue({ count: 1 } as any);
const callOrder: string[] = [];

jest.mocked(unenrollForAgentPolicyId).mockImplementationOnce(async () => {
callOrder.push('unenrollForAgentPolicyId');
});
const deleteAgentlessAgentSpy = jest
.spyOn(agentlessAgentService, 'deleteAgentlessAgent')
.mockImplementationOnce(async () => {
callOrder.push('deleteAgentlessAgent');
return undefined as any;
});

try {
await agentPolicyService.delete(agentlessSoClient, esClient, 'mocked');

const unenrollIdx = callOrder.indexOf('unenrollForAgentPolicyId');
const deleteIdx = callOrder.indexOf('deleteAgentlessAgent');
expect(unenrollIdx).toBeGreaterThanOrEqual(0);
expect(deleteIdx).toBeGreaterThanOrEqual(0);
expect(unenrollIdx).toBeLessThan(deleteIdx);
} finally {
deleteAgentlessAgentSpy.mockRestore();
}
});
});

describe('bumpRevision', () => {
Expand Down Expand Up @@ -3743,23 +3804,28 @@ describe('Agent policy', () => {
});

it('should roll back the verifier policy and re-throw when deployPolicy fails', async () => {
jest
const deploySpy = jest
.spyOn(agentPolicyService, 'deployPolicy')
.mockRejectedValueOnce(new Error('agentless provisioning limit'));
const deleteSpy = jest
.spyOn(agentPolicyService, 'deleteVerifierPolicy')
.mockResolvedValue(undefined);

await expect(
agentPolicyService.createVerifierPolicy(
soClient,
esClient,
baseConnector as any,
baseVerificationInfo
)
).rejects.toThrow('agentless provisioning limit');

expect(deleteSpy).toHaveBeenCalledWith(soClient, esClient, 'mocked');
try {
await expect(
agentPolicyService.createVerifierPolicy(
soClient,
esClient,
baseConnector as any,
baseVerificationInfo
)
).rejects.toThrow('agentless provisioning limit');

expect(deleteSpy).toHaveBeenCalledWith(soClient, esClient, 'mocked');
} finally {
deleteSpy.mockRestore();
deploySpy.mockRestore();
}
});

it('should propagate secret_references from created package policy', async () => {
Expand Down Expand Up @@ -3874,6 +3940,36 @@ describe('Agent policy', () => {
});
});

describe('deleteVerifierPolicy', () => {
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
const soClient = createSavedObjectClientMock();

it('should delegate to delete with force: true', async () => {
const deleteSpy = jest.spyOn(agentPolicyService, 'delete').mockResolvedValue({} as any);
try {
await agentPolicyService.deleteVerifierPolicy(soClient, esClient, 'verifier-policy-1');
expect(deleteSpy).toHaveBeenCalledWith(soClient, esClient, 'verifier-policy-1', {
force: true,
});
} finally {
deleteSpy.mockRestore();
}
});

it('should catch and log errors without re-throwing', async () => {
const deleteSpy = jest
.spyOn(agentPolicyService, 'delete')
.mockRejectedValue(new Error('delete failed'));
try {
await expect(
agentPolicyService.deleteVerifierPolicy(soClient, esClient, 'verifier-policy-1')
).resolves.toBeUndefined();
} finally {
deleteSpy.mockRestore();
}
});
});

describe('getLatestFleetPolicyRevisions', () => {
it('returns an empty map without querying when no policy ids are provided', async () => {
const esClient = elasticsearchServiceMock.createInternalClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1724,6 +1724,7 @@ class AgentPolicyService {

if (agentPolicy?.supports_agentless) {
logger.debug(`Starting unenrolling agent from agentless policy ${id}`);
await unenrollForAgentPolicyId(soClient, esClient, id, { revoke: true });
// unenroll offline agents for agentless policies first to avoid 404 Save Object error
await this.triggerAgentPolicyUpdatedEvent(esClient, 'deleted', id, {
spaceId: soClient.getCurrentNamespace(),
Expand Down Expand Up @@ -2969,9 +2970,6 @@ class AgentPolicyService {
): Promise<void> {
const logger = this.getLogger('deleteVerifierPolicy');
try {
// Force-revoke agents before deleting the policy because the agentless deployment
// is destroyed immediately, so agents can never check in to acknowledge a graceful unenroll.
await unenrollForAgentPolicyId(soClient, esClient, policyId, { revoke: true });
await this.delete(soClient, esClient, policyId, { force: true });
} catch (err) {
logger.error(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* 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 expect from '@kbn/expect';
import type * as http from 'http';
import { v4 as uuidv4 } from 'uuid';

import type { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { skipIfNoDockerRegistry } from '../../helpers';
import { setupMockServer } from '../agents/helpers/mock_agentless_api';
import { SpaceTestApiClient } from '../space_awareness/api_helper';
import { cleanFleetIndices } from '../space_awareness/helpers';

export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const es = getService('es');
const kibanaServer = getService('kibanaServer');
const supertest = getService('supertest');

describe('fleet_agentless_policy_delete_unenroll', () => {
skipIfNoDockerRegistry(providerContext);

const apiClient = new SpaceTestApiClient(supertest);

let mockApiServer: http.Server;
let policyId: string;
let agentId: string;

before(async () => {
const mockAgentlessApiService = setupMockServer();
// Start the mock agentless API server so that deleteAgentlessAgent HTTP calls succeed
// and do not cause the service to throw before we can check the agent document state.
mockApiServer = await mockAgentlessApiService.listen(8089);
});

after(async () => {
if (!mockApiServer) return;
await new Promise<void>((resolve, reject) => {
mockApiServer.close((err?: Error) => (err ? reject(err) : resolve()));
});
});

beforeEach(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await cleanFleetIndices(es);
await apiClient.setup();

policyId = uuidv4();
agentId = `agent-agentless-${policyId}`;

// Create the agentless policy (also creates the corresponding agent policy SO
// with supports_agentless: true).
await apiClient.createAgentlessPolicy({
id: policyId,
package: {
name: 'test_agentless',
version: '1.0.0',
},
name: `test_agentless_delete_unenroll-${Date.now()}`,
description: 'test agentless unenroll on delete',
namespace: 'default',
inputs: {
'sample-httpjson': {
enabled: true,
vars: {
api_key: 'TEST_VALUE_API_KEY',
},
streams: {},
},
},
});

// Manually insert a fake agent document into .fleet-agents tied to the agentless
// policy. We set active: true to simulate the orphaned-agent scenario — the agent
// has enrolled but the agentless deployment will be torn down immediately on delete.
await es.index({
index: '.fleet-agents',
id: agentId,
refresh: 'wait_for',
document: {
id: agentId,
type: 'PERMANENT',
active: true,
enrolled_at: new Date().toISOString(),
last_checkin: new Date().toISOString(),
policy_id: policyId,
policy_revision_idx: 1,
policy_revision: 1,
agent: { id: agentId, version: '8.16.0' },
local_metadata: {
elastic: { agent: { version: '8.16.0', upgradeable: false } },
host: { hostname: 'agentless-host' },
os: { platform: 'linux' },
},
},
});
});

afterEach(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await cleanFleetIndices(es);
});

it('should force-unenroll the agent document (active: false, unenrolled_at set) when deleting an agentless policy', async () => {
// Delete the agentless policy via the managed integrations endpoint. This triggers
// agentPolicyService.delete() → unenrollForAgentPolicyId(…, { revoke: true })
// BEFORE deleteAgentlessAgent() is called.
await apiClient.deleteAgentlessPolicy(policyId);

// Fetch the agent document directly from the index to check its final state.
const agentDoc = await es.get({
index: '.fleet-agents',
id: agentId,
});

const source = agentDoc._source as Record<string, unknown>;

// The fix: force-revoke must have set active: false and unenrolled_at synchronously
// before the agentless deployment was destroyed.
expect(source.active).to.be(false);
expect(source.unenrolled_at).to.be.a('string');
// unenrolled_at must be a non-empty ISO timestamp
expect((source.unenrolled_at as string).length).to.be.greaterThan(0);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
export default function loadTests({ loadTestFile }) {
describe('Agentless', () => {
loadTestFile(require.resolve('./agentless_policies'));
loadTestFile(require.resolve('./agentless_policy_delete_unenroll'));
});
}
Loading