mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
f987716d43
commit
3571878acd
28 changed files with 1248 additions and 28 deletions
|
@ -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.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.'
|
||||
|
|
|
@ -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.'
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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(),
|
||||
}),
|
||||
};
|
|
@ -13,3 +13,4 @@ export * from './enrollment_api_key';
|
|||
export * from './preconfiguration';
|
||||
export * from './download_sources';
|
||||
export * from './synced_integrations';
|
||||
export * from './custom_integrations';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue