Skip to content

Commit db5b8f6

Browse files
committed
[Fleet] Allow to delete orphaned agentless policies
1 parent 209d0b6 commit db5b8f6

3 files changed

Lines changed: 133 additions & 1 deletion

File tree

x-pack/platform/plugins/shared/fleet/server/services/agentless/agentless_policies.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { createAppContextStartContractMock, createPackagePolicyServiceMock } fro
1616
import { getPackageInfo } from '../epm/packages';
1717
import { appContextService, cloudConnectorService } from '..';
1818
import { agentPolicyService } from '../agent_policy';
19+
import { agentlessAgentService } from '../agents/agentless_agent';
1920

2021
import { AgentlessPoliciesServiceImpl } from './agentless_policies';
2122

@@ -310,6 +311,78 @@ describe('AgentlessPoliciesService', () => {
310311

311312
expect(jest.mocked(agentPolicyService.delete)).toHaveBeenCalledTimes(0);
312313
});
314+
315+
it('should clean up orphaned resources when agent policy is not found (404)', async () => {
316+
const deleteAgentlessAgentSpy = jest
317+
.spyOn(agentlessAgentService, 'deleteAgentlessAgent')
318+
.mockResolvedValueOnce(undefined as any);
319+
320+
jest.mocked(agentPolicyService.get).mockRejectedValueOnce({
321+
output: { statusCode: 404 },
322+
});
323+
324+
packagePolicyService.findAllForAgentPolicy.mockResolvedValueOnce([
325+
{ id: 'orphaned-pp-1' },
326+
{ id: 'orphaned-pp-2' },
327+
] as any);
328+
329+
packagePolicyService.delete.mockResolvedValueOnce([
330+
{ id: 'orphaned-pp-1', success: true },
331+
{ id: 'orphaned-pp-2', success: true },
332+
] as any);
333+
334+
const soClient = savedObjectsClientMock.create();
335+
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
336+
const logger = loggingSystemMock.createLogger();
337+
338+
const agentlessPoliciesService = new AgentlessPoliciesServiceImpl(
339+
packagePolicyService,
340+
soClient,
341+
esClient,
342+
logger
343+
);
344+
345+
await agentlessPoliciesService.deleteAgentlessPolicy('orphaned-policy-id');
346+
347+
expect(jest.mocked(agentPolicyService.delete)).not.toHaveBeenCalled();
348+
expect(packagePolicyService.findAllForAgentPolicy).toHaveBeenCalledWith(
349+
soClient,
350+
'orphaned-policy-id'
351+
);
352+
expect(packagePolicyService.delete).toHaveBeenCalledWith(
353+
soClient,
354+
esClient,
355+
['orphaned-pp-1', 'orphaned-pp-2'],
356+
expect.objectContaining({ force: true })
357+
);
358+
expect(deleteAgentlessAgentSpy).toHaveBeenCalledWith('orphaned-policy-id');
359+
360+
deleteAgentlessAgentSpy.mockRestore();
361+
});
362+
363+
it('should rethrow non-404 errors from agentPolicyService.get', async () => {
364+
jest.mocked(agentPolicyService.get).mockRejectedValueOnce({
365+
output: { statusCode: 500 },
366+
message: 'Internal server error',
367+
});
368+
369+
const soClient = savedObjectsClientMock.create();
370+
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
371+
const logger = loggingSystemMock.createLogger();
372+
373+
const agentlessPoliciesService = new AgentlessPoliciesServiceImpl(
374+
packagePolicyService,
375+
soClient,
376+
esClient,
377+
logger
378+
);
379+
380+
await expect(() =>
381+
agentlessPoliciesService.deleteAgentlessPolicy('some-policy-id')
382+
).rejects.toEqual(expect.objectContaining({ output: { statusCode: 500 } }));
383+
384+
expect(jest.mocked(agentPolicyService.delete)).not.toHaveBeenCalled();
385+
});
313386
});
314387

