[Fleet] Add Edit ReadMe Functionality To Custom Integrations (#215259)

Closes #212957 

Adds editing of the automatically-generated readMe file on custom
integrations.

- Allows the user to make edits and then save the readMe changes via a
new endpoint
 - Reloads the UI after making changes to reflect the updated readMe
 -  Automatically updates associated policies

To test the new endpoint, you will need a custom integration installed,
then use it as such

```
PUT kbn:/api/fleet/epm/custom_integrations/{pkgName}
{
    "readMeData": "New README content here"

}
```
 


https://github.com/user-attachments/assets/9a6f2197-aa7f-4610-9476-c1f8b4865c62



### Acceptance criteria

- [ ] An "edit" button with an icon appears in the README section of the
integration overview page for custom integrations generated by automatic
import
- [ ] Clicking the "edit" button should open a modal (TBD) containing a
markdown editor that allows the user to edit the content
- [ ] Users can save their changes, and the updated README content is
persisted
- [ ] Saving updates the version of the package and reloads the content
- [ ] Saving also starts updating policies automatically


### 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
Adds edit functionality to custom integrations, allowing a user to edit
the README file of a custom integration and save it to be persisted.
Additionally, saving will automatically increment the version of the
integration and update all associated policies.

---------

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-04-23 11:41:36 -06:00 committed by GitHub
parent f987716d43
commit 3571878acd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1248 additions and 28 deletions

View file

@ -24161,6 +24161,93 @@
]
}
},
"/api/fleet/epm/custom_integrations/{pkgName}": {
"put": {
"description": "[Required authorization] Route required privileges: fleet-settings-all AND integrations-all.",
"operationId": "put-fleet-epm-custom-integrations-pkgname",
"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": "pkgName",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"categories": {
"items": {
"type": "string"
},
"type": "array"
},
"readMeData": {
"type": "string"
}
},
"required": [
"readMeData"
],
"type": "object"
}
}
}
},
"responses": {
"200": {},
"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": "Update a custom integration",
"tags": [
"Elastic Package Manager (EPM)"
]
}
},
"/api/fleet/epm/data_streams": {
"get": {
"description": "[Required authorization] Route required privileges: integrations-read OR fleet-setup OR fleet-all.",

View file

@ -24161,6 +24161,93 @@
]
}
},
"/api/fleet/epm/custom_integrations/{pkgName}": {
"put": {
"description": "[Required authorization] Route required privileges: fleet-settings-all AND integrations-all.",
"operationId": "put-fleet-epm-custom-integrations-pkgname",
"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": "pkgName",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"categories": {
"items": {
"type": "string"
},
"type": "array"
},
"readMeData": {
"type": "string"
}
},
"required": [
"readMeData"
],
"type": "object"
}
}
}
},
"responses": {
"200": {},
"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": "Update a custom integration",
"tags": [
"Elastic Package Manager (EPM)"
]
}
},
"/api/fleet/epm/data_streams": {
"get": {
"description": "[Required authorization] Route required privileges: integrations-read OR fleet-setup OR fleet-all.",

View file

@ -25575,6 +25575,63 @@ paths:
summary: Create a custom integration
tags:
- Elastic Package Manager (EPM)
/api/fleet/epm/custom_integrations/{pkgName}:
put:
description: '[Required authorization] Route required privileges: fleet-settings-all AND integrations-all.'
operationId: put-fleet-epm-custom-integrations-pkgname
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: pkgName
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
additionalProperties: false
type: object
properties:
categories:
items:
type: string
type: array
readMeData:
type: string
required:
- readMeData
responses:
'200': {}
'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: Update a custom integration
tags:
- Elastic Package Manager (EPM)
/api/fleet/epm/data_streams:
get:
description: '[Required authorization] Route required privileges: integrations-read OR fleet-setup OR fleet-all.'

View file

@ -27817,6 +27817,63 @@ paths:
summary: Create a custom integration
tags:
- Elastic Package Manager (EPM)
/api/fleet/epm/custom_integrations/{pkgName}:
put:
description: '[Required authorization] Route required privileges: fleet-settings-all AND integrations-all.'
operationId: put-fleet-epm-custom-integrations-pkgname
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: pkgName
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
additionalProperties: false
type: object
properties:
categories:
items:
type: string
type: array
readMeData:
type: string
required:
- readMeData
responses:
'200': {}
'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: Update a custom integration
tags:
- Elastic Package Manager (EPM)
/api/fleet/epm/data_streams:
get:
description: '[Required authorization] Route required privileges: integrations-read OR fleet-setup OR fleet-all.'

View file

@ -40,6 +40,7 @@ export const EPM_API_ROUTES = {
INSTALL_FROM_REGISTRY_PATTERN: EPM_PACKAGES_ONE_WITH_OPTIONAL_VERSION,
INSTALL_BY_UPLOAD_PATTERN: EPM_PACKAGES_MANY,
CUSTOM_INTEGRATIONS_PATTERN: `${EPM_API_ROOT}/custom_integrations`,
UPDATE_CUSTOM_INTEGRATIONS_PATTERN: `${EPM_API_ROOT}/custom_integrations/{pkgName}`,
DELETE_PATTERN: EPM_PACKAGES_ONE_WITH_OPTIONAL_VERSION,
INSTALL_KIBANA_ASSETS_PATTERN: `${EPM_PACKAGES_ONE}/kibana_assets`,
DELETE_KIBANA_ASSETS_PATTERN: `${EPM_PACKAGES_ONE}/kibana_assets`,

View file

@ -142,6 +142,9 @@ export const epmRouteService = {
pkgVersion
);
},
getUpdateCustomIntegrationsPath: (pkgName: string) => {
return EPM_API_ROUTES.UPDATE_CUSTOM_INTEGRATIONS_PATTERN.replace('{pkgName}', pkgName);
},
};
export const packagePolicyRouteService = {

View file

@ -0,0 +1,18 @@
/*
* 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.
*/
export interface UpdateCustomIntegrationRequest {
readMeData?: string;
categories?: string[];
}
export interface UpdateCustomIntegrationResponse {
id: string;
result: {
version: string;
status: string;
};
}

View file

@ -22,3 +22,4 @@ export * from './health_check';
export * from './fleet_server_hosts';
export * from './standalone_agent_api_key';
export * from './remote_synced_integrations';
export * from './custom_integrations';

View file

@ -0,0 +1,23 @@
/*
* 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 { EuiButton } from '@elastic/eui';
import React from 'react';
interface EditIntegrationButtonProps {
handleEditIntegrationClick: Function;
}
export function EditIntegrationButton(props: EditIntegrationButtonProps) {
const { handleEditIntegrationClick } = props;
return (
<EuiButton
onClick={(e: React.MouseEvent) => handleEditIntegrationClick(e)}
iconType="pencil"
aria-label="Edit"
/>
);
}

View file

@ -0,0 +1,177 @@
/*
* 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 React, { useState, useEffect } from 'react';
import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiMarkdownEditor,
EuiButton,
EuiButtonEmpty,
EuiLoadingSpinner,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { FleetStartServices } from '../../../../../../../plugin';
import { sendGetFileByPath, useUpdateCustomIntegration } from '../../../../../../../hooks';
import type { PackageInfo } from '../../../../../types';
export const EditIntegrationFlyout: React.FunctionComponent<{
onClose: () => void;
integrationName: string;
miniIcon: React.ReactNode;
packageInfo: PackageInfo | null;
setIsEditOpen: (isOpen: boolean) => void;
integration: string | null;
services: FleetStartServices;
onComplete: (arg0: {}) => void;
}> = ({
onClose,
integrationName,
miniIcon,
packageInfo,
setIsEditOpen,
integration,
services,
onComplete,
}) => {
const updateCustomIntegration = useUpdateCustomIntegration;
const [editedContent, setEditedContent] = useState<string>();
const [savingEdits, setSavingEdits] = useState(false);
const [readmeLoading, setReadmeLoading] = useState(true);
// get the readme content from the packageInfo
useEffect(() => {
const readmePath = packageInfo?.readme;
if (!readmePath) {
setReadmeLoading(false);
return;
}
sendGetFileByPath(readmePath).then((res) => {
setEditedContent(res.data || '');
setReadmeLoading(false);
});
}, [packageInfo]);
const saveIntegrationEdits = async (updatedReadMe: string | undefined) => {
setSavingEdits(true);
const res = await updateCustomIntegration(packageInfo?.name || '', {
readMeData: updatedReadMe,
categories: [],
});
setSavingEdits(false);
setIsEditOpen(false);
// if everything is okay, then show success and redirect to new page
if (!res.error) {
services.notifications.toasts.addSuccess({
title: i18n.translate('xpack.fleet.epm.editReadMeSuccessToastTitle', {
defaultMessage: 'README updated',
}),
text: i18n.translate('xpack.fleet.epm.editReadMeSuccessToastText', {
defaultMessage:
'The README content has been updated successfully. Redirecting you to the updated integration.',
}),
});
setTimeout(() => {
// navigate to new page after 2 seconds
const urlParts = {
pkgkey: `${packageInfo?.name}-${res.data.result.version}`,
...(integration ? { integration } : {}),
};
onComplete(urlParts);
}, 2000);
} else {
services.notifications.toasts.addError(res.error, {
title: i18n.translate('xpack.fleet.epm.editReadMeErrorToastTitle', {
defaultMessage: 'Error updating README file',
}),
toastMessage: i18n.translate('xpack.fleet.epm.editReadMeErrorToastText', {
defaultMessage: 'There was an error updating the README content.',
}),
});
}
};
return (
<EuiFlyout ownFocus onClose={onClose} aria-labelledby="editIntegrationFlyoutTitle">
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>{miniIcon}</EuiFlexItem>
<EuiFlexItem>
<EuiTitle>
<h2 id="editIntegrationFlyoutTitle">
<FormattedMessage
id="xpack.fleet.epm.editIntegrationFlyout.title"
defaultMessage="Editing {integrationName}"
values={{ integrationName }}
/>
</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{readmeLoading ? (
<EuiLoadingSpinner />
) : (
<EuiMarkdownEditor
aria-label="Edit"
placeholder={`${i18n.translate(
'xpack.fleet.epm.editIntegrationFlyout.markdownEditorPlaceholder',
{
defaultMessage: 'Edit the README content for {integrationName}...',
values: { integrationName },
}
)}...`}
value={editedContent!}
onChange={setEditedContent}
readOnly={false}
height={600}
/>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose}>
<FormattedMessage
id="xpack.fleet.editIntegrationFlyout.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
isLoading={savingEdits}
fill
color="primary"
onClick={() => saveIntegrationEdits(editedContent)}
>
<FormattedMessage
id="xpack.fleet.editIntegrationFlyout.saveButtonLabel"
defaultMessage="Save Changes"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -50,3 +50,14 @@ export function LoadingIconPanel() {
</Panel>
);
}
export interface MiniIconProps
extends Pick<UsePackageIconType, 'packageName' | 'integrationName' | 'version' | 'icons'> {
size?: number; // Optional size multiplier
}
export function MiniIcon({ packageName, integrationName, version, icons }: MiniIconProps) {
const iconType = usePackageIconType({ packageName, integrationName, version, icons });
return <EuiIcon type={iconType} size="l" />;
}

View file

@ -6,9 +6,10 @@
*/
export { BackLink } from './back_link';
export { AddIntegrationButton } from './add_integration_button';
export { EditIntegrationButton } from './edit_integration_button';
export { CloudPostureThirdPartySupportCallout } from './cloud_posture_third_party_support_callout';
export { UpdateIcon } from './update_icon';
export { IntegrationAgentPolicyCount } from './integration_agent_policy_count';
export { IconPanel, LoadingIconPanel } from './icon_panel';
export { IconPanel, LoadingIconPanel, MiniIcon } from './icon_panel';
export { KeepPoliciesUpToDateSwitch } from './keep_policies_up_to_date_switch';
export { BidirectionalIntegrationsBanner } from './bidirectional_integrations_callout';

View file

@ -72,6 +72,7 @@ import { PermissionsError } from '../../../../layouts';
import { DeferredAssetsWarning } from './assets/deferred_assets_warning';
import { useIsFirstTimeAgentUserQuery } from './hooks';
import { getInstallPkgRouteOptions } from './utils';
import {
BackLink,
@ -79,7 +80,9 @@ import {
UpdateIcon,
IconPanel,
LoadingIconPanel,
MiniIcon,
AddIntegrationButton,
EditIntegrationButton,
} from './components';
import { AssetsPage } from './assets';
import { OverviewPage } from './overview';
@ -91,6 +94,7 @@ import { Configs } from './configs';
import type { InstallPkgRouteOptions } from './utils/get_install_route_options';
import { InstallButton } from './settings/install_button';
import { EditIntegrationFlyout } from './components/edit_integration_flyout';
export type DetailViewPanelName =
| 'overview'
@ -105,7 +109,7 @@ export interface DetailParams {
pkgkey: string;
panel?: DetailViewPanelName;
}
const CUSTOM_INTEGRATION_SOURCES = ['custom', 'upload'];
const Divider = styled.div`
width: 0;
height: 100%;
@ -166,6 +170,10 @@ export function Detail() {
const isCloud = !!services?.cloud?.cloudId;
const agentPolicyIdFromContext = getAgentPolicyId();
const isOverviewPage = panel === 'overview';
// edit readme state
const [isEditOpen, setIsEditOpen] = useState(false);
const [shouldAllowEdit, setShouldAllowEdit] = useState(false);
// Package info state
const [packageInfo, setPackageInfo] = useState<PackageInfo | null>(null);
@ -293,7 +301,13 @@ export function Detail() {
if (packageInfoIsFetchedAfterMount && packageInfoData?.item) {
const packageInfoResponse = packageInfoData.item;
setPackageInfo(packageInfoResponse);
setShouldAllowEdit(
(packageInfoResponse?.installationInfo?.install_source &&
CUSTOM_INTEGRATION_SOURCES.includes(
packageInfoResponse.installationInfo?.install_source
)) ??
false
);
let installedVersion;
const { name } = packageInfoData.item;
if ('installationInfo' in packageInfoResponse) {
@ -396,6 +410,9 @@ export function Detail() {
[integrationInfo, isLoading, packageInfo, fromIntegrationsPath, queryParams]
);
const handleEditIntegrationClick = useCallback<ReactEventHandler>((ev) => {
setIsEditOpen(true);
}, []);
const handleAddIntegrationPolicyClick = useCallback<ReactEventHandler>(
(ev) => {
ev.preventDefault();
@ -560,19 +577,30 @@ export function Detail() {
isTourVisible={isOverviewPage && isGuidedOnboardingActive}
tourOffset={10}
>
<AddIntegrationButton
userCanInstallPackages={userCanInstallPackages}
href={getHref('add_integration_to_policy', {
pkgkey,
...(integration ? { integration } : {}),
...(agentPolicyIdFromContext
? { agentPolicyId: agentPolicyIdFromContext }
: {}),
})}
missingSecurityConfiguration={missingSecurityConfiguration}
packageName={integrationInfo?.title || packageInfo.title}
onClick={handleAddIntegrationPolicyClick}
/>
<EuiFlexGroup justifyContent="center" alignItems="center" gutterSize="s">
{shouldAllowEdit && (
<EuiFlexItem grow={false}>
<EditIntegrationButton
handleEditIntegrationClick={handleEditIntegrationClick}
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<AddIntegrationButton
userCanInstallPackages={userCanInstallPackages}
href={getHref('add_integration_to_policy', {
pkgkey,
...(integration ? { integration } : {}),
...(agentPolicyIdFromContext
? { agentPolicyId: agentPolicyIdFromContext }
: {}),
})}
missingSecurityConfiguration={missingSecurityConfiguration}
packageName={integrationInfo?.title || packageInfo.title}
onClick={handleAddIntegrationPolicyClick}
/>
</EuiFlexItem>
</EuiFlexGroup>
</WithGuidedOnboardingTour>
),
},
@ -612,6 +640,8 @@ export function Detail() {
showVersionSelect,
versionLabel,
versionOptions,
handleEditIntegrationClick,
shouldAllowEdit,
]
);
@ -857,6 +887,32 @@ export function Detail() {
<Redirect to={INTEGRATIONS_ROUTING_PATHS.integration_details_overview} />
</Routes>
)}
{isEditOpen && (
<EditIntegrationFlyout
integrationName={packageInfo?.title || 'Integration'}
onClose={() => setIsEditOpen(false)}
packageInfo={packageInfo}
setIsEditOpen={setIsEditOpen}
integration={integration}
services={services}
onComplete={(urlParts) => {
const path = getPath('integration_details_overview', urlParts);
history.push(path);
}}
miniIcon={
isLoading || !packageInfo ? (
<Loading />
) : (
<MiniIcon
packageName={packageInfo?.name}
integrationName={integrationInfo?.name}
version={packageInfo?.version}
icons={integrationInfo?.icons || packageInfo?.icons}
/>
)
}
/>
)}
</WithHeaderLayout>
);
}

View file

@ -127,6 +127,20 @@ export const useGetLimitedPackages = () => {
version: API_VERSIONS.public.v1,
});
};
export const useUpdateCustomIntegration = async (
id: string,
fields: { readMeData: string | undefined; categories: string[] }
) => {
return sendRequest({
path: epmRouteService.getUpdateCustomIntegrationsPath(id),
method: 'put',
version: API_VERSIONS.public.v1,
body: {
readMeData: fields.readMeData,
categories: fields.categories,
},
});
};
export const useGetPackageInfoByKeyQuery = (
pkgName: string,

View file

@ -44,6 +44,7 @@ export {
downloadSourceRoutesService,
policyHasFleetServer,
} from '../../common/services';
export { isPackageUnverified, isVerificationError } from './package_verification';
export { isPackageUpdatable } from './is_package_updatable';
export { pkgKeyFromPackageInfo } from './pkg_key_from_package_info';

View file

@ -137,7 +137,8 @@ export class OutputNotFoundError extends FleetNotFoundError {}
export class PackageNotFoundError extends FleetNotFoundError {}
export class ArchiveNotFoundError extends FleetNotFoundError {}
export class IndexNotFoundError extends FleetNotFoundError {}
export class CustomIntegrationNotFoundError extends FleetNotFoundError {}
export class NotACustomIntegrationError extends FleetNotFoundError {}
export class PackagePolicyNotFoundError extends FleetNotFoundError<{
/** The package policy ID that was not found */
packagePolicyId: string;

View file

@ -52,6 +52,7 @@ import type {
GetBulkAssetsRequestSchema,
CreateCustomIntegrationRequestSchema,
GetInputsRequestSchema,
CustomIntegrationRequestSchema,
} from '../../types';
import {
bulkInstallPackages,
@ -65,6 +66,7 @@ import {
getLimitedPackages,
getBulkAssets,
getTemplateInputs,
updateCustomIntegration,
} from '../../services/epm/packages';
import type { BulkInstallResponse } from '../../services/epm/packages';
import { fleetErrorToResponseOptions, FleetError, FleetTooManyRequestsError } from '../../errors';
@ -389,6 +391,31 @@ export const createCustomIntegrationHandler: FleetRequestHandler<
throw error;
}
};
export const updateCustomIntegrationHandler: FleetRequestHandler<
TypeOf<typeof CustomIntegrationRequestSchema.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 { readMeData, categories } = request.body as TypeOf<
typeof CustomIntegrationRequestSchema.body
>;
const { pkgName } = request.params as unknown as TypeOf<
typeof CustomIntegrationRequestSchema.params
>;
const result = await updateCustomIntegration(esClient, soClient, pkgName, {
readMeData,
categories,
});
return response.ok({
body: {
id: pkgName,
result,
},
});
};
const bulkInstallServiceResponseToHttpEntry = (
result: BulkInstallResponse

View file

@ -35,6 +35,7 @@ import type {
InstallableSavedObject,
PackageInfo,
AssetsGroupedByServiceByType,
UpdateCustomIntegrationResponse,
} from '../../../common/types';
import {
@ -68,6 +69,7 @@ import {
reauthorizeTransformsHandler,
getDataStreamsHandler,
getInputsHandler,
updateCustomIntegrationHandler,
} from './handlers';
import { installPackageKibanaAssetsHandler } from './kibana_assets_handler';
@ -90,6 +92,7 @@ jest.mock('./handlers', () => ({
reauthorizeTransformsHandler: jest.fn(),
getDataStreamsHandler: jest.fn(),
createCustomIntegrationHandler: jest.fn(),
updateCustomIntegrationHandler: jest.fn(),
getInputsHandler: jest.fn(),
}));
@ -731,4 +734,22 @@ describe('schema validation', () => {
const validationResp = ReauthorizeTransformResponseSchema.validate(expectedResponse);
expect(validationResp).toEqual(expectedResponse);
});
it('update custom integration should return valid response', async () => {
const expectedResponse: UpdateCustomIntegrationResponse = {
id: 'test-integration',
result: {
version: '1.0.1',
status: 'installed',
},
};
(updateCustomIntegrationHandler as jest.Mock).mockImplementation((ctx, request, res) => {
return res.ok({ body: expectedResponse });
});
await updateCustomIntegrationHandler(context, {} as any, response);
expect(response.ok).toHaveBeenCalledWith({
body: expectedResponse,
});
});
});

View file

@ -60,6 +60,7 @@ import {
GetOneBulkOperationPackagesRequestSchema,
GetOneBulkOperationPackagesResponseSchema,
BulkUninstallPackagesRequestSchema,
CustomIntegrationRequestSchema,
} from '../../types';
import type { FleetConfigType } from '../../config';
import { FLEET_API_PRIVILEGES } from '../../constants/api_privileges';
@ -83,6 +84,7 @@ import {
getDataStreamsHandler,
createCustomIntegrationHandler,
getInputsHandler,
updateCustomIntegrationHandler,
} from './handlers';
import { getFileHandler } from './file_handler';
import {
@ -811,4 +813,36 @@ export const registerRoutes = (router: FleetAuthzRouter, config: FleetConfigType
},
reauthorizeTransformsHandler
);
router.versioned
.put({
path: EPM_API_ROUTES.UPDATE_CUSTOM_INTEGRATIONS_PATTERN,
security: {
authz: {
requiredPrivileges: [
FLEET_API_PRIVILEGES.SETTINGS.ALL,
FLEET_API_PRIVILEGES.INTEGRATIONS.ALL,
],
},
},
summary: `Update a custom integration`,
options: {
tags: ['oas-tag:Elastic Package Manager (EPM)'],
},
})
.addVersion(
{
version: API_VERSIONS.public.v1,
validate: {
request: CustomIntegrationRequestSchema,
response: {
200: {},
400: {
body: genericErrorResponse,
},
},
},
},
updateCustomIntegrationHandler
);
};

View file

@ -33,7 +33,7 @@ export type { BulkInstallResponse, IBulkInstallPackageError } from './install';
export { handleInstallPackageFailure, installPackage, ensureInstalledPackage } from './install';
export { reinstallPackageForInstallation } from './reinstall';
export { removeInstallation } from './remove';
export { updateCustomIntegration, incrementVersionAndUpdate } from './update_custom_integration';
export class PackageNotInstalledError extends Error {
constructor(pkgkey: string) {
super(`${pkgkey} is not installed`);

View file

@ -578,7 +578,7 @@ function getElasticSubscription(packageInfo: ArchivePackage) {
return subscription || packageInfo.license || 'basic';
}
async function installPackageWithStateMachine(options: {
export async function installPackageWithStateMachine(options: {
pkgName: string;
pkgVersion: string;
installSource: InstallSource;

View file

@ -0,0 +1,203 @@
/*
* 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 { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server';
import { createAppContextStartContractMock } from '../../../mocks';
import { appContextService } from '../../app_context';
import { getInstalledPackageWithAssets } from './get';
import { installPackageWithStateMachine } from './install';
import { incrementVersionAndUpdate, updateCustomIntegration } from './update_custom_integration';
jest.mock('./get');
jest.mock('./install');
jest.mock('../../package_policy', () => {
return {
packagePolicyService: {
listIds: jest.fn().mockResolvedValue({ items: [] }),
bulkUpgrade: jest.fn().mockResolvedValue({}),
},
};
});
const mockGetInstalledPackageWithAssets = getInstalledPackageWithAssets as jest.MockedFunction<
typeof getInstalledPackageWithAssets
>;
const mockInstallPackageWithStateMachine = installPackageWithStateMachine as jest.MockedFunction<
typeof installPackageWithStateMachine
>;
describe('updateCustomIntegration', () => {
let mockContract: ReturnType<typeof createAppContextStartContractMock>;
const savedObjectsClient = savedObjectsClientMock.create();
const esClient = elasticsearchClientMock.createElasticsearchClient();
beforeEach(() => {
mockContract = createAppContextStartContractMock();
appContextService.start(mockContract);
jest.clearAllMocks();
savedObjectsClient.get.mockResolvedValue({
id: 'test-integration',
type: 'epm-packages',
attributes: {
name: 'test-integration',
version: '1.0.0',
title: 'Test Integration',
description: 'Test integration description',
install_source: 'custom',
},
references: [],
});
mockGetInstalledPackageWithAssets.mockResolvedValue({
packageInfo: {
name: 'test-integration',
version: '1.0.0',
title: 'Test Integration',
description: 'Test integration description',
},
installation: {
installed_es: [],
installed_kb: [],
install_version: '1.0.0',
install_status: 'installed',
install_started_at: '2025-04-15T00:00:00.000Z',
install_source: 'custom',
},
assetsMap: new Map([
[
'test-integration-1.0.0/manifest.yml',
Buffer.from('name: test-integration\nversion: 1.0.0'),
],
['test-integration-1.0.0/docs/README.md', Buffer.from('# Test Integration')],
[
'test-integration-1.0.0/changelog.yml',
Buffer.from(
'- version: 1.0.0\n date: 2025-04-15\n changes:\n - type: added\n description: Initial release\n link: N/A'
),
],
]),
} as any);
mockInstallPackageWithStateMachine.mockResolvedValue({
attributes: {
name: 'test-integration',
version: '1.0.1',
title: 'Test Integration',
description: 'Test integration description',
},
status: 'installed',
} as any);
});
afterEach(() => {
appContextService.stop();
});
it('should update custom integration with new version and readme', async () => {
const result = await updateCustomIntegration(esClient, savedObjectsClient, 'test-integration', {
readMeData: '# Updated Test Integration',
categories: ['custom'],
});
// Verify the updated version
expect(result).toEqual({
version: '1.0.1',
status: 'installed',
});
// Verify that getInstalledPackageWithAssets was called with correct params
expect(mockGetInstalledPackageWithAssets).toHaveBeenCalledWith({
savedObjectsClient,
pkgName: 'test-integration',
});
// Verify that installPackageWithStateMachine was called with appropriate params
expect(mockInstallPackageWithStateMachine).toHaveBeenCalledWith(
expect.objectContaining({
pkgName: 'test-integration',
pkgVersion: '1.0.1',
installSource: 'custom',
installType: 'install',
savedObjectsClient,
esClient,
})
);
});
it('should increment version and update correctly', async () => {
const result = await incrementVersionAndUpdate(
savedObjectsClient,
esClient,
'test-integration',
{
readme: '# Updated Test Integration',
version: '1.0.1',
}
);
expect(result).toEqual({
attributes: {
name: 'test-integration',
version: '1.0.1',
title: 'Test Integration',
description: 'Test integration description',
},
status: 'installed',
});
});
it('should throw an error when integration is not found', async () => {
// Instead of returning null, mock the error that would be thrown by SavedObjectsClient
savedObjectsClient.get.mockImplementationOnce(() => {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(
'epm-packages',
'non-existent-integration'
);
});
await expect(
updateCustomIntegration(esClient, savedObjectsClient, 'non-existent-integration', {
readMeData: '# Updated Test Integration',
})
).rejects.toThrow('Integration with ID non-existent-integration not found');
});
it('should handle error during version update', async () => {
const testError = new Error('Test error during update');
mockGetInstalledPackageWithAssets.mockRejectedValueOnce(testError);
await expect(
updateCustomIntegration(esClient, savedObjectsClient, 'test-integration', {
readMeData: '# Updated Test Integration',
})
).rejects.toThrow('Test error during update');
});
it('should throw an error when integration is not a custom integration', async () => {
savedObjectsClient.get.mockResolvedValue({
id: 'test-integration',
type: 'epm-packages',
attributes: {
name: 'test-integration',
version: '1.0.0',
title: 'Test Integration',
description: 'Test integration description',
install_source: 'other',
},
references: [],
});
await expect(
updateCustomIntegration(esClient, savedObjectsClient, 'test-integration', {
readMeData: '# Updated Test Integration',
})
).rejects.toThrow('Integration with ID test-integration is not a custom integration');
});
});

View file

@ -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 type {
ElasticsearchClient,
SavedObject,
SavedObjectsClientContract,
} from '@kbn/core/server';
import { load, dump } from 'js-yaml';
import {
PACKAGES_SAVED_OBJECT_TYPE,
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
SO_SEARCH_LIMIT,
} from '../../../../common/constants';
import { packagePolicyService } from '../../package_policy';
import { createArchiveIteratorFromMap } from '../archive/archive_iterator';
import { appContextService } from '../../app_context';
import type { PackageInstallContext } from '../../../../common/types';
// Define a type for the integration attributes
interface IntegrationAttributes {
version: string;
install_source: string;
[key: string]: any;
}
import { CustomIntegrationNotFoundError, NotACustomIntegrationError } from '../../../errors';
import { getInstalledPackageWithAssets } from './get';
import { installPackageWithStateMachine } from './install';
export async function updateCustomIntegration(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
id: string,
fields: {
readMeData?: string;
categories?: string[];
}
) {
// Get the current integration using the id
let integration: SavedObject<IntegrationAttributes> | null = null;
try {
integration = await soClient.get(PACKAGES_SAVED_OBJECT_TYPE, id);
} catch (error) {
// Ignore the error and handle the case where integration is null later
}
// if theres no integration, the soClient will throw the error above and we will ignore it so we can handle it with our own here
if (!integration) {
throw new CustomIntegrationNotFoundError(`Integration with ID ${id} not found`);
} else if (
integration.attributes.install_source !== 'custom' &&
integration.attributes.install_source !== 'upload'
) {
throw new NotACustomIntegrationError(`Integration with ID ${id} is not a custom integration`);
}
// add one to the patch version in the semver
const newVersion = integration.attributes.version.split('.');
newVersion[2] = (parseInt(newVersion[2], 10) + 1).toString();
const newVersionString = newVersion.join('.');
// Increment the version of everything and create a new package
const res = await incrementVersionAndUpdate(soClient, esClient, id, {
version: newVersionString,
readme: fields.readMeData,
});
return {
version: newVersionString,
status: res.status,
};
}
// Increments the version of everything, then creates a new package with the new version, readme, etc.
export async function incrementVersionAndUpdate(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
pkgName: string,
data: {
version: string;
readme: string | undefined;
}
) {
const installedPkg = await getInstalledPackageWithAssets({
savedObjectsClient: soClient,
pkgName,
});
const assetsMap = [...installedPkg!.assetsMap.entries()].reduce((acc, [path, content]) => {
if (path === `${pkgName}-${installedPkg!.installation.install_version}/manifest.yml`) {
const yaml = load(content!.toString());
yaml.version = data.version;
content = Buffer.from(dump(yaml));
}
acc.set(
path.replace(`-${installedPkg!.installation.install_version}`, `-${data.version}`),
content
);
return acc;
}, new Map<string, Buffer | undefined>());
assetsMap.set(
`${pkgName}-${data.version}/docs/README.md`,
data.readme ? Buffer.from(data.readme) : undefined
);
// update the changelog asset as well by adding an entry
const changelogPath = `${pkgName}-${data.version}/changelog.yml`;
const changelog = assetsMap.get(changelogPath);
if (changelog) {
const yaml = load(changelog?.toString());
if (yaml) {
const newChangelogItem = {
version: data.version,
date: new Date().toISOString(),
changes: [
{
type: 'update',
description: `Edited integration and updated to version ${data.version}`,
link: 'N/A',
},
],
};
yaml.push(newChangelogItem);
assetsMap.set(changelogPath, Buffer.from(dump(yaml)));
}
}
const paths = [...assetsMap.keys()];
const packageInfo = {
...installedPkg!.packageInfo,
version: data.version,
};
const archiveIterator = createArchiveIteratorFromMap(assetsMap);
const packageInstallContext: PackageInstallContext = {
paths,
packageInfo,
archiveIterator,
};
const res = await installPackageWithStateMachine({
packageInstallContext,
pkgName,
pkgVersion: data.version,
installSource: 'custom',
installType: 'install',
savedObjectsClient: soClient,
esClient,
spaceId: 'default',
force: true,
paths: packageInstallContext.paths,
authorizationHeader: null,
keepFailedInstallation: true,
});
const policyIdsToUpgrade = await packagePolicyService.listIds(
appContextService.getInternalUserSOClientWithoutSpaceExtension(),
{
page: 1,
perPage: SO_SEARCH_LIMIT,
kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${pkgName}`,
}
);
if (policyIdsToUpgrade.items.length) {
await packagePolicyService.bulkUpgrade(soClient, esClient, policyIdsToUpgrade.items);
}
return res;
}

View file

@ -1216,7 +1216,8 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
const packageInfos = await getPackageInfoForPackagePolicies(
[...packagePolicyUpdates, ...oldPackagePolicies],
soClient
soClient,
true
);
const allSecretsToDelete: PolicySecretReference[] = [];
@ -2604,7 +2605,8 @@ export const packagePolicyService: PackagePolicyClient = new PackagePolicyClient
async function getPackageInfoForPackagePolicies(
packagePolicies: NewPackagePolicyWithId[],
soClient: SavedObjectsClientContract
soClient: SavedObjectsClientContract,
ignoreMissing?: boolean
) {
const pkgInfoMap = new Map<string, { name: string; version: string }>();
@ -2619,14 +2621,20 @@ async function getPackageInfoForPackagePolicies(
await pMap(pkgInfoMap.keys(), async (pkgKey) => {
const pkgInfo = pkgInfoMap.get(pkgKey);
if (pkgInfo) {
const pkgInfoData = await getPackageInfo({
savedObjectsClient: soClient,
pkgName: pkgInfo.name,
pkgVersion: pkgInfo.version,
prerelease: true,
});
try {
const pkgInfoData = await getPackageInfo({
savedObjectsClient: soClient,
pkgName: pkgInfo.name,
pkgVersion: pkgInfo.version,
prerelease: true,
});
resultMap.set(pkgKey, pkgInfoData);
resultMap.set(pkgKey, pkgInfoData);
} catch (error) {
if (!ignoreMissing) {
throw error;
}
}
}
});

View file

@ -0,0 +1,20 @@
/*
* 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 { schema } from '@kbn/config-schema';
const CustomIntegrationFieldsSchema = schema.object({
readMeData: schema.string(),
categories: schema.maybe(schema.arrayOf(schema.string())),
});
export const CustomIntegrationRequestSchema = {
body: CustomIntegrationFieldsSchema,
params: schema.object({
pkgName: schema.string(),
}),
};

View file

@ -13,3 +13,4 @@ export * from './enrollment_api_key';
export * from './preconfiguration';
export * from './download_sources';
export * from './synced_integrations';
export * from './custom_integrations';

View file

@ -0,0 +1,129 @@
/*
* 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 { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { skipIfNoDockerRegistry } from '../../helpers';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const fleetAndAgents = getService('fleetAndAgents');
const log = getService('log');
const testCustomIntegrationName = 'test-custom-integration';
// Helper functions
const createCustomIntegration = async (
name: string,
datasets: Array<{ type: string; name: string }> = []
) => {
return await supertest
.post('/api/fleet/epm/custom_integrations')
.set('kbn-xsrf', 'xxxx')
.send({
integrationName: name,
force: true,
datasets: datasets.length ? datasets : [{ type: 'logs', name }],
})
.expect(200);
};
const deleteCustomIntegration = async (name: string, version: string) => {
await supertest
.delete(`/api/fleet/epm/packages/${name}/${version}`)
.set('kbn-xsrf', 'xxxx')
.send({ force: true })
.expect(200);
};
const getCustomIntegrationInfo = async (name: string, version?: string) => {
const url = version
? `/api/fleet/epm/packages/${name}/${version}`
: `/api/fleet/epm/packages/${name}`;
return await supertest.get(url).set('kbn-xsrf', 'xxxx').expect(200);
};
describe('EPM - Custom Integrations', () => {
skipIfNoDockerRegistry(providerContext);
before(async () => {
await fleetAndAgents.setup();
});
describe('update custom integration', () => {
let initialVersion: string;
beforeEach(async () => {
// Create a custom integration to update
await createCustomIntegration(testCustomIntegrationName);
// Store the initial version to verify it changes after update
const getResponse = await getCustomIntegrationInfo(testCustomIntegrationName);
initialVersion = getResponse.body.item.version;
});
afterEach(async () => {
// Get current version after tests
const getResponse = await getCustomIntegrationInfo(testCustomIntegrationName);
const currentVersion = getResponse.body.item.version;
// Clean up
try {
await deleteCustomIntegration(testCustomIntegrationName, currentVersion);
} catch (err) {
log.info(`Error cleaning up custom integration: ${err.message}`);
}
});
it('should update readme and increment version', async () => {
// Define new readme content
const newReadmeContent =
'# Updated Test Integration\nThis readme has been updated through the API.';
// Update the custom integration
await supertest
.put(`/api/fleet/epm/custom_integrations/${testCustomIntegrationName}`)
.set('kbn-xsrf', 'xxxx')
.send({
readMeData: newReadmeContent,
})
.expect(200);
// Verify the integration was updated with new version
const response = await getCustomIntegrationInfo(testCustomIntegrationName);
const updatedIntegration = response.body.item;
// Version should be incremented
const parsedInitialVersion = initialVersion.split('.');
const expectedNewVersion = `${parsedInitialVersion[0]}.${parsedInitialVersion[1]}.${
Number(parsedInitialVersion[2]) + 1
}`;
expect(updatedIntegration.version).to.not.equal(initialVersion);
expect(updatedIntegration.version).to.equal(expectedNewVersion);
// Get the readme file to verify content
const readmeResponse = await supertest
.get(
`/api/fleet/epm/packages/${testCustomIntegrationName}/${expectedNewVersion}/docs/README.md`
)
.expect(200);
// The response body contains the raw file content, verify it matches the new content
expect(readmeResponse.text).to.equal(newReadmeContent);
});
it('should return 404 for non-existent integration', async () => {
await supertest
.put(`/api/fleet/epm/custom_integrations/non-existent-integration`)
.set('kbn-xsrf', 'xxxx')
.send({
readMeData: 'New content',
})
.expect(404);
});
});
});
}

View file

@ -52,5 +52,6 @@ export default function loadTests({ loadTestFile, getService }) {
loadTestFile(require.resolve('./install_runtime_field'));
loadTestFile(require.resolve('./get_templates_inputs'));
loadTestFile(require.resolve('./data_views'));
loadTestFile(require.resolve('./custom_integrations'));
});
}