From a26eec451fb54317a2b08d1f49b3f2b30fcbddca Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Wed, 13 Dec 2023 17:00:20 +0100 Subject: [PATCH] [Fleet] allow agent upgrades if patch version is higher than kibana (#173167) ## Summary Closes https://github.com/elastic/kibana/issues/168502 Changed the version check to allow agent upgrade if the agent version has a newer patch than kibana. To verify: - change kibana locally to return a mock version `8.11.0` [here](https://github.com/elastic/kibana/blob/05bfe53cb3a2fe33ecb9eec4a6fcb19a492aaadf/x-pack/plugins/fleet/public/hooks/use_kibana_version.ts#L17) - enroll an agent version 8.11.0 - verify that the upgrade is allowed to 8.11.1 and 8.11.2 - verify that the upgrade works image image image Tested the new agent build version by adding a dummy version to the available_versions API response. It is showing up for an agent 8.11.1 (same as the mock kibana version): image ### Checklist - [x] [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 --- .../agent_upgrade_modal/index.test.tsx | 231 ++++++++++-------- .../components/agent_upgrade_modal/index.tsx | 5 +- .../public/hooks/use_agent_version.test.ts | 43 ++++ .../fleet/public/hooks/use_agent_version.ts | 19 +- 4 files changed, 194 insertions(+), 104 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.test.tsx index 11dcd1a34bb2a..7850ae3a3128d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.test.tsx @@ -11,7 +11,7 @@ import { act, fireEvent, waitFor } from '@testing-library/react'; import { createFleetTestRendererMock } from '../../../../../../mock'; -import { sendPostBulkAgentUpgrade } from '../../../../hooks'; +import { sendGetAgentsAvailableVersions, sendPostBulkAgentUpgrade } from '../../../../hooks'; import { AgentUpgradeAgentModal } from '.'; import type { AgentUpgradeAgentModalProps } from '.'; @@ -34,6 +34,8 @@ jest.mock('../../../../hooks', () => { const mockSendPostBulkAgentUpgrade = sendPostBulkAgentUpgrade as jest.Mock; +const mockSendGetAgentsAvailableVersions = sendGetAgentsAvailableVersions as jest.Mock; + function renderAgentUpgradeAgentModal(props: Partial) { const renderer = createFleetTestRendererMock(); @@ -45,126 +47,155 @@ function renderAgentUpgradeAgentModal(props: Partial { - it('should set the default to Immediately if there is less than 10 agents using kuery', async () => { - const { utils } = renderAgentUpgradeAgentModal({ - agents: '*', - agentCount: 3, + describe('maintenance window', () => { + it('should set the default to Immediately if there is less than 10 agents using kuery', async () => { + const { utils } = renderAgentUpgradeAgentModal({ + agents: '*', + agentCount: 3, + }); + + const el = utils.getByTestId('agentUpgradeModal.MaintenanceCombobox'); + expect(el?.textContent).toBe('Immediately'); }); - const el = utils.getByTestId('agentUpgradeModal.MaintenanceCombobox'); - expect(el?.textContent).toBe('Immediately'); - }); + it('should set the default to Immediately if there is less than 10 agents using selected agents', async () => { + const { utils } = renderAgentUpgradeAgentModal({ + agents: [{ id: 'agent1' }, { id: 'agent2' }] as any, + agentCount: 3, + }); - it('should set the default to Immediately if there is less than 10 agents using selected agents', async () => { - const { utils } = renderAgentUpgradeAgentModal({ - agents: [{ id: 'agent1' }, { id: 'agent2' }] as any, - agentCount: 3, + const el = utils.getByTestId('agentUpgradeModal.MaintenanceCombobox'); + expect(el?.textContent).toBe('Immediately'); }); - const el = utils.getByTestId('agentUpgradeModal.MaintenanceCombobox'); - expect(el?.textContent).toBe('Immediately'); - }); + it('should set the default to 1 hour if there is more than 10 agents', async () => { + const { utils } = renderAgentUpgradeAgentModal({ + agents: '*', + agentCount: 13, + }); - it('should set the default to 1 hour if there is more than 10 agents', async () => { - const { utils } = renderAgentUpgradeAgentModal({ - agents: '*', - agentCount: 13, + const el = utils.getByTestId('agentUpgradeModal.MaintenanceCombobox'); + expect(el?.textContent).toBe('1 hour'); }); - - const el = utils.getByTestId('agentUpgradeModal.MaintenanceCombobox'); - expect(el?.textContent).toBe('1 hour'); }); - it('should enable the version combo if agents is a query', async () => { - const { utils } = renderAgentUpgradeAgentModal({ - agents: '*', - agentCount: 30, + describe('version combo', () => { + it('should enable the version combo if agents is a query', async () => { + const { utils } = renderAgentUpgradeAgentModal({ + agents: '*', + agentCount: 30, + }); + + const el = utils.getByTestId('agentUpgradeModal.VersionCombobox'); + await waitFor(() => { + expect(el.classList.contains('euiComboBox-isDisabled')).toBe(false); + }); }); - const el = utils.getByTestId('agentUpgradeModal.VersionCombobox'); - await waitFor(() => { - expect(el.classList.contains('euiComboBox-isDisabled')).toBe(false); - }); - }); + it('should default the version combo to latest agent version', async () => { + const { utils } = renderAgentUpgradeAgentModal({ + agents: [{ id: 'agent1', local_metadata: { host: 'abc' } }] as any, + agentCount: 1, + }); - it('should default the version combo to latest agent version', async () => { - const { utils } = renderAgentUpgradeAgentModal({ - agents: [{ id: 'agent1', local_metadata: { host: 'abc' } }] as any, - agentCount: 1, + const el = utils.getByTestId('agentUpgradeModal.VersionCombobox'); + await waitFor(() => { + expect(el.textContent).toEqual('8.10.2'); + }); }); - const el = utils.getByTestId('agentUpgradeModal.VersionCombobox'); - await waitFor(() => { - expect(el.textContent).toEqual('8.10.2'); + it('should display available version options', async () => { + mockSendGetAgentsAvailableVersions.mockClear(); + mockSendGetAgentsAvailableVersions.mockResolvedValue({ + data: { + items: ['8.10.4', '8.10.2+build123456789', '8.10.2', '8.7.0'], + }, + }); + const { utils } = renderAgentUpgradeAgentModal({ + agents: [ + { + id: 'agent1', + local_metadata: { host: 'abc', elastic: { agent: { version: '8.10.2' } } }, + }, + ] as any, + agentCount: 1, + }); + fireEvent.click(await utils.findByTestId('comboBoxToggleListButton')); + const optionList = await utils.findByTestId( + 'comboBoxOptionsList agentUpgradeModal.VersionCombobox-optionsList' + ); + expect(optionList.textContent).toEqual(['8.10.4', '8.10.2+build123456789'].join('')); }); }); - it('should restart uprade on updating agents if some agents in updating', async () => { - const { utils } = renderAgentUpgradeAgentModal({ - agents: [ - { status: 'updating', upgrade_started_at: '2022-11-21T12:27:24Z', id: 'agent1' }, - { id: 'agent2' }, - ] as any, - agentCount: 2, - isUpdating: true, + describe('restart upgrade', () => { + it('should restart uprade on updating agents if some agents in updating', async () => { + const { utils } = renderAgentUpgradeAgentModal({ + agents: [ + { status: 'updating', upgrade_started_at: '2022-11-21T12:27:24Z', id: 'agent1' }, + { id: 'agent2' }, + ] as any, + agentCount: 2, + isUpdating: true, + }); + + const el = utils.getByTestId('confirmModalTitleText'); + expect(el.textContent).toEqual('Restart upgrade on 1 out of 2 agents stuck in updating'); + + const btn = utils.getByTestId('confirmModalConfirmButton'); + await waitFor(() => { + expect(btn).toBeEnabled(); + }); + + act(() => { + fireEvent.click(btn); + }); + + expect(mockSendPostBulkAgentUpgrade.mock.calls.at(-1)[0]).toEqual( + expect.objectContaining({ agents: ['agent1'], force: true }) + ); }); - const el = utils.getByTestId('confirmModalTitleText'); - expect(el.textContent).toEqual('Restart upgrade on 1 out of 2 agents stuck in updating'); - - const btn = utils.getByTestId('confirmModalConfirmButton'); - await waitFor(() => { - expect(btn).toBeEnabled(); - }); - - act(() => { - fireEvent.click(btn); + it('should restart upgrade on updating agents if kuery', async () => { + const { utils } = renderAgentUpgradeAgentModal({ + agents: '*', + agentCount: 3, + isUpdating: true, + }); + + const el = await utils.findByTestId('confirmModalTitleText'); + expect(el.textContent).toEqual('Restart upgrade on 2 out of 3 agents stuck in updating'); + + const btn = utils.getByTestId('confirmModalConfirmButton'); + await waitFor(() => { + expect(btn).toBeEnabled(); + }); + + act(() => { + fireEvent.click(btn); + }); + + expect(mockSendPostBulkAgentUpgrade.mock.calls.at(-1)[0]).toEqual( + expect.objectContaining({ + agents: + '(*) AND status:updating AND upgrade_started_at:* AND NOT upgraded_at:* AND upgrade_started_at < now-2h', + force: true, + }) + ); }); - expect(mockSendPostBulkAgentUpgrade.mock.calls.at(-1)[0]).toEqual( - expect.objectContaining({ agents: ['agent1'], force: true }) - ); - }); - - it('should restart upgrade on updating agents if kuery', async () => { - const { utils } = renderAgentUpgradeAgentModal({ - agents: '*', - agentCount: 3, - isUpdating: true, + it('should disable submit button if no agents stuck updating', () => { + const { utils } = renderAgentUpgradeAgentModal({ + agents: [ + { status: 'offline', upgrade_started_at: '2022-11-21T12:27:24Z', id: 'agent1' }, + { id: 'agent2' }, + ] as any, + agentCount: 2, + isUpdating: true, + }); + + const el = utils.getByTestId('confirmModalConfirmButton'); + expect(el).toBeDisabled(); }); - - const el = await utils.findByTestId('confirmModalTitleText'); - expect(el.textContent).toEqual('Restart upgrade on 2 out of 3 agents stuck in updating'); - - const btn = utils.getByTestId('confirmModalConfirmButton'); - await waitFor(() => { - expect(btn).toBeEnabled(); - }); - - act(() => { - fireEvent.click(btn); - }); - - expect(mockSendPostBulkAgentUpgrade.mock.calls.at(-1)[0]).toEqual( - expect.objectContaining({ - agents: - '(*) AND status:updating AND upgrade_started_at:* AND NOT upgraded_at:* AND upgrade_started_at < now-2h', - force: true, - }) - ); - }); - - it('should disable submit button if no agents stuck updating', () => { - const { utils } = renderAgentUpgradeAgentModal({ - agents: [ - { status: 'offline', upgrade_started_at: '2022-11-21T12:27:24Z', id: 'agent1' }, - { id: 'agent2' }, - ] as any, - agentCount: 2, - isUpdating: true, - }); - - const el = utils.getByTestId('confirmModalConfirmButton'); - expect(el).toBeDisabled(); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx index d361349b3f327..0acb4296befdc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx @@ -44,6 +44,7 @@ import { useConfig, sendGetAgentStatus, useAgentVersion, + differsOnlyInPatch, } from '../../../../hooks'; import { sendGetAgentsAvailableVersions } from '../../../../hooks'; @@ -164,7 +165,9 @@ export const AgentUpgradeAgentModal: React.FunctionComponent> = useMemo(() => { const displayVersions = minVersion - ? availableVersions.filter((v) => semverGt(v, minVersion)) + ? availableVersions.filter( + (v) => semverGt(v, minVersion) || differsOnlyInPatch(v, minVersion, false) + ) : availableVersions; const options = displayVersions.map((option) => ({ diff --git a/x-pack/plugins/fleet/public/hooks/use_agent_version.test.ts b/x-pack/plugins/fleet/public/hooks/use_agent_version.test.ts index 6cb1c8ee42248..34a7afe33baaa 100644 --- a/x-pack/plugins/fleet/public/hooks/use_agent_version.test.ts +++ b/x-pack/plugins/fleet/public/hooks/use_agent_version.test.ts @@ -37,6 +37,24 @@ describe('useAgentVersion', () => { expect(result.current).toEqual(mockKibanaVersion); }); + it('should return agent version with newer patch than kibana', async () => { + const mockKibanaVersion = '8.8.1'; + const mockAvailableVersions = ['8.9.0', '8.8.2', '8.8.0', '8.7.0']; + + (useKibanaVersion as jest.Mock).mockReturnValue(mockKibanaVersion); + (sendGetAgentsAvailableVersions as jest.Mock).mockResolvedValue({ + data: { items: mockAvailableVersions }, + }); + + const { result, waitForNextUpdate } = renderHook(() => useAgentVersion()); + + expect(sendGetAgentsAvailableVersions).toHaveBeenCalled(); + + await waitForNextUpdate(); + + expect(result.current).toEqual('8.8.2'); + }); + it('should return the latest availeble agent version if a version that matches Kibana version is not released', async () => { const mockKibanaVersion = '8.11.0'; const mockAvailableVersions = ['8.8.0', '8.7.0', '8.9.2', '7.16.0']; @@ -122,4 +140,29 @@ describe('useAgentVersion', () => { expect(result.current).toEqual(mockKibanaVersion); }); + + it('should return the latest availeble agent version if has build suffix', async () => { + const mockKibanaVersion = '8.11.0'; + const mockAvailableVersions = [ + '8.12.0', + '8.11.1+build123456789', + '8.8.0', + '8.7.0', + '8.9.2', + '7.16.0', + ]; + + (useKibanaVersion as jest.Mock).mockReturnValue(mockKibanaVersion); + (sendGetAgentsAvailableVersions as jest.Mock).mockResolvedValue({ + data: { items: mockAvailableVersions }, + }); + + const { result, waitForNextUpdate } = renderHook(() => useAgentVersion()); + + expect(sendGetAgentsAvailableVersions).toHaveBeenCalled(); + + await waitForNextUpdate(); + + expect(result.current).toEqual('8.11.1+build123456789'); + }); }); diff --git a/x-pack/plugins/fleet/public/hooks/use_agent_version.ts b/x-pack/plugins/fleet/public/hooks/use_agent_version.ts index 8c198dbc7773e..85409759cd88a 100644 --- a/x-pack/plugins/fleet/public/hooks/use_agent_version.ts +++ b/x-pack/plugins/fleet/public/hooks/use_agent_version.ts @@ -25,15 +25,15 @@ export const useAgentVersion = (): string | undefined => { const availableVersions = res?.data?.items; let agentVersionToUse; + availableVersions?.sort(semverRcompare); if ( availableVersions && availableVersions.length > 0 && - availableVersions.indexOf(kibanaVersion) === -1 + availableVersions.indexOf(kibanaVersion) !== 0 ) { - availableVersions.sort(semverRcompare); agentVersionToUse = availableVersions.find((version) => { - return semverLt(version, kibanaVersion); + return semverLt(version, kibanaVersion) || differsOnlyInPatch(version, kibanaVersion); }) || availableVersions[0]; } else { agentVersionToUse = kibanaVersion; @@ -50,3 +50,16 @@ export const useAgentVersion = (): string | undefined => { return agentVersion; }; + +export const differsOnlyInPatch = ( + versionA: string, + versionB: string, + allowEqualPatch: boolean = true +): boolean => { + const [majorA, minorA, patchA] = versionA.split('.'); + const [majorB, minorB, patchB] = versionB.split('.'); + + return ( + majorA === majorB && minorA === minorB && (allowEqualPatch ? patchA >= patchB : patchA > patchB) + ); +};