[Security Solution][Endpoint] New enroll endpoint host function CI specific for Cypress tests to use cached agent files (#171399)

## Summary

In order to avoid downloading the elastic agent installer file on each
Cypress test, we have introduced a new method CI specific that will
cache elastic agent files and reuse it across all tests.

Old code about `if CI` conditions will be removed in a follow up pr.

It also introduces a CLI script to download a specific version of
elastic agent using the existing methods in place.

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
David Sánchez 2023-11-28 17:27:25 +01:00 committed by GitHub
parent d9ebfd9af1
commit 1823d94240
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 307 additions and 53 deletions

View file

@ -14,5 +14,9 @@ for version in $(cat versions.json | jq -r '.versions[].version'); do
node scripts/es snapshot --download-only --base-path "$ES_CACHE_DIR" --version "$version"
done
for version in $(cat versions.json | jq -r '.versions[].version'); do
node x-pack/plugins/security_solution/scripts/endpoint/agent_downloader --version "$version"
done
echo "--- Cloning repos for docs build"
node scripts/validate_next_docs --clone-only

View file

@ -21,8 +21,7 @@ import { enableAllPolicyProtections } from '../../../tasks/endpoint_policy';
import { createEndpointHost } from '../../../tasks/create_endpoint_host';
import { deleteAllLoadedEndpointData } from '../../../tasks/delete_all_endpoint_data';
// FLAKY: https://github.com/elastic/kibana/issues/170667
describe.skip(
describe(
'Uninstall agent from host when agent tamper protection is disabled',
{ tags: ['@ess'] },
() => {

View file

@ -22,8 +22,7 @@ import { login } from '../../../tasks/login';
import { createEndpointHost } from '../../../tasks/create_endpoint_host';
import { deleteAllLoadedEndpointData } from '../../../tasks/delete_all_endpoint_data';
// FLAKY: https://github.com/elastic/kibana/issues/170601
describe.skip(
describe(
'Uninstall agent from host when agent tamper protection is enabled',
{ tags: ['@ess'] },
() => {

View file

@ -23,8 +23,7 @@ import { enableAllPolicyProtections } from '../../../tasks/endpoint_policy';
import { createEndpointHost } from '../../../tasks/create_endpoint_host';
import { deleteAllLoadedEndpointData } from '../../../tasks/delete_all_endpoint_data';
// FLAKY: https://github.com/elastic/kibana/issues/170604
describe.skip(
describe(
'Uninstall agent from host changing agent policy when agent tamper protection is enabled but then is switched to a policy with it disabled',
{ tags: ['@ess'] },
() => {

View file

@ -0,0 +1,132 @@
/*
* 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 { kibanaPackageJson } from '@kbn/repo-info';
import type { ToolingLog } from '@kbn/tooling-log';
import type { KbnClient } from '@kbn/test/src/kbn_client';
import { isFleetServerRunning } from '../../../../scripts/endpoint/common/fleet_server/fleet_server_services';
import type { HostVm } from '../../../../scripts/endpoint/common/types';
import type { BaseVmCreateOptions } from '../../../../scripts/endpoint/common/vm_services';
import { createVm } from '../../../../scripts/endpoint/common/vm_services';
import {
fetchAgentPolicyEnrollmentKey,
fetchFleetServerUrl,
getAgentDownloadUrl,
getAgentFileName,
getOrCreateDefaultAgentPolicy,
waitForHostToEnroll,
} from '../../../../scripts/endpoint/common/fleet_services';
import type { DownloadedAgentInfo } from '../../../../scripts/endpoint/common/agent_downloads_service';
import {
downloadAndStoreAgent,
isAgentDownloadFromDiskAvailable,
} from '../../../../scripts/endpoint/common/agent_downloads_service';
export interface CreateAndEnrollEndpointHostCIOptions
extends Pick<BaseVmCreateOptions, 'disk' | 'cpus' | 'memory'> {
kbnClient: KbnClient;
log: ToolingLog;
/** The fleet Agent Policy ID to use for enrolling the agent */
agentPolicyId: string;
/** version of the Agent to install. Defaults to stack version */
version?: string;
/** The name for the host. Will also be the name of the VM */
hostname?: string;
/** If `version` should be exact, or if this is `true`, then the closest version will be used. Defaults to `false` */
useClosestVersionMatch?: boolean;
}
export interface CreateAndEnrollEndpointHostCIResponse {
hostname: string;
agentId: string;
hostVm: HostVm;
}
/**
* Creates a new virtual machine (host) and enrolls that with Fleet
*/
export const createAndEnrollEndpointHostCI = async ({
kbnClient,
log,
agentPolicyId,
cpus,
disk,
memory,
hostname,
version = kibanaPackageJson.version,
useClosestVersionMatch = true,
}: CreateAndEnrollEndpointHostCIOptions): Promise<CreateAndEnrollEndpointHostCIResponse> => {
const vmName = hostname ?? `test-host-${Math.random().toString().substring(2, 6)}`;
const fileNameNoExtension = getAgentFileName(version);
const agentFileName = `${fileNameNoExtension}.tar.gz`;
let agentDownload: DownloadedAgentInfo | undefined;
// Check if agent file is already on disk before downloading it again
agentDownload = isAgentDownloadFromDiskAvailable(agentFileName);
// If it has not been already downloaded, it should be downloaded.
if (!agentDownload) {
log.warning(
`There is no agent installer for ${agentFileName} present on disk, trying to download it now.`
);
const { url: agentUrl } = await getAgentDownloadUrl(version, useClosestVersionMatch, log);
agentDownload = await downloadAndStoreAgent(agentUrl, agentFileName);
}
const hostVm = await createVm({
type: 'vagrant',
name: vmName,
log,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
agentDownload: agentDownload!,
disk,
cpus,
memory,
});
if (!(await isFleetServerRunning(kbnClient))) {
throw new Error(`Fleet server does not seem to be running on this instance of kibana!`);
}
const policyId = agentPolicyId || (await getOrCreateDefaultAgentPolicy({ kbnClient, log })).id;
const [fleetServerUrl, enrollmentToken] = await Promise.all([
fetchFleetServerUrl(kbnClient),
fetchAgentPolicyEnrollmentKey(kbnClient, policyId),
]);
const agentEnrollCommand = [
'sudo',
`./${fileNameNoExtension}/elastic-agent`,
'install',
'--insecure',
'--force',
'--url',
fleetServerUrl,
'--enrollment-token',
enrollmentToken,
].join(' ');
log.info(`Enrolling Elastic Agent with Fleet`);
log.verbose('Enrollment command:', agentEnrollCommand);
await hostVm.exec(agentEnrollCommand);
const { id: agentId } = await waitForHostToEnroll(kbnClient, log, hostVm.name, 240000);
return {
hostname: hostVm.name,
agentId,
hostVm,
};
};

View file

@ -26,10 +26,7 @@ import {
import type { DeleteAllEndpointDataResponse } from '../../../../scripts/endpoint/common/delete_all_endpoint_data';
import { deleteAllEndpointData } from '../../../../scripts/endpoint/common/delete_all_endpoint_data';
import { waitForEndpointToStreamData } from '../../../../scripts/endpoint/common/endpoint_metadata_services';
import type {
CreateAndEnrollEndpointHostOptions,
CreateAndEnrollEndpointHostResponse,
} from '../../../../scripts/endpoint/common/endpoint_host_services';
import type { CreateAndEnrollEndpointHostResponse } from '../../../../scripts/endpoint/common/endpoint_host_services';
import {
createAndEnrollEndpointHost,
destroyEndpointHost,
@ -66,6 +63,11 @@ import {
indexFleetEndpointPolicy,
} from '../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy';
import { cyLoadEndpointDataHandler } from './plugin_handlers/endpoint_data_loader';
import type {
CreateAndEnrollEndpointHostCIOptions,
CreateAndEnrollEndpointHostCIResponse,
} from './create_and_enroll_endpoint_host_ci';
import { createAndEnrollEndpointHostCI } from './create_and_enroll_endpoint_host_ci';
/**
* Test Role/User loader for cypress. Checks to see if running in serverless and handles it as appropriate
@ -290,40 +292,48 @@ export const dataLoadersForRealEndpoints = (
on('task', {
createEndpointHost: async (
options: Omit<CreateAndEnrollEndpointHostOptions, 'log' | 'kbnClient'>
): Promise<CreateAndEnrollEndpointHostResponse> => {
options: Omit<CreateAndEnrollEndpointHostCIOptions, 'log' | 'kbnClient'>
): Promise<CreateAndEnrollEndpointHostCIResponse> => {
const { kbnClient, log } = await stackServicesPromise;
let retryAttempt = 0;
const attemptCreateEndpointHost = async (): Promise<CreateAndEnrollEndpointHostResponse> => {
try {
log.info(`Creating endpoint host, attempt ${retryAttempt}`);
const newHost = await createAndEnrollEndpointHost({
useClosestVersionMatch: true,
...options,
log,
kbnClient,
});
await waitForEndpointToStreamData(kbnClient, newHost.agentId, 360000);
return newHost;
} catch (err) {
log.info(`Caught error when setting up the agent: ${err}`);
if (retryAttempt === 0 && err.agentId) {
retryAttempt++;
await destroyEndpointHost(kbnClient, {
hostname: err.hostname || '', // No hostname in CI env for vagrant
agentId: err.agentId,
});
log.info(`Deleted endpoint host ${err.agentId} and retrying`);
return attemptCreateEndpointHost();
} else {
log.info(
`${retryAttempt} attempts of creating endpoint host failed, reason for the last failure was ${err}`
);
throw err;
const attemptCreateEndpointHost =
async (): Promise<CreateAndEnrollEndpointHostCIResponse> => {
try {
log.info(`Creating endpoint host, attempt ${retryAttempt}`);
const newHost = process.env.CI
? await createAndEnrollEndpointHostCI({
useClosestVersionMatch: true,
...options,
log,
kbnClient,
})
: await createAndEnrollEndpointHost({
useClosestVersionMatch: true,
...options,
log,
kbnClient,
});
await waitForEndpointToStreamData(kbnClient, newHost.agentId, 360000);
return newHost;
} catch (err) {
log.info(`Caught error when setting up the agent: ${err}`);
if (retryAttempt === 0 && err.agentId) {
retryAttempt++;
await destroyEndpointHost(kbnClient, {
hostname: err.hostname || '', // No hostname in CI env for vagrant
agentId: err.agentId,
});
log.info(`Deleted endpoint host ${err.agentId} and retrying`);
return attemptCreateEndpointHost();
} else {
log.info(
`${retryAttempt} attempts of creating endpoint host failed, reason for the last failure was ${err}`
);
throw err;
}
}
}
};
};
return attemptCreateEndpointHost();
},

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
require('../../../../../src/setup_node_env');
require('./agent_downloader_cli').cli();

View file

@ -0,0 +1,32 @@
/*
* 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 { ok } from 'assert';
import type { RunFn } from '@kbn/dev-cli-runner';
import type { ToolingLog } from '@kbn/tooling-log';
import { getAgentDownloadUrl, getAgentFileName } from '../common/fleet_services';
import { downloadAndStoreAgent } from '../common/agent_downloads_service';
const downloadAndStoreElasticAgent = async (
version: string,
closestMatch: boolean,
log: ToolingLog
) => {
const downloadUrlResponse = await getAgentDownloadUrl(version, closestMatch, log);
const fileNameNoExtension = getAgentFileName(version);
const agentFile = `${fileNameNoExtension}.tar.gz`;
await downloadAndStoreAgent(downloadUrlResponse.url, agentFile);
};
export const agentDownloaderRunner: RunFn = async (cliContext) => {
ok(cliContext.flags.version, 'version argument is required');
await downloadAndStoreElasticAgent(
cliContext.flags.version as string,
cliContext.flags.closestMatch as boolean,
cliContext.log
);
};

View file

@ -0,0 +1,31 @@
/*
* 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 { run } from '@kbn/dev-cli-runner';
import { agentDownloaderRunner } from './agent_downloader';
export const cli = () => {
run(
agentDownloaderRunner,
// Options
{
description: `Elastic Agent downloader`,
flags: {
string: ['version'],
boolean: ['closestMatch'],
default: {
closestMatch: true,
},
help: `
--version Required. Elastic agent version to be downloaded.
--closestMatch Optional. Use closest elastic agent version to match with.
`,
},
}
);
};

View file

@ -64,8 +64,10 @@ class AgentDownloadStorage extends SettingsStorage<AgentDownloadStorageSettings>
}
}
public getPathsForUrl(agentDownloadUrl: string): DownloadedAgentInfo {
const filename = agentDownloadUrl.replace(/^https?:\/\//gi, '').replace(/\//g, '#');
public getPathsForUrl(agentDownloadUrl: string, agentFileName?: string): DownloadedAgentInfo {
const filename = agentFileName
? agentFileName
: agentDownloadUrl.replace(/^https?:\/\//gi, '').replace(/\//g, '#');
const directory = this.downloadsDirFullPath;
const fullFilePath = this.buildPath(join(this.downloadsDirName, filename));
@ -76,14 +78,17 @@ class AgentDownloadStorage extends SettingsStorage<AgentDownloadStorageSettings>
};
}
public async downloadAndStore(agentDownloadUrl: string): Promise<DownloadedAgentInfo> {
public async downloadAndStore(
agentDownloadUrl: string,
agentFileName?: string
): Promise<DownloadedAgentInfo> {
this.log.debug(`Downloading and storing: ${agentDownloadUrl}`);
// TODO: should we add "retry" attempts to file downloads?
await this.ensureExists();
const newDownloadInfo = this.getPathsForUrl(agentDownloadUrl);
const newDownloadInfo = this.getPathsForUrl(agentDownloadUrl, agentFileName);
// If download is already present on disk, then just return that info. No need to re-download it
if (fs.existsSync(newDownloadInfo.fullFilePath)) {
@ -154,6 +159,18 @@ class AgentDownloadStorage extends SettingsStorage<AgentDownloadStorageSettings>
return response;
}
public isAgentDownloadFromDiskAvailable(filename: string): DownloadedAgentInfo | undefined {
if (fs.existsSync(join(this.downloadsDirFullPath, filename))) {
return {
filename,
/** The local directory where downloads are stored */
directory: this.downloadsDirFullPath,
/** The full local file path and name */
fullFilePath: join(this.downloadsDirFullPath, filename),
};
}
}
}
const handleProcessInterruptions = async <T>(
@ -203,11 +220,16 @@ export interface DownloadAndStoreAgentResponse extends DownloadedAgentInfo {
* already exists on disk, then no download is actually done - the information about the cached
* version is returned instead
* @param agentDownloadUrl
* @param agentFileName
*/
export const downloadAndStoreAgent = async (
agentDownloadUrl: string
agentDownloadUrl: string,
agentFileName?: string
): Promise<DownloadAndStoreAgentResponse> => {
const downloadedAgent = await agentDownloadsClient.downloadAndStore(agentDownloadUrl);
const downloadedAgent = await agentDownloadsClient.downloadAndStore(
agentDownloadUrl,
agentFileName
);
return {
url: agentDownloadUrl,
@ -221,3 +243,9 @@ export const downloadAndStoreAgent = async (
export const cleanupDownloads = async (): ReturnType<AgentDownloadStorage['cleanupDownloads']> => {
return agentDownloadsClient.cleanupDownloads();
};
export const isAgentDownloadFromDiskAvailable = (
fileName: string
): DownloadedAgentInfo | undefined => {
return agentDownloadsClient.isAgentDownloadFromDiskAvailable(fileName);
};

View file

@ -373,6 +373,16 @@ export const getAgentVersionMatchingCurrentStack = async (
return version;
};
// Generates a file name using system arch and an agent version.
export const getAgentFileName = (agentVersion: string): string => {
const downloadArch =
{ arm64: 'arm64', x64: 'x86_64' }[process.arch as string] ??
`UNSUPPORTED_ARCHITECTURE_${process.arch}`;
const fileName = `elastic-agent-${agentVersion}-linux-${downloadArch}`;
return fileName;
};
interface ElasticArtifactSearchResponse {
manifest: {
'last-update-time': string;
@ -414,11 +424,9 @@ export const getAgentDownloadUrl = async (
log?: ToolingLog
): Promise<GetAgentDownloadUrlResponse> => {
const agentVersion = closestMatch ? await getLatestAgentDownloadVersion(version, log) : version;
const downloadArch =
{ arm64: 'arm64', x64: 'x86_64' }[process.arch as string] ??
`UNSUPPORTED_ARCHITECTURE_${process.arch}`;
const fileNameNoExtension = `elastic-agent-${agentVersion}-linux-${downloadArch}`;
const agentFile = `${fileNameNoExtension}.tar.gz`;
const fileNameWithoutExtension = getAgentFileName(agentVersion);
const agentFile = `${fileNameWithoutExtension}.tar.gz`;
const artifactSearchUrl = `https://artifacts-api.elastic.co/v1/search/${agentVersion}/${agentFile}`;
log?.verbose(`Retrieving elastic agent download URL from:\n ${artifactSearchUrl}`);
@ -444,7 +452,7 @@ export const getAgentDownloadUrl = async (
return {
url: searchResult.packages[agentFile].url,
fileName: agentFile,
dirName: fileNameNoExtension,
dirName: fileNameWithoutExtension,
};
};

View file

@ -5,6 +5,7 @@
hostname = ENV["VMNAME"] || 'ubuntu'
cachedAgentSource = ENV["CACHED_AGENT_SOURCE"] || ''
cachedAgentFilename = ENV["CACHED_AGENT_FILENAME"] || ''
agentDestinationFolder = ENV["AGENT_DESTINATION_FOLDER"] || ''
Vagrant.configure("2") do |config|
config.vm.hostname = hostname
@ -29,6 +30,7 @@ Vagrant.configure("2") do |config|
end
config.vm.provision "file", source: cachedAgentSource, destination: "~/#{cachedAgentFilename}"
config.vm.provision "shell", inline: "tar -zxf #{cachedAgentFilename} && rm -f #{cachedAgentFilename}"
config.vm.provision "shell", inline: "mkdir #{agentDestinationFolder}"
config.vm.provision "shell", inline: "tar -zxf #{cachedAgentFilename} --directory #{agentDestinationFolder} --strip-components=1 && rm -f #{cachedAgentFilename}"
config.vm.provision "shell", inline: "sudo apt-get install unzip"
end

View file

@ -249,6 +249,7 @@ const createVagrantVm = async ({
VMNAME: name,
CACHED_AGENT_SOURCE: agentFullFilePath,
CACHED_AGENT_FILENAME: agentFileName,
AGENT_DESTINATION_FOLDER: agentFileName.replace('.tar.gz', ''),
},
// Only `pipe` STDERR to parent process
stdio: ['inherit', 'inherit', 'pipe'],