[Fleet] Allow to bulk upgrade integrations (#215419)

This commit is contained in:
Nicolas Chaulet 2025-03-27 14:32:39 -04:00 committed by GitHub
parent b8a29d4096
commit 2aa857643d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1363 additions and 113 deletions

View file

@ -22,12 +22,13 @@ export const LIMITED_CONCURRENCY_ROUTE_TAG = 'ingest:limited-concurrency';
// EPM API routes
const EPM_PACKAGES_MANY = `${EPM_API_ROOT}/packages`;
const EPM_PACKAGES_INSTALLED = `${EPM_API_ROOT}/packages/installed`;
const EPM_PACKAGES_BULK = `${EPM_PACKAGES_MANY}/_bulk`;
const EPM_PACKAGES_ONE_WITHOUT_VERSION = `${EPM_PACKAGES_MANY}/{pkgName}`;
const EPM_PACKAGES_ONE = `${EPM_PACKAGES_MANY}/{pkgName}/{pkgVersion}`;
const EPM_PACKAGES_ONE_WITH_OPTIONAL_VERSION = `${EPM_PACKAGES_MANY}/{pkgName}/{pkgVersion?}`;
export const EPM_API_ROUTES = {
BULK_INSTALL_PATTERN: EPM_PACKAGES_BULK,
BULK_INSTALL_PATTERN: `${EPM_PACKAGES_MANY}/_bulk`,
BULK_UPGRADE_PATTERN: `${EPM_PACKAGES_MANY}/_bulk_upgrade`,
BULK_UPGRADE_INFO_PATTERN: `${EPM_PACKAGES_MANY}/_bulk_upgrade/{taskId}`,
LIST_PATTERN: EPM_PACKAGES_MANY,
INSTALLED_LIST_PATTERN: EPM_PACKAGES_INSTALLED,
LIMITED_LIST_PATTERN: `${EPM_PACKAGES_MANY}/limited`,

View file

@ -86,6 +86,14 @@ export const epmRouteService = {
return EPM_API_ROUTES.BULK_INSTALL_PATTERN;
},
getBulkUpgradePath: () => {
return EPM_API_ROUTES.BULK_UPGRADE_PATTERN;
},
getOneBulkUpgradePath: (taskId: string) => {
return EPM_API_ROUTES.BULK_UPGRADE_INFO_PATTERN.replace('{taskId}', taskId);
},
getRemovePath: (pkgName: string, pkgVersion?: string) => {
if (pkgVersion) {
return EPM_API_ROUTES.DELETE_PATTERN.replace('{pkgName}', pkgName)

View file

@ -157,6 +157,23 @@ export interface BulkInstallPackagesResponse {
items: Array<BulkInstallPackageInfo | IBulkInstallPackageHTTPError>;
}
export interface BulkUpgradePackagesRequest {
packages: Array<{ name: string; version?: string }>;
upgrade_package_policies?: boolean;
force?: boolean;
prerelease?: boolean;
}
export interface BulkUpgradePackagesResponse {
taskId: string;
}
export interface GetOneBulkUpgradePackagesResponse {
status: string;
error?: { message: string };
results?: Array<{ success?: boolean; error?: { message: string } }>;
}
export interface BulkInstallPackagesRequest {
body: {
packages: string[];

View file

@ -0,0 +1,98 @@
/*
* 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 } from 'react';
import {
EuiCallOut,
EuiConfirmModal,
EuiFormRow,
EuiSpacer,
EuiSwitch,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import type { InstalledPackageUIPackageListItem } from '../types';
export const ConfirmBulkUpgradeModal: React.FunctionComponent<{
selectedItems: InstalledPackageUIPackageListItem[];
onClose: () => void;
onConfirm: (params: { updatePolicies: boolean }) => void;
}> = ({ onClose, onConfirm, selectedItems }) => {
const [updatePolicies, setUpdatePolicies] = useState(false);
const [isLoading, setIsLoading] = useState(false);
return (
<EuiConfirmModal
title={i18n.translate('xpack.fleet.installedIntegrations.bulkUpgradeModal.title', {
defaultMessage: 'Upgrade {countIntegrations} integrations and {countPolicies} policies',
values: {
countIntegrations: selectedItems.length,
countPolicies: selectedItems.reduce(
(acc, item) => acc + (item.packagePoliciesInfo?.count ?? 0),
0
),
},
})}
confirmButtonText={i18n.translate(
'xpack.fleet.installedIntegrations.bulkUpgradeModal.confirmButton',
{ defaultMessage: 'Upgrade to latest version' }
)}
cancelButtonText={i18n.translate(
'xpack.fleet.installedIntegrations.bulkUpgradeModal.cancelButton',
{ defaultMessage: 'Review integration selection' }
)}
onCancel={onClose}
isLoading={isLoading}
onConfirm={async () => {
try {
setIsLoading(true);
await onConfirm({ updatePolicies });
onClose();
} catch (err) {
setIsLoading(false);
}
}}
>
<EuiText>
<FormattedMessage
id="xpack.fleet.installedIntegrations.bulkUpgradeModal.description"
defaultMessage={
'We will upgrade your integrations and policies to the latest available version.'
}
/>
</EuiText>
<EuiSpacer size="l" />
<EuiFormRow fullWidth>
<EuiSwitch
checked={updatePolicies}
onChange={(e) => {
setUpdatePolicies(e.target.checked);
}}
label={
<FormattedMessage
id="xpack.fleet.installedIntegrations.bulkUpgradeModal.policiesSwitchLabel"
defaultMessage="Upgrade integration policies"
/>
}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiCallOut
iconType="iInCircle"
title={i18n.translate(
'xpack.fleet.installedIntegrations.bulkUpgradeModal.policiesCallout',
{
defaultMessage:
'When enabled, Fleet will attempt to upgrade and deploy integration policies automatically.',
}
)}
/>
</EuiConfirmModal>
);
};

View file

@ -14,6 +14,8 @@ import {
EuiIcon,
EuiCallOut,
EuiButton,
EuiToolTip,
EuiLoadingSpinner,
} from '@elastic/eui';
import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react';
@ -31,7 +33,7 @@ const InstalledVersionStatus: React.FunctionComponent<{
<EuiFlexItem grow={false}>
<EuiIcon size="m" type="checkInCircleFilled" color="success" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{item.version}</EuiFlexItem>
<EuiFlexItem grow={false}>{item.installationInfo?.version ?? item.version}</EuiFlexItem>
</EuiFlexGroup>
);
});
@ -72,6 +74,34 @@ const UpgradeAvailableVersionStatus: React.FunctionComponent<{
);
});
const UpgradingVersionStatus: React.FunctionComponent<{
item: InstalledPackageUIPackageListItem;
}> = React.memo(({ item }) => {
return (
<EuiToolTip
content={
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.upgradingTooltip"
defaultMessage={'Upgrading to {version}'}
values={{ version: item.version }}
/>
}
>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size={'m'} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.upgradingText"
defaultMessage="Upgrading..."
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiToolTip>
);
});
function formatAttempt(attempt: InstallFailedAttempt): React.ReactNode {
return (
<>
@ -188,6 +218,8 @@ export const InstallationVersionStatus: React.FunctionComponent<{
return <InstalledVersionStatus item={item} />;
} else if (status === 'upgrade_available') {
return <UpgradeAvailableVersionStatus item={item} />;
} else if (status === 'upgrading') {
return <UpgradingVersionStatus item={item} />;
} else if (status === 'upgrade_failed') {
return <InstallUpgradeFailedVersionStatus isUpgradeFailed={true} item={item} />;
} else if (status === 'install_failed') {

View file

@ -5,13 +5,30 @@
* 2.0.
*/
import React from 'react';
import { EuiButton } from '@elastic/eui';
import React, { useState, useMemo, useCallback } from 'react';
import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { useStartServices } from '../../../../../../../hooks';
import type { InstalledPackageUIPackageListItem } from '../types';
import { useBulkActions } from '../hooks/use_bulk_actions';
import { ConfirmBulkUpgradeModal } from './confirm_bulk_upgrade_modal';
export const InstalledIntegrationsActionMenu: React.FunctionComponent<{
selectedItems: InstalledPackageUIPackageListItem[];
}> = ({ selectedItems }) => {
const [isOpen, setIsOpen] = useState(false);
const startServices = useStartServices();
export const InstalledIntegrationsActionMenu: React.FunctionComponent = () => {
const button = (
<EuiButton iconType="arrowDown" iconSide="right" disabled onClick={() => {}}>
<EuiButton
iconType="arrowDown"
disabled={selectedItems.length === 0}
iconSide="right"
onClick={() => setIsOpen((s) => !s)}
>
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.actionButton"
defaultMessage="Actions"
@ -19,5 +36,65 @@ export const InstalledIntegrationsActionMenu: React.FunctionComponent = () => {
</EuiButton>
);
return button;
const {
actions: { bulkUpgradeIntegrations },
} = useBulkActions();
const openUpgradeModal = useCallback(() => {
setIsOpen(false);
const ref = startServices.overlays.openModal(
toMountPoint(
<ConfirmBulkUpgradeModal
onClose={() => {
ref.close();
}}
onConfirm={({ updatePolicies }) => bulkUpgradeIntegrations(selectedItems, updatePolicies)}
selectedItems={selectedItems}
/>,
startServices
)
);
}, [selectedItems, startServices, bulkUpgradeIntegrations]);
const items = useMemo(() => {
const hasUpgreadableIntegrations = selectedItems.some(
(item) =>
item.ui.installation_status === 'upgrade_available' ||
item.ui.installation_status === 'upgrade_failed' ||
item.ui.installation_status === 'install_failed'
);
return [
<EuiContextMenuItem
key="upgrade"
icon="refresh"
disabled={!hasUpgreadableIntegrations}
onClick={openUpgradeModal}
>
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.bulkUpgradeButton"
defaultMessage={'Upgrade {count, plural, one {# integration} other {# integrations}}'}
values={{
count: selectedItems.length,
}}
/>
</EuiContextMenuItem>,
<EuiContextMenuItem key="edit" icon="pencil" onClick={() => {}}>
TODO other actions
</EuiContextMenuItem>,
];
}, [selectedItems, openUpgradeModal]);
return (
<EuiPopover
id="fleet.epmInstalledIntegrations.bulkActionPopover"
button={button}
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s" items={items} />
</EuiPopover>
);
};

View file

@ -19,7 +19,11 @@ import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import useDebounce from 'react-use/lib/useDebounce';
import type { InstalledIntegrationsFilter, InstalledPackagesUIInstallationStatus } from '../types';
import type {
InstalledIntegrationsFilter,
InstalledPackageUIPackageListItem,
InstalledPackagesUIInstallationStatus,
} from '../types';
import { useAddUrlFilters } from '../hooks/use_url_filters';
import { InstalledIntegrationsActionMenu } from './installed_integration_action_menu';
@ -30,7 +34,8 @@ export const InstalledIntegrationsSearchBar: React.FunctionComponent<{
filters: InstalledIntegrationsFilter;
countPerStatus: { [k: string]: number | undefined };
customIntegrationsCount: number;
}> = ({ filters, countPerStatus, customIntegrationsCount }) => {
selectedItems: InstalledPackageUIPackageListItem[];
}> = ({ filters, countPerStatus, customIntegrationsCount, selectedItems }) => {
const addUrlFilter = useAddUrlFilters();
const theme = useEuiTheme();
const [searchTerms, setSearchTerms] = useState(filters.q);
@ -160,7 +165,7 @@ export const InstalledIntegrationsSearchBar: React.FunctionComponent<{
</EuiFilterGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<InstalledIntegrationsActionMenu />
<InstalledIntegrationsActionMenu selectedItems={selectedItems} />
</EuiFlexItem>
</EuiFlexGroup>
</div>

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState } from 'react';
import React from 'react';
import {
EuiBasicTable,
EuiLink,
@ -31,11 +31,14 @@ export const InstalledIntegrationsTable: React.FunctionComponent<{
total: number;
isLoading: boolean;
pagination: UrlPagination;
}> = ({ installedPackages, total, isLoading, pagination }) => {
selection: {
selectedItems: InstalledPackageUIPackageListItem[];
setSelectedItems: React.Dispatch<React.SetStateAction<InstalledPackageUIPackageListItem[]>>;
};
}> = ({ installedPackages, total, isLoading, pagination, selection }) => {
const authz = useAuthz();
const { getHref } = useLink();
const [selectedItems, setSelectedItems] = useState<InstalledPackageUIPackageListItem[]>([]);
const { selectedItems, setSelectedItems } = selection;
const { setPagination } = pagination;
const handleTablePagination = React.useCallback(

View file

@ -0,0 +1,159 @@
/*
* 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, { useCallback, useContext, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { useQueries, useQueryClient } from '@tanstack/react-query';
import {
sendBulkUpgradePackagesForRq,
sendGetOneBulkUpgradePackagesForRq,
useStartServices,
} from '../../../../../../../hooks';
import type { InstalledPackageUIPackageListItem } from '../types';
export const bulkActionsContext = React.createContext<{
upgradingIntegrations: InstalledPackageUIPackageListItem[];
bulkActions: {
setBulkUpgradeActions: React.Dispatch<
React.SetStateAction<
Array<{
taskId: string;
upgradingIntegrations: InstalledPackageUIPackageListItem[];
}>
>
>;
};
}>({
upgradingIntegrations: [],
bulkActions: {
setBulkUpgradeActions: () => {},
},
});
export const BulkActionContextProvider: React.FunctionComponent<{ children: React.ReactNode }> = ({
children,
}) => {
const {
notifications: { toasts },
} = useStartServices();
const queryClient = useQueryClient();
const [bulkUpgradeActions, setBulkUpgradeActions] = useState<
Array<{
taskId: string;
upgradingIntegrations: InstalledPackageUIPackageListItem[];
}>
>([]);
const upgradingIntegrations = useMemo(() => {
return bulkUpgradeActions.flatMap((action) => action.upgradingIntegrations);
}, [bulkUpgradeActions]);
// Poll for upgrade task results
useQueries({
queries: bulkUpgradeActions.map((action) => ({
queryKey: ['bulk-upgrade-packages', action.taskId],
queryFn: async () => {
const res = await sendGetOneBulkUpgradePackagesForRq(action.taskId);
if (res.status !== 'pending') {
await queryClient.invalidateQueries(['get-packages']);
setBulkUpgradeActions((actions) => actions.filter((a) => a.taskId !== action.taskId));
if (res.status === 'success') {
// TODO update copy and view integrations https://github.com/elastic/kibana/issues/209892
toasts.addSuccess({
title: i18n.translate(
'xpack.fleet.epmInstalledIntegrations.bulkActions.bulkUpgradeSuccessTitle',
{
defaultMessage: 'Upgrade succeeded',
}
),
});
} else if (res.status === 'failed') {
// TODO update copy and view integrations https://github.com/elastic/kibana/issues/209892
toasts.addDanger({
title: i18n.translate(
'xpack.fleet.epmInstalledIntegrations.bulkActions.bulkUpgradeFailedTitle',
{
defaultMessage: 'Upgrade failed',
}
),
});
}
}
},
refetchInterval: 3 * 1000,
})),
});
const bulkActions = useMemo(
() => ({
setBulkUpgradeActions,
}),
[setBulkUpgradeActions]
);
return (
<bulkActionsContext.Provider
value={{
upgradingIntegrations,
bulkActions,
}}
>
{children}
</bulkActionsContext.Provider>
);
};
export function useBulkActions() {
const {
upgradingIntegrations,
bulkActions: { setBulkUpgradeActions },
} = useContext(bulkActionsContext);
const {
notifications: { toasts },
} = useStartServices();
const bulkUpgradeIntegrations = useCallback(
async (items: InstalledPackageUIPackageListItem[], updatePolicies?: boolean) => {
try {
const res = await sendBulkUpgradePackagesForRq({
packages: items.map((item) => ({ name: item.name })),
upgrade_package_policies: updatePolicies,
});
setBulkUpgradeActions((actions) => [
...actions,
{
taskId: res.taskId,
upgradingIntegrations: items,
},
]);
} catch (error) {
toasts.addError(error, {
title: i18n.translate(
'xpack.fleet.epmInstalledIntegrations.bulkActions.bulkUpgradeErrorTitle',
{
defaultMessage: 'Error upgrading integrations',
}
),
});
}
},
[setBulkUpgradeActions, toasts]
);
return {
actions: {
bulkUpgradeIntegrations,
},
upgradingIntegrations,
};
}

View file

@ -92,6 +92,31 @@ describe('useInstalledIntegrations', () => {
});
});
it('should compute upgrading status in ui property', () => {
const { result } = renderHook(() =>
useInstalledIntegrations({}, { currentPage: 1, pageSize: 10 }, [
{
id: 'mysql',
name: 'mysql',
status: 'installed',
version: '2.0.0',
installationInfo: { version: '1.0.0' },
},
] as any)
);
expect(result.current).toEqual(
expect.objectContaining({
installedPackages: expect.arrayContaining([
expect.objectContaining({
id: 'mysql',
ui: { installation_status: 'upgrading' },
}),
]),
})
);
});
it('should support filtering on status', () => {
const { result } = renderHook(() =>
useInstalledIntegrations(

View file

@ -17,7 +17,13 @@ import type {
InstalledPackageUIPackageListItem,
} from '../types';
function getIntegrationStatus(item: PackageListItem): InstalledPackagesUIInstallationStatus {
function getIntegrationStatus(
item: PackageListItem,
isUpgrading: boolean
): InstalledPackagesUIInstallationStatus {
if (isUpgrading) {
return 'upgrading';
}
if (item.status === 'install_failed') {
return 'install_failed';
} else if (item.status === 'installed') {
@ -41,7 +47,8 @@ function getIntegrationStatus(item: PackageListItem): InstalledPackagesUIInstall
export function useInstalledIntegrations(
filters: InstalledIntegrationsFilter,
pagination: Pagination
pagination: Pagination,
upgradingIntegrations?: InstalledPackageUIPackageListItem[]
) {
const { data, isInitialLoading, isLoading } = useGetPackagesQuery({
withPackagePoliciesCount: true,
@ -55,10 +62,13 @@ export function useInstalledIntegrations(
.map((item) => ({
...item,
ui: {
installation_status: getIntegrationStatus(item),
installation_status: getIntegrationStatus(
item,
upgradingIntegrations?.some((u) => u.name === item.name) ?? false
),
},
})),
[data]
[data, upgradingIntegrations]
);
const localSearch = useLocalSearch(internalInstalledPackages, isInitialLoading);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useState } from 'react';
import { EuiSpacer } from '@elastic/eui';
import styled from '@emotion/styled';
@ -16,6 +16,8 @@ import { InstalledIntegrationsTable } from './components/installed_integrations_
import { useInstalledIntegrations } from './hooks/use_installed_integrations';
import { useUrlFilters } from './hooks/use_url_filters';
import { InstalledIntegrationsSearchBar } from './components/installed_integrations_search_bar';
import type { InstalledPackageUIPackageListItem } from './types';
import { BulkActionContextProvider, useBulkActions } from './hooks/use_bulk_actions';
const ContentWrapper = styled.div`
max-width: 1200px;
@ -23,10 +25,11 @@ const ContentWrapper = styled.div`
height: 100%;
`;
export const InstalledIntegrationsPage: React.FunctionComponent = () => {
const InstalledIntegrationsPageContent: React.FunctionComponent = () => {
// State management
const filters = useUrlFilters();
const pagination = useUrlPagination();
const { upgradingIntegrations } = useBulkActions();
const {
installedPackages,
countPerStatus,
@ -34,7 +37,9 @@ export const InstalledIntegrationsPage: React.FunctionComponent = () => {
isLoading,
isInitialLoading,
total,
} = useInstalledIntegrations(filters, pagination.pagination);
} = useInstalledIntegrations(filters, pagination.pagination, upgradingIntegrations);
const [selectedItems, setSelectedItems] = useState<InstalledPackageUIPackageListItem[]>([]);
if (isInitialLoading) {
return <Loading />;
@ -46,6 +51,7 @@ export const InstalledIntegrationsPage: React.FunctionComponent = () => {
filters={filters}
customIntegrationsCount={customIntegrationsCount}
countPerStatus={countPerStatus}
selectedItems={selectedItems}
/>
<EuiSpacer size="l" />
<InstalledIntegrationsTable
@ -53,7 +59,16 @@ export const InstalledIntegrationsPage: React.FunctionComponent = () => {
pagination={pagination}
isLoading={isInitialLoading || isLoading}
installedPackages={installedPackages}
selection={{ selectedItems, setSelectedItems }}
/>
</ContentWrapper>
);
};
export const InstalledIntegrationsPage: React.FunctionComponent = () => {
return (
<BulkActionContextProvider>
<InstalledIntegrationsPageContent />
</BulkActionContextProvider>
);
};

View file

@ -13,6 +13,7 @@ export type InstalledPackagesUIInstallationStatus =
| 'installed'
| 'install_failed'
| 'upgrade_failed'
| 'upgrading'
| 'upgrade_available';
export type InstalledPackageUIPackageListItem = PackageListItem & {

View file

@ -31,8 +31,11 @@ import type {
GetInputsTemplatesResponse,
} from '../../types';
import type {
BulkUpgradePackagesRequest,
BulkUpgradePackagesResponse,
FleetErrorResponse,
GetEpmDataStreamsResponse,
GetOneBulkUpgradePackagesResponse,
GetStatsResponse,
} from '../../../common/types';
import { API_VERSIONS } from '../../../common/constants';
@ -305,6 +308,23 @@ export const sendBulkInstallPackages = (
});
};
export const sendBulkUpgradePackagesForRq = (params: BulkUpgradePackagesRequest) => {
return sendRequestForRq<BulkUpgradePackagesResponse>({
path: epmRouteService.getBulkUpgradePath(),
method: 'post',
version: API_VERSIONS.public.v1,
body: params,
});
};
export const sendGetOneBulkUpgradePackagesForRq = (taskId: string) => {
return sendRequestForRq<GetOneBulkUpgradePackagesResponse>({
path: epmRouteService.getOneBulkUpgradePath(taskId),
method: 'get',
version: API_VERSIONS.public.v1,
});
};
export function sendRemovePackage(
{ pkgName, pkgVersion }: DeletePackageRequest['params'],
query?: DeletePackageRequest['query']

View file

@ -150,6 +150,7 @@ import { registerBumpAgentPoliciesTask } from './services/agent_policies/bump_ag
import { UpgradeAgentlessDeploymentsTask } from './tasks/upgrade_agentless_deployment';
import { SyncIntegrationsTask } from './tasks/sync_integrations/sync_integrations_task';
import { AutomaticAgentUpgradeTask } from './tasks/automatic_agent_upgrade_task';
import { registerBulkUpgradePackagesTask } from './tasks/bulk_upgrade_packages_task';
export interface FleetSetupDeps {
security: SecurityPluginSetup;
@ -639,6 +640,7 @@ export class FleetPlugin
registerUpgradeManagedPackagePoliciesTask(deps.taskManager);
registerDeployAgentPoliciesTask(deps.taskManager);
registerBumpAgentPoliciesTask(deps.taskManager);
registerBulkUpgradePackagesTask(deps.taskManager);
this.bulkActionsResolver = new BulkActionsResolver(deps.taskManager, core);
this.checkDeletedFilesTask = new CheckDeletedFilesTask({

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TypeOf } from '@kbn/config-schema';
import { appContextService } from '../../services';
import type {
BulkUpgradePackagesRequestSchema,
FleetRequestHandler,
GetOneBulkUpgradePackagesRequestSchema,
} from '../../types';
import { HTTPAuthorizationHeader } from '../../../common/http_authorization_header';
import type {
BulkUpgradePackagesResponse,
GetOneBulkUpgradePackagesResponse,
} from '../../../common/types';
import {
getBulkUpgradeTaskResults,
scheduleBulkUpgrade,
} from '../../tasks/bulk_upgrade_packages_task';
import { getInstallationsByName } from '../../services/epm/packages/get';
import { FleetError } from '../../errors';
export const postBulkUpgradePackagesHandler: FleetRequestHandler<
undefined,
undefined,
TypeOf<typeof BulkUpgradePackagesRequestSchema.body>
> = async (context, request, response) => {
const fleetContext = await context.fleet;
const savedObjectsClient = fleetContext.internalSoClient;
const spaceId = fleetContext.spaceId;
const user = appContextService.getSecurityCore().authc.getCurrentUser(request) || undefined;
const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request, user?.username);
const taskManagerStart = appContextService.getTaskManagerStart();
if (!taskManagerStart) {
throw new Error('Task manager not defined');
}
const pkgNames = request.body.packages.map(({ name }) => name);
const installations = await getInstallationsByName({ savedObjectsClient, pkgNames });
const nonInstalledPackages = pkgNames.filter(
(pkgName) => !installations.some((installation) => installation.name === pkgName)
);
if (nonInstalledPackages.length) {
throw new FleetError(
`Cannot upgrade non installed packages: ${nonInstalledPackages.join(', ')}`
);
}
const taskId = await scheduleBulkUpgrade(taskManagerStart, savedObjectsClient, {
authorizationHeader,
spaceId,
packages: request.body.packages,
upgradePackagePolicies: request.body.upgrade_package_policies,
force: request.body.force,
prerelease: request.body.prerelease,
});
const body: BulkUpgradePackagesResponse = {
taskId,
};
return response.ok({ body });
};
export const getOneBulkUpgradePackagesHandler: FleetRequestHandler<
TypeOf<typeof GetOneBulkUpgradePackagesRequestSchema.params>
> = async (context, request, response) => {
const taskManagerStart = appContextService.getTaskManagerStart();
if (!taskManagerStart) {
throw new Error('Task manager not defined');
}
const results = await getBulkUpgradeTaskResults(taskManagerStart, request.params.taskId);
const body: GetOneBulkUpgradePackagesResponse = {
status: results.status,
error: results.error,
results: results.results,
};
return response.ok({ body });
};

View file

@ -7,8 +7,8 @@
import type { RouteSecurity } from '@kbn/core-http-server';
import { parseExperimentalConfigValue } from '../../../common/experimental_features';
import { API_VERSIONS } from '../../../common/constants';
import type { FleetAuthz } from '../../../common';
import {
@ -55,6 +55,10 @@ import {
GetDataStreamsResponseSchema,
GetBulkAssetsResponseSchema,
ReauthorizeTransformResponseSchema,
BulkUpgradePackagesRequestSchema,
BulkUpgradePackagesResponseSchema,
GetOneBulkUpgradePackagesRequestSchema,
GetOneBulkUpgradePackagesResponseSchema,
} from '../../types';
import type { FleetConfigType } from '../../config';
import { FLEET_API_PRIVILEGES } from '../../constants/api_privileges';
@ -84,6 +88,7 @@ import {
deletePackageKibanaAssetsHandler,
installPackageKibanaAssetsHandler,
} from './kibana_assets_handler';
import { postBulkUpgradePackagesHandler, getOneBulkUpgradePackagesHandler } from './bulk_handler';
const MAX_FILE_SIZE_BYTES = 104857600; // 100MB
@ -119,6 +124,8 @@ export const READ_PACKAGE_INFO_SECURITY: RouteSecurity = {
};
export const registerRoutes = (router: FleetAuthzRouter, config: FleetConfigType) => {
const experimentalFeatures = parseExperimentalConfigValue(config.enableExperimental);
router.versioned
.get({
path: EPM_API_ROUTES.CATEGORIES_PATTERN,
@ -446,6 +453,62 @@ export const registerRoutes = (router: FleetAuthzRouter, config: FleetConfigType
deletePackageKibanaAssetsHandler
);
if (experimentalFeatures.installedIntegrationsTabularUI) {
router.versioned
.post({
path: EPM_API_ROUTES.BULK_UPGRADE_PATTERN,
security: INSTALL_PACKAGES_SECURITY,
summary: `Bulk upgrade packages`,
options: {
tags: ['oas-tag:Elastic Package Manager (EPM)'],
},
})
.addVersion(
{
version: API_VERSIONS.public.v1,
validate: {
request: BulkUpgradePackagesRequestSchema,
response: {
200: {
body: () => BulkUpgradePackagesResponseSchema,
},
400: {
body: genericErrorResponse,
},
},
},
},
postBulkUpgradePackagesHandler
);
router.versioned
.get({
path: EPM_API_ROUTES.BULK_UPGRADE_INFO_PATTERN,
security: INSTALL_PACKAGES_SECURITY,
summary: `Get Bulk upgrade packages details`,
options: {
tags: ['oas-tag:Elastic Package Manager (EPM)'],
},
})
.addVersion(
{
version: API_VERSIONS.public.v1,
validate: {
request: GetOneBulkUpgradePackagesRequestSchema,
response: {
200: {
body: () => GetOneBulkUpgradePackagesResponseSchema,
},
400: {
body: genericErrorResponse,
},
},
},
},
getOneBulkUpgradePackagesHandler
);
}
router.versioned
.post({
path: EPM_API_ROUTES.BULK_INSTALL_PATTERN,

View file

@ -0,0 +1,136 @@
/*
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
import { createAppContextStartContractMock } from '../mocks';
import { appContextService } from '../services';
import { packagePolicyService } from '../services/package_policy';
import { installPackage } from '../services/epm/packages';
import { _runBulkUpgradeTask } from './bulk_upgrade_packages_task';
jest.mock('../services/epm/packages');
jest.mock('../services/package_policy');
describe('Bulk upgrade task', () => {
beforeEach(() => {
const mockContract = createAppContextStartContractMock();
appContextService.start(mockContract);
jest.mocked(installPackage).mockReset();
jest.mocked(installPackage).mockImplementation(async (params) => {
if (!('pkgkey' in params)) {
throw new Error('Invalid call to installPackage');
}
if (params.pkgkey.startsWith('test_valid')) {
return {
status: 'installed',
} as any;
}
if (params.pkgkey.startsWith('test_invalid')) {
return {
error: new Error('Impossible to install: ' + params.pkgkey),
};
}
throw new Error('not implemented');
});
jest.mocked(packagePolicyService.listIds).mockResolvedValue({ items: ['id1', 'id2'] } as any);
jest
.mocked(packagePolicyService.bulkUpgrade)
.mockResolvedValue([{ success: true }, { success: true }] as any);
});
describe('_runBulkUpgradeTask', () => {
it('should work for successfull upgrade', async () => {
const res = await _runBulkUpgradeTask({
abortController: new AbortController(),
logger: loggingSystemMock.createLogger(),
taskParams: { packages: [{ name: 'test_valid' }], authorizationHeader: null },
});
expect(installPackage).toBeCalled();
expect(res).toEqual([{ name: 'test_valid', success: true }]);
});
it('should return error for non successful upgrade', async () => {
const res = await _runBulkUpgradeTask({
abortController: new AbortController(),
logger: loggingSystemMock.createLogger(),
taskParams: {
packages: [
{ name: 'test_valid_1' },
{ name: 'test_invalid_1' },
{ name: 'test_valid_2' },
{ name: 'test_invalid_2' },
],
authorizationHeader: null,
},
});
expect(installPackage).toBeCalledTimes(4);
expect(res).toEqual([
{ name: 'test_valid_1', success: true },
{
name: 'test_invalid_1',
success: false,
error: { message: 'Impossible to install: test_invalid_1' },
},
{ name: 'test_valid_2', success: true },
{
name: 'test_invalid_2',
success: false,
error: { message: 'Impossible to install: test_invalid_2' },
},
]);
});
it('should work for successful upgrade with package policies upgrade', async () => {
const res = await _runBulkUpgradeTask({
abortController: new AbortController(),
logger: loggingSystemMock.createLogger(),
taskParams: {
packages: [{ name: 'test_valid' }],
authorizationHeader: null,
upgradePackagePolicies: true,
},
});
expect(res).toEqual([{ name: 'test_valid', success: true }]);
expect(installPackage).toBeCalled();
expect(packagePolicyService.bulkUpgrade).toBeCalled();
});
it('should not continue to upgrade packages when task is cancelled', async () => {
const abortController = new AbortController();
abortController.abort();
await expect(() =>
_runBulkUpgradeTask({
abortController,
logger: loggingSystemMock.createLogger(),
taskParams: {
packages: [
{ name: 'test_valid_1' },
{ name: 'test_invalid_1' },
{ name: 'test_valid_2' },
{ name: 'test_invalid_2' },
],
authorizationHeader: null,
},
})
).rejects.toThrow(/Task was aborted/);
expect(installPackage).toBeCalledTimes(0);
});
});
});

View file

@ -0,0 +1,235 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import type {
ConcreteTaskInstance,
TaskManagerSetupContract,
TaskManagerStartContract,
} from '@kbn/task-manager-plugin/server';
import type { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/core/server';
import { HTTPAuthorizationHeader } from '../../common/http_authorization_header';
import { installPackage } from '../services/epm/packages';
import { appContextService, packagePolicyService } from '../services';
import { PACKAGE_POLICY_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../constants';
const TASK_TYPE = 'fleet:packages-bulk-operations';
const TASK_TITLE = 'Fleet packages bulk operations';
const TASK_TIMEOUT = '10m';
interface BulkUpgradeTaskParams {
packages: Array<{ name: string; version?: string }>;
spaceId?: string;
authorizationHeader: HTTPAuthorizationHeader | null;
force?: boolean;
prerelease?: boolean;
upgradePackagePolicies?: boolean;
}
interface BulkUpgradeTaskState {
isDone?: boolean;
error?: { message: string };
results?: Array<
| {
success: true;
name: string;
}
| { success: false; name: string; error: { message: string } }
>;
[k: string]: unknown;
}
export function registerBulkUpgradePackagesTask(taskManager: TaskManagerSetupContract) {
taskManager.registerTaskDefinitions({
[TASK_TYPE]: {
title: TASK_TITLE,
timeout: TASK_TIMEOUT,
createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => {
const abortController = new AbortController();
return {
run: async () => {
const logger = appContextService.getLogger();
if (taskInstance.state.isDone) {
return;
}
const taskParams = taskInstance.params as BulkUpgradeTaskParams;
try {
const results = await _runBulkUpgradeTask({ abortController, logger, taskParams });
const state: BulkUpgradeTaskState = {
isDone: true,
results,
};
return {
runAt: new Date(Date.now() + 60 * 60 * 1000),
state,
};
} catch (error) {
logger.error('Packages bulk upgrade failed', { error });
return {
runAt: new Date(Date.now() + 60 * 60 * 1000),
state: {
isDone: true,
error: formatError(error),
},
};
}
},
cancel: async () => {
abortController.abort('task timed out');
},
};
},
},
});
}
function formatError(err: Error) {
return { message: err.message };
}
export async function _runBulkUpgradeTask({
abortController,
taskParams,
logger,
}: {
taskParams: BulkUpgradeTaskParams;
abortController: AbortController;
logger: Logger;
}) {
const {
packages,
spaceId = DEFAULT_SPACE_ID,
authorizationHeader,
force,
prerelease,
upgradePackagePolicies,
} = taskParams;
const esClient = appContextService.getInternalUserESClient();
const savedObjectsClient = appContextService.getInternalUserSOClientForSpaceId(spaceId);
const results: BulkUpgradeTaskState['results'] = [];
for (const pkg of packages) {
// Throw between package install if task is aborted
if (abortController.signal.aborted) {
throw new Error('Task was aborted');
}
try {
const installResult = await installPackage({
spaceId,
authorizationHeader: authorizationHeader
? new HTTPAuthorizationHeader(
authorizationHeader.scheme,
authorizationHeader.credentials,
authorizationHeader.username
)
: undefined,
installSource: 'registry', // Upgrade can only happens from the registry,
esClient,
savedObjectsClient,
pkgkey: pkg?.version ? `${pkg.name}-${pkg.version}` : pkg.name,
force,
prerelease,
});
if (installResult.error) {
throw installResult.error;
}
if (upgradePackagePolicies) {
await bulkUpgradePackagePolicies({
savedObjectsClient,
esClient,
pkgName: pkg.name,
});
}
results.push({
name: pkg.name,
success: true,
});
} catch (error) {
logger.error(`Upgrade of package: ${pkg.name} failed`, { error });
results.push({
name: pkg.name,
success: false,
error: formatError(error),
});
}
}
return results;
}
async function bulkUpgradePackagePolicies({
savedObjectsClient,
esClient,
pkgName,
}: {
savedObjectsClient: SavedObjectsClientContract;
esClient: ElasticsearchClient;
pkgName: string;
}) {
const policyIdsToUpgrade = await packagePolicyService.listIds(savedObjectsClient, {
page: 1,
perPage: SO_SEARCH_LIMIT,
kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${pkgName}`,
});
if (policyIdsToUpgrade.items.length) {
const upgradePackagePoliciesResults = await packagePolicyService.bulkUpgrade(
savedObjectsClient,
esClient,
policyIdsToUpgrade.items
);
const errors = upgradePackagePoliciesResults
.filter((result) => !result.success)
.map((result) => `${result.statusCode}: ${result.body?.message ?? ''}`);
if (errors.length) {
throw new Error(`Package policies upgrade for ${pkgName} failed:\n${errors.join('\n')}`);
}
}
}
export async function scheduleBulkUpgrade(
taskManagerStart: TaskManagerStartContract,
savedObjectsClient: SavedObjectsClientContract,
taskParams: BulkUpgradeTaskParams
) {
const id = uuidv4();
await taskManagerStart.ensureScheduled({
id: `${TASK_TYPE}:${id}`,
scope: ['fleet'],
params: taskParams,
taskType: TASK_TYPE,
runAt: new Date(Date.now() + 3 * 1000),
state: {},
});
return id;
}
export async function getBulkUpgradeTaskResults(
taskManagerStart: TaskManagerStartContract,
id: string
) {
const task = await taskManagerStart.get(`${TASK_TYPE}:${id}`);
const state: BulkUpgradeTaskState = task.state;
const status = !state?.isDone
? 'pending'
: state?.error || state?.results?.some((r) => !r.success)
? 'failed'
: 'success';
return {
status,
error: state.error,
results: state.results,
};
}

View file

@ -369,6 +369,22 @@ export const BulkInstallPackagesFromRegistryResponseSchema = schema.object({
items: schema.arrayOf(BulkInstallPackagesResponseItemSchema),
});
export const BulkUpgradePackagesResponseSchema = schema.object({ taskId: schema.string() });
export const GetOneBulkUpgradePackagesResponseSchema = schema.object({
status: schema.string(),
error: schema.maybe(schema.object({ message: schema.string() })),
results: schema.maybe(
schema.arrayOf(
schema.object({
name: schema.string(),
success: schema.boolean(),
error: schema.maybe(schema.object({ message: schema.string() })),
})
)
),
});
export const DeletePackageResponseSchema = schema.object({
items: schema.arrayOf(AssetReferenceSchema),
});
@ -549,6 +565,27 @@ export const BulkInstallPackagesFromRegistryRequestSchema = {
}),
};
export const GetOneBulkUpgradePackagesRequestSchema = {
params: schema.object({
taskId: schema.string(),
}),
};
export const BulkUpgradePackagesRequestSchema = {
body: schema.object({
packages: schema.arrayOf(
schema.object({
name: schema.string(),
version: schema.maybe(schema.string()),
}),
{ minSize: 1 }
),
prerelease: schema.maybe(schema.boolean()),
force: schema.boolean({ defaultValue: false }),
upgrade_package_policies: schema.boolean({ defaultValue: false }),
}),
};
export const InstallPackageByUploadRequestSchema = {
query: schema.object({
ignoreMappingUpdateErrors: schema.boolean({ defaultValue: false }),

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 {
BulkInstallPackageInfo,
BulkInstallPackagesResponse,
IBulkInstallPackageHTTPError,
} from '@kbn/fleet-plugin/common';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { skipIfNoDockerRegistry } from '../../helpers';
import { testUsers } from '../test_users';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const fleetAndAgents = getService('fleetAndAgents');
const deletePackage = async (name: string, version: string) => {
await supertest.delete(`/api/fleet/epm/packages/${name}/${version}`).set('kbn-xsrf', 'xxxx');
};
describe('bulk package upgrade using install api', () => {
skipIfNoDockerRegistry(providerContext);
before(async () => {
await fleetAndAgents.setup();
});
describe('bulk package upgrade with a package already installed', () => {
beforeEach(async () => {
await supertest
.post(`/api/fleet/epm/packages/multiple_versions/0.1.0`)
.set('kbn-xsrf', 'xxxx')
.send({ force: true })
.expect(200);
});
afterEach(async () => {
await deletePackage('multiple_versions', '0.1.0');
await deletePackage('multiple_versions', '0.3.0');
await deletePackage('overrides', '0.1.0');
});
it('should return 400 if no packages are requested for upgrade', async function () {
await supertest.post(`/api/fleet/epm/packages/_bulk`).set('kbn-xsrf', 'xxxx').expect(400);
});
it('should return 403 if user without integrations all requests upgrade', async function () {
await supertestWithoutAuth
.post(`/api/fleet/epm/packages/_bulk`)
.auth(testUsers.fleet_all_int_read.username, testUsers.fleet_all_int_read.password)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions', 'overrides'] })
.expect(403);
});
it('should return 403 if user without fleet access requests upgrade', async function () {
await supertestWithoutAuth
.post(`/api/fleet/epm/packages/_bulk`)
.auth(testUsers.integr_all_only.username, testUsers.integr_all_only.password)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions', 'overrides'] })
.expect(403);
});
it('should return 200 and an array for upgrading a package', async function () {
const { body }: { body: BulkInstallPackagesResponse } = await supertest
.post(`/api/fleet/epm/packages/_bulk?prerelease=true`)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions'] })
.expect(200);
expect(body.items.length).equal(1);
expect(body.items[0].name).equal('multiple_versions');
const entry = body.items[0] as BulkInstallPackageInfo;
expect(entry.version).equal('0.3.0');
});
it('should return an error for packages that do not exist', async function () {
const { body }: { body: BulkInstallPackagesResponse } = await supertest
.post(`/api/fleet/epm/packages/_bulk?prerelease=true`)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions', 'blahblah'] })
.expect(200);
expect(body.items.length).equal(2);
expect(body.items[0].name).equal('multiple_versions');
const entry = body.items[0] as BulkInstallPackageInfo;
expect(entry.version).equal('0.3.0');
const err = body.items[1] as IBulkInstallPackageHTTPError;
expect(err.statusCode).equal(404);
expect(body.items[1].name).equal('blahblah');
});
it('should upgrade multiple packages', async function () {
const { body }: { body: BulkInstallPackagesResponse } = await supertest
.post(`/api/fleet/epm/packages/_bulk?prerelease=true`)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions', 'overrides'] })
.expect(200);
expect(body.items.length).equal(2);
expect(body.items[0].name).equal('multiple_versions');
let entry = body.items[0] as BulkInstallPackageInfo;
expect(entry.version).equal('0.3.0');
entry = body.items[1] as BulkInstallPackageInfo;
expect(entry.version).equal('0.1.0');
expect(entry.name).equal('overrides');
});
});
describe('bulk upgrade without package already installed', () => {
afterEach(async () => {
await deletePackage('multiple_versions', '0.3.0');
});
it('should return 200 and an array for upgrading a package', async function () {
const { body }: { body: BulkInstallPackagesResponse } = await supertest
.post(`/api/fleet/epm/packages/_bulk?prerelease=true`)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions'] })
.expect(200);
expect(body.items.length).equal(1);
expect(body.items[0].name).equal('multiple_versions');
const entry = body.items[0] as BulkInstallPackageInfo;
expect(entry.version).equal('0.3.0');
});
});
});
}

View file

@ -6,123 +6,209 @@
*/
import expect from '@kbn/expect';
import {
BulkInstallPackageInfo,
BulkInstallPackagesResponse,
IBulkInstallPackageHTTPError,
} from '@kbn/fleet-plugin/common';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { skipIfNoDockerRegistry } from '../../helpers';
import { testUsers } from '../test_users';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const fleetAndAgents = getService('fleetAndAgents');
const kibanaServer = getService('kibanaServer');
const deletePackage = async (name: string, version: string) => {
await supertest.delete(`/api/fleet/epm/packages/${name}/${version}`).set('kbn-xsrf', 'xxxx');
};
describe('bulk package upgrade api', () => {
const installPackage = async (name: string, version: string) => {
await supertest
.post(`/api/fleet/epm/packages/${name}/${version}`)
.send({ force: true })
.set('kbn-xsrf', 'xxxx');
};
describe('packages/_bulk_upgrade', () => {
skipIfNoDockerRegistry(providerContext);
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await fleetAndAgents.setup();
});
describe('bulk package upgrade with a package already installed', () => {
beforeEach(async () => {
await supertest
.post(`/api/fleet/epm/packages/multiple_versions/0.1.0`)
.set('kbn-xsrf', 'xxxx')
.send({ force: true })
.expect(200);
});
afterEach(async () => {
await deletePackage('multiple_versions', '0.1.0');
await deletePackage('multiple_versions', '0.3.0');
await deletePackage('overrides', '0.1.0');
});
after(async () => {
await kibanaServer.savedObjects.cleanStandardList();
});
it('should return 400 if no packages are requested for upgrade', async function () {
await supertest.post(`/api/fleet/epm/packages/_bulk`).set('kbn-xsrf', 'xxxx').expect(400);
});
it('should return 403 if user without integrations all requests upgrade', async function () {
await supertestWithoutAuth
.post(`/api/fleet/epm/packages/_bulk`)
.auth(testUsers.fleet_all_int_read.username, testUsers.fleet_all_int_read.password)
describe('Validations', () => {
it('should not allow to create a _bulk_upgrade with non installed packages', async () => {
const res = await supertest
.post(`/api/fleet/epm/packages/_bulk_upgrade`)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions', 'overrides'] })
.expect(403);
});
it('should return 403 if user without fleet access requests upgrade', async function () {
await supertestWithoutAuth
.post(`/api/fleet/epm/packages/_bulk`)
.auth(testUsers.integr_all_only.username, testUsers.integr_all_only.password)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions', 'overrides'] })
.expect(403);
});
it('should return 200 and an array for upgrading a package', async function () {
const { body }: { body: BulkInstallPackagesResponse } = await supertest
.post(`/api/fleet/epm/packages/_bulk?prerelease=true`)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions'] })
.expect(200);
expect(body.items.length).equal(1);
expect(body.items[0].name).equal('multiple_versions');
const entry = body.items[0] as BulkInstallPackageInfo;
expect(entry.version).equal('0.3.0');
});
it('should return an error for packages that do not exist', async function () {
const { body }: { body: BulkInstallPackagesResponse } = await supertest
.post(`/api/fleet/epm/packages/_bulk?prerelease=true`)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions', 'blahblah'] })
.expect(200);
expect(body.items.length).equal(2);
expect(body.items[0].name).equal('multiple_versions');
const entry = body.items[0] as BulkInstallPackageInfo;
expect(entry.version).equal('0.3.0');
.send({ packages: [{ name: 'idonotexists' }] })
.expect(400);
const err = body.items[1] as IBulkInstallPackageHTTPError;
expect(err.statusCode).equal(404);
expect(body.items[1].name).equal('blahblah');
});
it('should upgrade multiple packages', async function () {
const { body }: { body: BulkInstallPackagesResponse } = await supertest
.post(`/api/fleet/epm/packages/_bulk?prerelease=true`)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions', 'overrides'] })
.expect(200);
expect(body.items.length).equal(2);
expect(body.items[0].name).equal('multiple_versions');
let entry = body.items[0] as BulkInstallPackageInfo;
expect(entry.version).equal('0.3.0');
entry = body.items[1] as BulkInstallPackageInfo;
expect(entry.version).equal('0.1.0');
expect(entry.name).equal('overrides');
expect(res.body.message).equal('Cannot upgrade non installed packages: idonotexists');
});
});
describe('bulk upgrade without package already installed', () => {
afterEach(async () => {
await deletePackage('multiple_versions', '0.3.0');
describe('Success upgrade', () => {
const POLICIES_IDS = ['multiple-versions-1', 'multiple-versions-2'];
beforeEach(async () => {
await installPackage('multiple_versions', '0.1.0');
// Create package policies
for (const id of POLICIES_IDS) {
await supertest
.post(`/api/fleet/package_policies`)
.set('kbn-xsrf', 'xxxx')
.send({
id,
policy_ids: [],
package: {
name: 'multiple_versions',
version: '0.1.0',
},
name: id,
description: '',
namespace: '',
inputs: {},
})
.expect(200);
}
});
it('should return 200 and an array for upgrading a package', async function () {
const { body }: { body: BulkInstallPackagesResponse } = await supertest
.post(`/api/fleet/epm/packages/_bulk?prerelease=true`)
afterEach(async () => {
await deletePackage('multiple_versions', '0.2.0');
for (const id of POLICIES_IDS) {
await supertest
.delete(`/api/fleet/package_policies/${id}`)
.set('kbn-xsrf', 'xxxx')
.expect(200);
}
});
async function assertPackagePoliciesVersion(expectedVersion: string) {
for (const id of POLICIES_IDS) {
const res = await supertest
.get(`/api/fleet/package_policies/${id}`)
.set('kbn-xsrf', 'xxxx')
.expect(200);
expect(res.body.item.package.version).equal(expectedVersion);
}
}
async function assetPackageInstallVersion(expectedVersion: string) {
const res = await supertest
.get(`/api/fleet/epm/packages/multiple_versions`)
.set('kbn-xsrf', 'xxxx')
.send({ packages: ['multiple_versions'] })
.expect(200);
expect(body.items.length).equal(1);
expect(body.items[0].name).equal('multiple_versions');
const entry = body.items[0] as BulkInstallPackageInfo;
expect(entry.version).equal('0.3.0');
expect(res.body.item.installationInfo.version).equal(expectedVersion);
}
it('should allow to create a _bulk_upgrade with installed packages that will succeed', async () => {
const res = await supertest
.post(`/api/fleet/epm/packages/_bulk_upgrade`)
.set('kbn-xsrf', 'xxxx')
.send({
packages: [{ name: 'multiple_versions', version: '0.2.0' }],
force: true,
prerelease: true,
})
.expect(200);
const maxTimeout = Date.now() + 60 * 1000;
let lastPollResult: string = '';
while (Date.now() < maxTimeout) {
const pollRes = await supertest
.get(`/api/fleet/epm/packages/_bulk_upgrade/${res.body.taskId}`)
.set('kbn-xsrf', 'xxxx')
.expect(200);
await new Promise((resolve) => setTimeout(resolve, 1000));
if (pollRes.body.status === 'success') {
await assetPackageInstallVersion('0.2.0');
await assertPackagePoliciesVersion('0.1.0');
return;
}
lastPollResult = JSON.stringify(pollRes.body);
}
throw new Error(`bulk upgrade of "multiple_versions" never succeed: ${lastPollResult}`);
});
it('should upgrade related policies if upgrade_package_policies:true', async () => {
const res = await supertest
.post(`/api/fleet/epm/packages/_bulk_upgrade`)
.set('kbn-xsrf', 'xxxx')
.send({
packages: [{ name: 'multiple_versions', version: '0.2.0' }],
prerelease: true,
force: true,
upgrade_package_policies: true,
})
.expect(200);
const maxTimeout = Date.now() + 60 * 1000;
let lastPollResult: string = '';
while (Date.now() < maxTimeout) {
const pollRes = await supertest
.get(`/api/fleet/epm/packages/_bulk_upgrade/${res.body.taskId}`)
.set('kbn-xsrf', 'xxxx')
.expect(200);
await new Promise((resolve) => setTimeout(resolve, 1000));
if (pollRes.body.status === 'success') {
await assertPackagePoliciesVersion('0.2.0');
await assetPackageInstallVersion('0.2.0');
return;
}
lastPollResult = JSON.stringify(pollRes.body);
}
throw new Error(`bulk upgrade of "multiple_versions" never succeed: ${lastPollResult}`);
});
});
describe('Failed upgrade', () => {
const goodPackageVersion = '0.1.0';
const badPackageVersion = '0.2.0';
beforeEach(async () => {
await installPackage('error_handling', goodPackageVersion);
});
afterEach(async () => {
await deletePackage('error_handling', goodPackageVersion);
});
it('should allow to create a _bulk_upgrade with installed packages that will fail', async () => {
const res = await supertest
.post(`/api/fleet/epm/packages/_bulk_upgrade`)
.set('kbn-xsrf', 'xxxx')
.send({ packages: [{ name: 'error_handling', version: badPackageVersion }] })
.expect(200);
const maxTimeout = Date.now() + 60 * 1000;
let lastPollResult: string = '';
while (Date.now() < maxTimeout) {
const pollRes = await supertest
.get(`/api/fleet/epm/packages/_bulk_upgrade/${res.body.taskId}`)
.set('kbn-xsrf', 'xxxx')
.expect(200);
await new Promise((resolve) => setTimeout(resolve, 1000));
if (pollRes.body.status === 'failed') {
return;
}
lastPollResult = JSON.stringify(pollRes.body);
}
throw new Error(`bulk upgrade of "multiple_versions" never failed: ${lastPollResult}`);
});
});
});

View file

@ -33,6 +33,7 @@ export default function loadTests({ loadTestFile, getService }) {
loadTestFile(require.resolve('./install_tag_assets'));
loadTestFile(require.resolve('./install_with_streaming'));
loadTestFile(require.resolve('./bulk_upgrade'));
loadTestFile(require.resolve('./bulk_install_upgrade'));
loadTestFile(require.resolve('./bulk_install'));
loadTestFile(require.resolve('./update_assets'));
loadTestFile(require.resolve('./data_stream'));

View file

@ -85,7 +85,10 @@ export default async function ({ readConfigFile, log }: FtrConfigProviderContext
'./apis/fixtures/package_verification/signatures/fleet_test_key_public.asc'
)}`,
`--xpack.securitySolution.enableExperimental=${JSON.stringify(['endpointRbacEnabled'])}`,
`--xpack.fleet.enableExperimental=${JSON.stringify(['enableAutomaticAgentUpgrades'])}`,
`--xpack.fleet.enableExperimental=${JSON.stringify([
'enableAutomaticAgentUpgrades',
'installedIntegrationsTabularUI',
])}`,
`--xpack.cloud.id='123456789'`,
`--xpack.fleet.agentless.enabled=true`,
`--xpack.fleet.agentless.api.url=https://api.agentless.url/api/v1/ess`,

View file

@ -149,6 +149,7 @@ export default function ({ getService }: FtrProviderContext) {
'fleet:check-deleted-files-task',
'fleet:delete-unenrolled-agents-task',
'fleet:deploy_agent_policies',
'fleet:packages-bulk-operations',
'fleet:reassign_action:retry',
'fleet:request_diagnostics:retry',
'fleet:setup:upgrade_managed_package_policies',