[Fleet] Disallow downgrades and filter out old versions from modal (#133136)

* [Fleet] Disallow downgrades and filter out old versions from modal

* Remove a console.log

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* Address code review comments

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* Remove an import to decrease bundle size

* Code review comment

* Increase limits size

* Update packages/kbn-optimizer/limits.yml

Co-authored-by: Jonathan Budzenski <jon@budzenski.me>

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Jonathan Budzenski <jon@budzenski.me>
This commit is contained in:
Cristina Amico 2022-06-01 11:02:31 +02:00 committed by GitHub
parent bac00e290a
commit c2b4645d90
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 280 additions and 78 deletions

View file

@ -27,7 +27,7 @@ pageLoadAssetSize:
indexLifecycleManagement: 107090
indexManagement: 140608
infra: 184320
fleet: 95000
fleet: 100000
ingestPipelines: 58003
inputControlVis: 172675
inspector: 148711

View file

@ -1,40 +0,0 @@
/*
* 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 { getMaxVersion } from './get_max_version';
describe('Fleet - getMaxVersion', () => {
it('returns the maximum version', () => {
const versions = ['8.1.0', '8.3.0', '8.2.1', '7.16.0', '8.2.0', '7.16.1', '8.3.1'];
expect(getMaxVersion(versions)).toEqual('8.3.1');
});
it('returns the maximum version when there are duplicates', () => {
const versions = ['8.1.0', '8.3.0', '8.2.1', '7.16.0', '8.2.0', '7.16.1', '8.2.0', '7.15.1'];
expect(getMaxVersion(versions)).toEqual('8.3.0');
});
it('returns the maximum version when there is a snapshot version', () => {
const versions = ['8.1.0', '8.2.0-SNAPSHOT', '7.16.0', '7.16.1'];
expect(getMaxVersion(versions)).toEqual('8.2.0-SNAPSHOT');
});
it('returns the maximum version and prefers the major version to the snapshot', () => {
const versions = ['8.1.0', '8.2.0-SNAPSHOT', '8.2.0', '7.16.0', '7.16.1'];
expect(getMaxVersion(versions)).toEqual('8.2.0');
});
it('when there is only a version returns it', () => {
const versions = ['8.1.0'];
expect(getMaxVersion(versions)).toEqual('8.1.0');
});
it('returns an empty string when the passed array is empty', () => {
const versions: string[] = [];
expect(getMaxVersion(versions)).toEqual('');
});
});

View file

@ -1,23 +0,0 @@
/*
* 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 { uniq } from 'lodash';
import semverGt from 'semver/functions/gt';
import semverCoerce from 'semver/functions/coerce';
// Find max version from an array of string versions
export function getMaxVersion(versions: string[]) {
const uniqVersions: string[] = uniq(versions);
if (uniqVersions.length === 1) {
const semverVersion = semverCoerce(uniqVersions[0])?.version;
return semverVersion ? semverVersion : '';
} else if (uniqVersions.length > 1) {
const sorted = uniqVersions.sort((a, b) => (semverGt(a, b) ? 1 : -1));
return sorted[sorted.length - 1];
}
return '';
}

View file

@ -0,0 +1,112 @@
/*
* 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 { getMaxVersion, getMinVersion, sortVersions } from './get_min_max_version';
describe('Fleet - sortVersions', () => {
it('returns the array ordered in ascending order', () => {
const versions = ['8.1.0', '8.3.0', '8.2.1', '7.16.0', '8.2.0', '7.16.1', '8.3.1'];
expect(sortVersions(versions)).toEqual([
'7.16.0',
'7.16.1',
'8.1.0',
'8.2.0',
'8.2.1',
'8.3.0',
'8.3.1',
]);
});
it('returns the array ordered in ascending order and removes duplicates', () => {
const versions = ['8.1.0', '8.3.0', '8.2.0', '7.16.0', '8.2.0', '7.16.0', '8.3.1'];
expect(sortVersions(versions)).toEqual(['7.16.0', '8.1.0', '8.2.0', '8.3.0', '8.3.1']);
});
it('returns the array ordered in ascending order when there are snapshot versions', () => {
const versions = ['8.1.0', '8.2.0-SNAPSHOT', '8.2.0', '7.16.0', '7.16.1'];
expect(sortVersions(versions)).toEqual([
'7.16.0',
'7.16.1',
'8.1.0',
'8.2.0-SNAPSHOT',
'8.2.0',
]);
});
});
describe('Fleet - getMaxVersion', () => {
it('returns the maximum version', () => {
const versions = ['8.1.0', '8.3.0', '8.2.1', '7.16.0', '8.2.0', '7.16.1', '8.3.1'];
expect(getMaxVersion(versions)).toEqual('8.3.1');
});
it('returns the maximum version if the array has a single element', () => {
const versions = ['8.1.0'];
expect(getMaxVersion(versions)).toEqual('8.1.0');
});
it('returns the maximum version when there are duplicates', () => {
const versions = ['8.1.0', '8.3.0', '8.2.1', '7.16.0', '8.2.0', '7.16.1', '8.2.0', '7.15.1'];
expect(getMaxVersion(versions)).toEqual('8.3.0');
});
it('returns the maximum version and prefers the major version to the snapshot', () => {
const versions = ['8.1.0', '8.2.0-SNAPSHOT', '8.2.0', '7.16.0', '7.16.1'];
expect(getMaxVersion(versions)).toEqual('8.2.0');
});
it('when there is only a version returns it', () => {
const versions = ['8.1.0'];
expect(getMaxVersion(versions)).toEqual('8.1.0');
});
it('returns an empty string when the passed array is empty', () => {
const versions: string[] = [];
expect(getMaxVersion(versions)).toEqual('');
});
it('returns empty string if the passed array is empty', () => {
expect(getMaxVersion([])).toEqual('');
});
it('returns empty string if the array contains invalid strings', () => {
expect(getMaxVersion(['bla', 'not-a-version'])).toEqual('');
});
});
describe('Fleet - getMinVersion', () => {
it('returns the minimum version', () => {
const versions = ['8.1.0', '8.3.0', '8.2.1', '8.0.0', '8.2.0', '8.2.1'];
expect(getMinVersion(versions)).toEqual('8.0.0');
});
it('returns the minimum version if the array has a single element', () => {
const versions = ['8.1.0'];
expect(getMaxVersion(versions)).toEqual('8.1.0');
});
it('returns the minimum version when there are duplicates', () => {
const versions = ['8.1.0', '8.3.0', '8.2.1', '7.16.0', '8.2.0', '7.16.1', '8.2.0', '7.15.1'];
expect(getMinVersion(versions)).toEqual('7.15.1');
});
it('when there is only a version returns it', () => {
const versions = ['8.1.0'];
expect(getMinVersion(versions)).toEqual('8.1.0');
});
it('returns an empty string when the passed array is empty', () => {
const versions: string[] = [];
expect(getMinVersion(versions)).toEqual('');
});
it('returns empty string if the passed array is empty', () => {
expect(getMaxVersion([])).toEqual('');
});
it('returns empty string if the array contains invalid strings', () => {
expect(getMaxVersion(['bla', 'not-a-version'])).toEqual('');
});
});

View file

@ -0,0 +1,38 @@
/*
* 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 { uniq } from 'lodash';
import semverGt from 'semver/functions/gt';
import semverCoerce from 'semver/functions/coerce';
// Sort array in ascending order
export function sortVersions(versions: string[]) {
// remove duplicates and filter out invalid versions
const uniqVersions = uniq(versions).filter((v) => semverCoerce(v)?.version !== undefined);
if (uniqVersions.length > 1) {
return uniqVersions.sort((a, b) => (semverGt(a, b) ? 1 : -1));
}
return uniqVersions;
}
// Find max version from an array of string versions
export function getMaxVersion(versions: string[]) {
const sorted = sortVersions(versions);
if (sorted.length >= 1) {
return sorted[sorted.length - 1];
}
return '';
}
// Find min version from an array of string versions
export function getMinVersion(versions: string[]) {
const sorted = sortVersions(versions);
if (sorted.length >= 1) {
return sorted[0];
}
return '';
}

View file

@ -35,3 +35,4 @@ export {
export { normalizeHostsForAgents } from './hosts_utils';
export { splitPkgKey } from './split_pkg_key';
export { getMaxPackageName } from './max_package_name';
export { getMinVersion, getMaxVersion } from './get_min_max_version';

View file

@ -168,4 +168,22 @@ describe('Fleet - isAgentUpgradeable', () => {
isAgentUpgradeable(getAgent({ version: '7.9.0', upgradeable: true }), '8.0.0-SNAPSHOT')
).toBe(true);
});
it('returns false if agent reports upgradeable, with target version < current agent version ', () => {
expect(
isAgentUpgradeable(
getAgent({ version: '7.9.0', upgradeable: true }),
'8.0.0-SNAPSHOT',
'7.8.0'
)
).toBe(false);
});
it('returns false if agent reports upgradeable, with target version == current agent version ', () => {
expect(
isAgentUpgradeable(
getAgent({ version: '7.9.0', upgradeable: true }),
'8.0.0-SNAPSHOT',
'7.9.0'
)
).toBe(false);
});
});

View file

@ -7,10 +7,11 @@
import semverCoerce from 'semver/functions/coerce';
import semverLt from 'semver/functions/lt';
import semverGt from 'semver/functions/gt';
import type { Agent } from '../types';
export function isAgentUpgradeable(agent: Agent, kibanaVersion: string) {
export function isAgentUpgradeable(agent: Agent, kibanaVersion: string, versionToUpgrade?: string) {
let agentVersion: string;
if (typeof agent?.local_metadata?.elastic?.agent?.version === 'string') {
agentVersion = agent.local_metadata.elastic.agent.version;
@ -23,7 +24,12 @@ export function isAgentUpgradeable(agent: Agent, kibanaVersion: string) {
if (!agent.local_metadata.elastic.agent.upgradeable) {
return false;
}
if (versionToUpgrade !== undefined) {
return (
isNotDowngrade(agentVersion, versionToUpgrade) &&
isAgentVersionLessThanKibana(agentVersion, kibanaVersion)
);
}
return isAgentVersionLessThanKibana(agentVersion, kibanaVersion);
}
@ -36,3 +42,12 @@ export const isAgentVersionLessThanKibana = (agentVersion: string, kibanaVersion
return semverLt(agentVersionNumber, kibanaVersionNumber);
};
export const isNotDowngrade = (agentVersion: string, versionToUpgrade: string) => {
const agentVersionNumber = semverCoerce(agentVersion);
if (!agentVersionNumber) throw new Error('agent version is not valid');
const versionToUpgradeNumber = semverCoerce(versionToUpgrade);
if (!versionToUpgradeNumber) throw new Error('target version is not valid');
return semverGt(versionToUpgradeNumber, agentVersionNumber);
};

View file

@ -25,8 +25,9 @@ import { FormattedMessage } from '@kbn/i18n-react';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import semverCoerce from 'semver/functions/coerce';
import semverLt from 'semver/functions/lt';
import semverGt from 'semver/functions/gt';
import { getMinVersion } from '../../../../../../../common/services/get_min_max_version';
import type { Agent } from '../../../../types';
import {
sendPostAgentUpgrade,
@ -62,12 +63,24 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<Props> = ({
const isAllAgents = agents === '';
const fallbackVersions = [kibanaVersion].concat(FALLBACK_VERSIONS);
const fallbackOptions: Array<EuiComboBoxOptionOption<string>> = fallbackVersions.map(
(option) => ({
const minVersion = useMemo(() => {
const versions = (agents as Agent[]).map(
(agent) => agent.local_metadata?.elastic?.agent?.version
);
return getMinVersion(versions);
}, [agents]);
const versionOptions: Array<EuiComboBoxOptionOption<string>> = useMemo(() => {
const displayVersions = minVersion
? fallbackVersions.filter((v) => semverGt(v, minVersion))
: fallbackVersions;
return displayVersions.map((option) => ({
label: option,
value: option,
})
);
}));
}, [fallbackVersions, minVersion]);
const maintainanceWindows =
isSmallBatch && !isScheduled ? [0].concat(MAINTAINANCE_VALUES) : MAINTAINANCE_VALUES;
const maintainanceOptions: Array<EuiComboBoxOptionOption<number>> = maintainanceWindows.map(
@ -84,7 +97,7 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<Props> = ({
value: option === 0 ? 0 : option * 3600,
})
);
const [selectedVersion, setSelectedVersion] = useState([fallbackOptions[0]]);
const [selectedVersion, setSelectedVersion] = useState([versionOptions[0]]);
const [selectedMantainanceWindow, setSelectedMantainanceWindow] = useState([
maintainanceOptions[0],
]);
@ -183,7 +196,12 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<Props> = ({
const onCreateOption = (searchValue: string) => {
const agentVersionNumber = semverCoerce(searchValue);
if (agentVersionNumber?.version && semverLt(agentVersionNumber?.version, kibanaVersion)) {
if (
agentVersionNumber?.version &&
semverGt(kibanaVersion, agentVersionNumber?.version) &&
minVersion &&
semverGt(agentVersionNumber?.version, minVersion)
) {
const newOption = {
label: searchValue,
value: searchValue,
@ -274,7 +292,7 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<Props> = ({
data-test-subj="agentUpgradeModal.VersionCombobox"
fullWidth
singleSelection={{ asPlainText: true }}
options={fallbackOptions}
options={versionOptions}
selectedOptions={selectedVersion}
onChange={(selected: Array<EuiComboBoxOptionOption<string>>) => {
setSelectedVersion(selected);

View file

@ -21,7 +21,7 @@ import * as AgentService from '../../services/agents';
import { appContextService } from '../../services';
import { defaultIngestErrorHandler } from '../../errors';
import { isAgentUpgradeable } from '../../../common/services';
import { getMaxVersion } from '../../../common/services/get_max_version';
import { getMaxVersion } from '../../../common/services/get_min_max_version';
import { getAgentById } from '../../services/agents';
import type { Agent } from '../../types';
@ -57,7 +57,7 @@ export const postAgentUpgradeHandler: RequestHandler<
},
});
}
if (!force && !isAgentUpgradeable(agent, kibanaVersion)) {
if (!force && !isAgentUpgradeable(agent, kibanaVersion, version)) {
return response.customError({
statusCode: 400,
body: {
@ -181,6 +181,10 @@ const checkFleetServerVersion = (versionToUpgradeNumber: string, fleetServerAgen
const maxFleetServerVersion = getMaxVersion(fleetServerVersions);
if (!maxFleetServerVersion) {
return;
}
if (semverGt(versionToUpgradeNumber, maxFleetServerVersion)) {
throw new Error(
`cannot upgrade agent to ${versionToUpgradeNumber} because it is higher than the latest fleet server version ${maxFleetServerVersion}`

View file

@ -133,8 +133,9 @@ export async function sendUpgradeAgentsActions(
const upgradeableResults = await Promise.allSettled(
agentsToCheckUpgradeable.map(async (agent) => {
// Filter out agents currently unenrolling, unenrolled, or not upgradeable b/c of version check
const isAllowed = options.force || isAgentUpgradeable(agent, kibanaVersion);
if (!isAllowed) {
const isNotAllowed =
!options.force && !isAgentUpgradeable(agent, kibanaVersion, options.version);
if (isNotAllowed) {
throw new IngestManagerError(`${agent.id} is not upgradeable`);
}

View file

@ -160,6 +160,26 @@ export default function (providerContext: FtrProviderContext) {
})
.expect(400);
});
it('should respond 400 if trying to downgrade version', async () => {
await es.update({
id: 'agent1',
refresh: 'wait_for',
index: AGENTS_INDEX,
body: {
doc: {
local_metadata: { elastic: { agent: { upgradeable: true, version: '7.0.0' } } },
},
},
});
await supertest
.post(`/api/fleet/agents/agent1/upgrade`)
.set('kbn-xsrf', 'xxx')
.send({
version: '6.0.0',
})
.expect(400);
});
it('should respond 400 if trying to upgrade with source_uri set', async () => {
const kibanaVersion = await kibanaServer.version.get();
const res = await supertest
@ -710,6 +730,44 @@ export default function (providerContext: FtrProviderContext) {
})
.expect(400);
});
it('should prevent any agent to downgrade', async () => {
await es.update({
id: 'agent1',
refresh: 'wait_for',
index: AGENTS_INDEX,
body: {
doc: {
policy_id: `agent-policy-1`,
local_metadata: { elastic: { agent: { upgradeable: true, version: '6.0.0' } } },
},
},
});
await es.update({
id: 'agent2',
refresh: 'wait_for',
index: AGENTS_INDEX,
body: {
doc: {
policy_id: `agent-policy-2`,
local_metadata: { elastic: { agent: { upgradeable: true, version: '6.0.0' } } },
},
},
});
await supertest
.post(`/api/fleet/agents/bulk_upgrade`)
.set('kbn-xsrf', 'xxx')
.send({
agents: ['agent1', 'agent2'],
version: '5.0.0',
})
.expect(200);
const [agent1data, agent2data] = await Promise.all([
supertest.get(`/api/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'),
supertest.get(`/api/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'),
]);
expect(typeof agent1data.body.item.upgrade_started_at).to.be('undefined');
expect(typeof agent2data.body.item.upgrade_started_at).to.be('undefined');
});
it('should throw an error if source_uri parameter is passed', async () => {
const kibanaVersion = await kibanaServer.version.get();