315388
describe('createAgentlessPolicy with cloud connectors', () => {

x-pack/platform/plugins/shared/fleet/server/services/agentless/agentless_policies.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type Logger,
1212
type RequestHandlerContext,
1313
type SavedObjectsClientContract,
14+
SavedObjectsErrorHelpers,
1415
} from '@kbn/core/server';
1516
import type { TypeOf } from '@kbn/config-schema';
1617
import { v4 as uuidv4 } from 'uuid';
@@ -274,7 +275,18 @@ export class AgentlessPoliciesServiceImpl implements AgentlessPoliciesService {
274275
? appContextService.getSecurityCore().authc.getCurrentUser(request) || undefined
275276
: undefined;
276277

277-
const agentPolicy = await agentPolicyService.get(this.soClient, policyId);
278+
let agentPolicy;
279+
try {
280+
agentPolicy = await agentPolicyService.get(this.soClient, policyId);
281+
} catch (e) {
282+
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
283+
this.logger.warn(`Agent policy ${policyId} not found, cleaning up orphaned resources`);
284+
await this.deleteOrphanedAgentlessResources(policyId, user);
285+
return;
286+
}
287+
throw e;
288+
}
289+
278290
if (!agentPolicy?.supports_agentless) {
279291
throw new Error(`Policy ${policyId} is not an agentless policy`);
280292
}
@@ -285,4 +297,28 @@ export class AgentlessPoliciesServiceImpl implements AgentlessPoliciesService {
285297
user,
286298
});
287299
}
300+
301+
private async deleteOrphanedAgentlessResources(policyId: string, user?: any) {
302+
const packagePolicies = await this.packagePolicyService.findAllForAgentPolicy(
303+
this.soClient,
304+
policyId
305+
);
306+
307+
if (packagePolicies.length > 0) {
308+
await this.packagePolicyService.delete(
309+
this.soClient,
310+
this.esClient,
311+
packagePolicies.map((pp) => pp.id),
312+
{ force: true, user: user ?? undefined }
313+
);
314+
}
315+
316+
try {
317+
await agentlessAgentService.deleteAgentlessAgent(policyId);
318+
} catch (e) {
319+
this.logger.warn(
320+
`Failed to delete agentless deployment for orphaned policy ${policyId}: ${e.message}`
321+
);
322+
}
323+
}
288324
}

x-pack/platform/test/fleet_api_integration/apis/agentless/agentless_policies.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,29 @@ export default function (providerContext: FtrProviderContext) {
329329
expect(apiCalls[0].url).to.be(`/agentless-api/api/v1/ess/deployments/${policyId}`);
330330
expect(apiCalls[0].method).to.be('DELETE');
331331
});
332+
333+
it('should allow to delete an orphaned agentless policy when agent policy is missing', async () => {
334+
// Orphan the policy by directly deleting the agent policy SO
335+
await es.delete({
336+
index: '.kibana_ingest',
337+
id: `fleet-agent-policies:${policyId}`,
338+
refresh: 'wait_for',
339+
});
340+
341+
// Verify agent policy is gone
342+
await expectToRejectWithNotFound(() => apiClient.getAgentPolicy(policyId));
343+
344+
// Delete via agentless API should succeed despite missing agent policy
345+
await apiClient.deleteAgentlessPolicy(policyId);
346+
347+
// Verify package policy is cleaned up
348+
await expectToRejectWithNotFound(() => apiClient.getPackagePolicy(policyId));
349+
350+
// Verify agentless API DELETE was called to clean up the deployment
351+
expect(apiCalls.length).to.be(1);
352+
expect(apiCalls[0].url).to.be(`/agentless-api/api/v1/ess/deployments/${policyId}`);
353+
expect(apiCalls[0].method).to.be('DELETE');
354+
});
332355
});
333356

334357
describe('Update Agentless Policy', () => {

0 commit comments

Comments
 (0)