From 0350dd28c7bbccb003244f49454e676e94fd138b Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Tue, 17 Dec 2024 08:31:12 -0500 Subject: [PATCH] [8.x] [Security Solution][Endpoint] Cypress test improvements to capture Agent diagnostics file when test fails (#202965) (#204485) # Backport This will backport the following commits from `main` to `8.x`: - [[Security Solution][Endpoint] Cypress test improvements to capture Agent diagnostics file when test fails (#202965)](https://github.com/elastic/kibana/pull/202965) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) --- .../public/management/cypress/cypress.d.ts | 8 + .../response_console/file_operations.cy.ts | 9 + .../cypress/support/data_loaders.ts | 67 ++++++ .../support/setup_tooling_log_level.ts | 14 +- .../public/management/cypress/types.ts | 5 + .../fleet_server/fleet_server_services.ts | 19 +- .../scripts/endpoint/common/fleet_services.ts | 221 ++++++++++++++++++ .../scripts/endpoint/common/types.ts | 10 +- .../scripts/endpoint/common/vm_services.ts | 98 ++++++-- .../scripts/run_cypress/parallel.ts | 8 +- .../run_cypress/parallel_serverless.ts | 12 +- .../scripts/run_cypress/utils.ts | 21 ++ 12 files changed, 461 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts b/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts index eb4264982790..f5bb7071d859 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts @@ -11,6 +11,7 @@ import type { CasePostRequest } from '@kbn/cases-plugin/common/api'; import type { UsageRecord } from '@kbn/security-solution-serverless/server/types'; +import type { HostVmTransferResponse } from '../../../scripts/endpoint/common/types'; import type { DeletedEndpointHeartbeats, IndexedEndpointHeartbeats, @@ -30,6 +31,7 @@ import type { UninstallAgentFromHostTaskOptions, IsAgentAndEndpointUninstalledFromHostTaskOptions, LogItTaskOptions, + CaptureHostVmAgentDiagnosticsOptions, } from './types'; import type { DeleteIndexedFleetEndpointPoliciesResponse, @@ -267,6 +269,12 @@ declare global { arg: LogItTaskOptions, options?: Partial ): Chainable; + + task( + name: 'captureHostVmAgentDiagnostics', + arg: CaptureHostVmAgentDiagnosticsOptions, + options?: Partial + ): Chainable>; } } } diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/file_operations.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/file_operations.cy.ts index ef252cbce01a..5fbb9118370e 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/file_operations.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/file_operations.cy.ts @@ -66,6 +66,15 @@ describe('Response console', { tags: ['@ess', '@serverless'] }, () => { } }); + afterEach(function () { + if (Cypress.env('IS_CI') && this.currentTest?.isFailed() && createdHost) { + cy.task('captureHostVmAgentDiagnostics', { + hostname: createdHost.hostname, + fileNamePrefix: this.currentTest?.fullTitle(), + }); + } + }); + it('"get-file --path" - should retrieve a file', () => { const downloadsFolder = Cypress.config('downloadsFolder'); diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts index 3abf04765ca5..a85feb74f1d9 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts @@ -11,6 +11,10 @@ import type { CasePostRequest } from '@kbn/cases-plugin/common'; import execa from 'execa'; import type { KbnClient } from '@kbn/test'; import type { ToolingLog } from '@kbn/tooling-log'; +import { REPO_ROOT } from '@kbn/repo-info'; +// This is a Cypress module and only used by Cypress, so disabling "should" be safe +// eslint-disable-next-line import/no-nodejs-modules +import { mkdir } from 'node:fs/promises'; import type { IndexedEndpointHeartbeats } from '../../../../common/endpoint/data_loaders/index_endpoint_hearbeats'; import { deleteIndexedEndpointHeartbeats, @@ -53,6 +57,7 @@ import type { LoadUserAndRoleCyTaskOptions, CreateUserAndRoleCyTaskOptions, LogItTaskOptions, + CaptureHostVmAgentDiagnosticsOptions, } from '../types'; import type { DeletedIndexedEndpointRuleAlerts, @@ -75,6 +80,7 @@ import { deleteAgentPolicy, fetchAgentPolicyEnrollmentKey, getOrCreateDefaultAgentPolicy, + setAgentLoggingLevel, } from '../../../../scripts/endpoint/common/fleet_services'; import { startElasticAgentWithDocker } from '../../../../scripts/endpoint/common/elastic_agent_service'; import type { IndexedFleetEndpointPolicyResponse } from '../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy'; @@ -433,6 +439,7 @@ ${s1Info.status} log, kbnClient, }); + await setAgentLoggingLevel(kbnClient, newHost.agentId, 'debug', log); await waitForEndpointToStreamData(kbnClient, newHost.agentId, 360000); return newHost; } catch (err) { @@ -531,5 +538,65 @@ ${s1Info.status} await startEndpointHost(hostName); return null; }, + + /** + * Generates an Agent Diagnostics archive (ZIP) directly on the Host VM and saves it to a directory + * that is then included with the list of Artifacts that are captured with Buildkite job. + * + * ### Usage: + * + * This task is best used from a `afterEach()` by checking if the test failed and if so (and it + * was a test that was running against a host VM), then capture the diagnostics file + * + * @param hostname + * + * @example + * + * describe('something', () => { + * let hostVm; + * + * afterEach(function() { // << Important: Note the use of `function()` here instead of arrow function + * if (this.currentTest?.isFailed() && hostVm) { + * cy.task('captureHostVmAgentDiagnostics', { hostname: hostVm.hostname }); + * } + * }); + * + * //... + * }) + */ + captureHostVmAgentDiagnostics: async ({ + hostname, + fileNamePrefix = '', + }: CaptureHostVmAgentDiagnosticsOptions) => { + const { log } = await stackServicesPromise; + + log.info(`Capturing agent diagnostics for host VM [${hostname}]`); + + const vmClient = getHostVmClient(hostname, undefined, undefined, log); + const fileName = `elastic-agent-diagnostics-${hostname}-${new Date() + .toISOString() + .replace(/:/g, '.')}.zip`; + const vmDiagnosticsFile = `/tmp/${fileName}`; + const localDiagnosticsDir = `${REPO_ROOT}/target/test_failures`; + const localDiagnosticsFile = `${localDiagnosticsDir}/${ + fileNamePrefix + ? // Insure the file name prefix does not have characters that can't be used in file names + `${fileNamePrefix.replace(/[><:"/\\|?*'`{} ]/g, '_')}-` + : '' + }${fileName}`; + + await mkdir(localDiagnosticsDir, { recursive: true }); + + // generate diagnostics file on the host and then download it + await vmClient.exec( + `sudo /opt/Elastic/Agent/elastic-agent diagnostics --file ${vmDiagnosticsFile}` + ); + return vmClient.download(vmDiagnosticsFile, localDiagnosticsFile).then((response) => { + log.info(`Agent diagnostic file for host [${hostname}] has been downloaded and is available at: + ${response.filePath} +`); + return { filePath: response.filePath }; + }); + }, }); }; diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/setup_tooling_log_level.ts b/x-pack/plugins/security_solution/public/management/cypress/support/setup_tooling_log_level.ts index b4901bef9321..a8c85fad1c3a 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/setup_tooling_log_level.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/setup_tooling_log_level.ts @@ -16,10 +16,20 @@ export const setupToolingLogLevel = (config: Cypress.PluginConfigOptions) => { const log = createToolingLogger(); const defaultToolingLogLevel = config.env.TOOLING_LOG_LEVEL; - log.info(`Cypress config 'env.TOOLING_LOG_LEVEL': ${defaultToolingLogLevel}`); + log.info(` + +Cypress Configuration File: ${config.configFile} + +'env.TOOLING_LOG_LEVEL' set to: ${defaultToolingLogLevel} + +*** FYI: *** To help with test failures, an environmental variable named 'TOOLING_LOG_LEVEL' can be set + with a value of 'verbose' in order to capture more data in the logs. This environment + property can be set either in the runtime environment (ex. local shell or buildkite) or + directly in the Cypress configuration file \`env: {}\` section. + + `); if (defaultToolingLogLevel && defaultToolingLogLevel !== createToolingLogger.defaultLogLevel) { createToolingLogger.defaultLogLevel = defaultToolingLogLevel; - log.info(`Default log level for 'createToolingLogger()' set to ${defaultToolingLogLevel}`); } }; diff --git a/x-pack/plugins/security_solution/public/management/cypress/types.ts b/x-pack/plugins/security_solution/public/management/cypress/types.ts index 8beb150a64d5..e23ff82fa847 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/types.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/types.ts @@ -81,3 +81,8 @@ export interface LogItTaskOptions { level: keyof Pick; data: any; } + +export interface CaptureHostVmAgentDiagnosticsOptions { + hostname: string; + fileNamePrefix?: string; +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_server/fleet_server_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_server/fleet_server_services.ts index 51260c5ac605..cc9a75148d9d 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_server/fleet_server_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_server/fleet_server_services.ts @@ -277,6 +277,7 @@ const startFleetServerWithDocker = async ({ const isServerless = await isServerlessKibanaFlavor(kbnClient); const esURL = new URL(await getFleetElasticsearchOutputHost(kbnClient)); const containerName = `dev-fleet-server.${port}`; + let fleetServerVersionInfo = ''; log.info( `Starting a new fleet server using Docker\n Agent version: ${agentVersion}\n Server URL: ${fleetServerUrl}` @@ -284,10 +285,11 @@ const startFleetServerWithDocker = async ({ let retryAttempt = isServerless ? 0 : 1; const attemptServerlessFleetServerSetup = async (): Promise => { + fleetServerVersionInfo = ''; + return log.indent(4, async () => { const hostname = `dev-fleet-server.${port}.${Math.random().toString(32).substring(2, 6)}`; let containerId = ''; - let fleetServerVersionInfo = ''; if (isLocalhost(esURL.hostname)) { esURL.hostname = localhostRealIp; @@ -361,8 +363,17 @@ const startFleetServerWithDocker = async ({ } fleetServerVersionInfo = isServerless - ? // `/usr/bin/fleet-server` process does not seem to support a `--version` type of argument - 'Running latest standalone fleet server' + ? ( + await execa + .command(`docker exec ${containerName} /usr/bin/fleet-server --version`) + .catch((err) => { + log.verbose( + `Failed to retrieve fleet-server (serverless/standalone) version information from running instance.`, + err + ); + return { stdout: 'Unable to retrieve version information (serverless)' }; + }) + ).stdout : ( await execa('docker', [ 'exec', @@ -424,7 +435,7 @@ Kill container: ${chalk.cyan(`docker kill ${containerId}`)} const response: StartedServer = await attemptServerlessFleetServerSetup(); - log.info(`Done. Fleet server up and running`); + log.info(`Done. Fleet server up and running (version: ${fleetServerVersionInfo})`); return response; }; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts index 92c13a521ed2..7c89beec9150 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts @@ -21,10 +21,12 @@ import type { GetAgentsResponse, GetInfoResponse, GetOneAgentPolicyResponse, + GetOnePackagePolicyResponse, GetPackagePoliciesRequest, GetPackagePoliciesResponse, PackagePolicy, PostFleetSetupResponse, + UpdatePackagePolicyResponse, } from '@kbn/fleet-plugin/common'; import { AGENT_API_ROUTES, @@ -39,6 +41,7 @@ import { PACKAGE_POLICY_API_ROUTES, PACKAGE_POLICY_SAVED_OBJECT_TYPE, SETUP_API_ROUTE, + packagePolicyRouteService, } from '@kbn/fleet-plugin/common'; import type { ToolingLog } from '@kbn/tooling-log'; import type { KbnClient } from '@kbn/test'; @@ -53,15 +56,20 @@ import type { DeleteAgentPolicyResponse, EnrollmentAPIKey, GenerateServiceTokenResponse, + GetActionStatusResponse, GetAgentsRequest, GetEnrollmentAPIKeysResponse, GetOutputsResponse, PostAgentUnenrollResponse, + UpdateAgentPolicyRequest, + UpdateAgentPolicyResponse, + PostNewAgentActionResponse, } from '@kbn/fleet-plugin/common/types'; import semver from 'semver'; import axios from 'axios'; import { userInfo } from 'os'; import pRetry from 'p-retry'; +import { getPolicyDataForUpdate } from '../../../common/endpoint/service/policy'; import { fetchActiveSpace } from './spaces'; import { fetchKibanaStatus } from '../../../common/endpoint/utils/kibana_status'; import { isFleetServerRunning } from './fleet_server/fleet_server_services'; @@ -76,6 +84,7 @@ import { } from '../../../common/endpoint/data_loaders/utils'; import { catchAxiosErrorFormatAndThrow } from '../../../common/endpoint/format_axios_error'; import { FleetAgentGenerator } from '../../../common/endpoint/data_generators/fleet_agent_generator'; +import type { PolicyData } from '../../../common/endpoint/types'; const fleetGenerator = new FleetAgentGenerator(); const CURRENT_USERNAME = userInfo().username.toLowerCase(); @@ -101,6 +110,39 @@ export const randomAgentPolicyName = (() => { */ const isValidArtifactVersion = (version: string) => !!version.match(/^\d+\.\d+\.\d+(-SNAPSHOT)?$/); +const getAgentPolicyDataForUpdate = ( + agentPolicy: AgentPolicy +): UpdateAgentPolicyRequest['body'] => { + return pick(agentPolicy, [ + 'advanced_settings', + 'agent_features', + 'data_output_id', + 'description', + 'download_source_id', + 'fleet_server_host_id', + 'global_data_tags', + 'has_fleet_server', + 'id', + 'inactivity_timeout', + 'is_default', + 'is_default_fleet_server', + 'is_managed', + 'is_protected', + 'keep_monitoring_alive', + 'monitoring_diagnostics', + 'monitoring_enabled', + 'monitoring_http', + 'monitoring_output_id', + 'monitoring_pprof_enabled', + 'name', + 'namespace', + 'overrides', + 'space_ids', + 'supports_agentless', + 'unenroll_timeout', + ]) as UpdateAgentPolicyRequest['body']; +}; + export const checkInFleetAgent = async ( esClient: Client, agentId: string, @@ -1369,3 +1411,182 @@ export const enableFleetSpaceAwareness = memoize(async (kbnClient: KbnClient): P }) .catch(catchAxiosErrorFormatAndThrow); }); + +/** + * Fetches a single integratino policy by id + * @param kbnClient + * @param policyId + */ +export const fetchIntegrationPolicy = async ( + kbnClient: KbnClient, + policyId: string +): Promise => { + return kbnClient + .request({ + path: packagePolicyRouteService.getInfoPath(policyId), + method: 'GET', + headers: { 'elastic-api-version': '2023-10-31' }, + }) + .catch(catchAxiosErrorFormatAndThrow) + .then((response) => response.data.item); +}; + +/** + * Update a fleet integration policy (aka: package policy) + * @param kbnClient + */ +export const updateIntegrationPolicy = async ( + kbnClient: KbnClient, + /** The Integration policy id */ + id: string, + policyData: Partial, + /** If set to `true`, then `policyData` can be a partial set of updates and not the full policy data */ + patch: boolean = false +): Promise => { + let fullPolicyData = policyData; + + if (patch) { + const currentSavedPolicy = await fetchIntegrationPolicy(kbnClient, id); + fullPolicyData = getPolicyDataForUpdate(currentSavedPolicy as PolicyData); + Object.assign(fullPolicyData, policyData); + } + + return kbnClient + .request({ + path: packagePolicyRouteService.getUpdatePath(id), + method: 'PUT', + body: fullPolicyData, + headers: { 'elastic-api-version': '2023-10-31' }, + }) + .catch(catchAxiosErrorFormatAndThrow) + .then((response) => response.data.item); +}; + +/** + * Updates a Fleet agent policy + * @param kbnClient + * @param id + * @param policyData + * @param patch + */ +export const updateAgentPolicy = async ( + kbnClient: KbnClient, + /** Fleet Agent Policy ID */ + id: string, + /** The updated agent policy data. Could be a `partial` update if `patch` arguments below is true */ + policyData: Partial, + /** + * If set to `true`, the `policyData` provided on input will first be merged with the latest version + * of the policy and then the updated applied + */ + patch: boolean = false +): Promise => { + let fullPolicyData = policyData; + + if (patch) { + const currentSavedPolicy = await fetchAgentPolicy(kbnClient, id); + + fullPolicyData = getAgentPolicyDataForUpdate(currentSavedPolicy); + delete fullPolicyData.id; + Object.assign(fullPolicyData, policyData); + } + + return kbnClient + .request({ + path: agentPolicyRouteService.getUpdatePath(id), + method: 'PUT', + body: fullPolicyData, + headers: { 'elastic-api-version': '2023-10-31' }, + }) + .catch(catchAxiosErrorFormatAndThrow) + .then((response) => response.data.item); +}; + +/** + * Sets the log level on a Fleet agent and waits a bit of time to allow it for to + * complete (but does not error if it does not complete) + * + * @param kbnClient + * @param agentId + * @param logLevel + * @param log + */ +export const setAgentLoggingLevel = async ( + kbnClient: KbnClient, + agentId: string, + logLevel: 'debug' | 'info' | 'warning' | 'error', + log: ToolingLog = createToolingLogger() +): Promise => { + log.debug(`Setting fleet agent [${agentId}] logging level to [${logLevel}]`); + + const response = await kbnClient + .request({ + method: 'POST', + path: `/api/fleet/agents/${agentId}/actions`, + body: { action: { type: 'SETTINGS', data: { log_level: logLevel } } }, + headers: { 'Elastic-Api-Version': API_VERSIONS.public.v1 }, + }) + .then((res) => res.data); + + // Wait to see if the action completes, but don't `throw` if it does not + await waitForFleetAgentActionToComplete(kbnClient, response.item.id) + .then(() => { + log.debug(`Fleet action to set agent [${agentId}] logging level to [${logLevel}] completed!`); + }) + .catch((err) => { + log.debug(err.message); + }); + + return response; +}; + +/** + * Retrieve fleet agent action statuses + * @param kbnClient + */ +export const fetchFleetAgentActionStatus = async ( + kbnClient: KbnClient +): Promise => { + return kbnClient + .request({ + method: 'GET', + path: agentRouteService.getActionStatusPath(), + query: { perPage: 1000 }, + headers: { 'Elastic-Api-Version': API_VERSIONS.public.v1 }, + }) + .then((response) => response.data); +}; + +/** + * Check and wait until a Fleet Agent action is complete. + * @param kbnClient + * @param actionId + * @param timeout + * + * @throws + */ +export const waitForFleetAgentActionToComplete = async ( + kbnClient: KbnClient, + actionId: string, + timeout: number = 20_000 +): Promise => { + await pRetry( + async (attempts) => { + const { items: actionList } = await fetchFleetAgentActionStatus(kbnClient); + const actionInfo = actionList.find((action) => action.actionId === actionId); + + if (!actionInfo) { + throw new Error( + `Fleet Agent action id [${actionId}] was not found in list of actions retrieved from fleet!` + ); + } + + if (actionInfo.status === 'IN_PROGRESS') { + throw new Error( + `Fleet agent action id [${actionId}] remains in progress after [${attempts}] attempts to check its status` + ); + } + }, + { maxTimeout: 2_000, maxRetryTime: timeout } + ); +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/types.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/types.ts index 38256f1c774b..e3e41c41b77f 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/types.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/types.ts @@ -14,8 +14,12 @@ export interface HostVm { exec: (command: string) => Promise; mount: (localDir: string, hostVmDir: string) => Promise; unmount: (hostVmDir: string) => Promise; - /** Uploads/copies a file from the local machine to the VM */ + /** @deprecated use `upload` */ transfer: (localFilePath: string, destFilePath: string) => Promise; + /** Uploads/copies a file from the local machine to the VM */ + upload: (localFilePath: string, destFilePath: string) => Promise; + /** Downloads a file from the host VM to the local machine */ + download: (vmFilePath: string, localFilePath: string) => Promise; destroy: () => Promise; info: () => string; stop: () => void; @@ -33,8 +37,8 @@ export interface HostVmMountResponse { unmount: () => Promise; } export interface HostVmTransferResponse { - /** The file path of the file on the host vm */ + /** The path of the file that was either uploaded to the host VM or downloaded to the local machine from the VM */ filePath: string; - /** Delete the file from the host VM */ + /** Delete the file from the host VM or from the local machine depending on what client method was used */ delete: () => Promise; } diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/vm_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/vm_services.ts index 084e068768e8..fc1301c9fed9 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/vm_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/vm_services.ts @@ -8,15 +8,19 @@ import type { ToolingLog } from '@kbn/tooling-log'; import execa from 'execa'; import chalk from 'chalk'; +import path from 'path'; import { userInfo } from 'os'; -import { join as pathJoin, dirname } from 'path'; +import { unlink as deleteFile } from 'fs/promises'; +import { dump } from './utils'; import type { DownloadedAgentInfo } from './agent_downloads_service'; import { BaseDataGenerator } from '../../../common/endpoint/data_generators/base_data_generator'; import { createToolingLogger } from '../../../common/endpoint/data_loaders/utils'; import type { HostVm, HostVmExecResponse, SupportedVmManager } from './types'; const baseGenerator = new BaseDataGenerator(); -export const DEFAULT_VAGRANTFILE = pathJoin(__dirname, 'vagrant', 'Vagrantfile'); +export const DEFAULT_VAGRANTFILE = path.join(__dirname, 'vagrant', 'Vagrantfile'); + +const MAX_BUFFER = 1024 * 1024 * 5; // 5MB export interface BaseVmCreateOptions { name: string; @@ -75,9 +79,16 @@ export const createMultipassHostVmClient = ( log: ToolingLog = createToolingLogger() ): HostVm => { const exec = async (command: string): Promise => { - const execResponse = await execa.command(`multipass exec ${name} -- ${command}`); - - log.verbose(execResponse); + const execResponse = await execa + .command(`multipass exec ${name} -- ${command}`, { maxBuffer: MAX_BUFFER }) + .catch((e) => { + log.error(dump(e)); + throw e; + }); + + log.verbose( + `exec response from host [${name}] for command [${command}]:\n${dump(execResponse)}` + ); return { stdout: execResponse.stdout, @@ -125,11 +136,11 @@ export const createMultipassHostVmClient = ( log.verbose(`multipass stop response:\n`, response); }; - const transfer: HostVm['transfer'] = async (localFilePath, destFilePath) => { + const upload: HostVm['upload'] = async (localFilePath, destFilePath) => { const response = await execa.command( `multipass transfer ${localFilePath} ${name}:${destFilePath}` ); - log.verbose(`Transferred file to VM [${name}]:`, response); + log.verbose(`Uploaded file to VM [${name}]:`, response); return { filePath: destFilePath, @@ -139,6 +150,27 @@ export const createMultipassHostVmClient = ( }; }; + const download: HostVm['download'] = async (vmFilePath: string, localFilePath: string) => { + const localFileAbsolutePath = path.resolve(localFilePath); + const response = await execa.command( + `multipass transfer ${name}:${vmFilePath} ${localFilePath}` + ); + log.verbose(`Downloaded file from VM [${name}]:`, response); + + return { + filePath: localFileAbsolutePath, + delete: async () => { + return deleteFile(localFileAbsolutePath).then(() => { + return { + stdout: 'success', + stderr: '', + exitCode: 0, + }; + }); + }, + }; + }; + return { type: 'multipass', name, @@ -147,7 +179,9 @@ export const createMultipassHostVmClient = ( info, mount, unmount, - transfer, + transfer: upload, + upload, + download, start, stop, }; @@ -217,7 +251,7 @@ const createVagrantVm = async ({ }: CreateVagrantVmOptions): Promise => { log.debug(`Using Vagrantfile: ${vagrantFile}`); - const VAGRANT_CWD = dirname(vagrantFile); + const VAGRANT_CWD = path.dirname(vagrantFile); // Destroy the VM running (if any) with the provided vagrant file before re-creating it try { @@ -273,18 +307,24 @@ export const createVagrantHostVmClient = ( vagrantFile: string = DEFAULT_VAGRANTFILE, log: ToolingLog = createToolingLogger() ): HostVm => { - const VAGRANT_CWD = dirname(vagrantFile); + const VAGRANT_CWD = path.dirname(vagrantFile); const execaOptions: execa.Options = { env: { VAGRANT_CWD, }, stdio: ['inherit', 'pipe', 'pipe'], + maxBuffer: MAX_BUFFER, }; log.debug(`Creating Vagrant VM client for [${name}] with vagrantfile [${vagrantFile}]`); const exec = async (command: string): Promise => { - const execResponse = await execa.command(`vagrant ssh -- ${command}`, execaOptions); + const execResponse = await execa + .command(`vagrant ssh -- ${command}`, execaOptions) + .catch((e) => { + log.error(dump(e)); + throw e; + }); log.verbose(execResponse); @@ -328,12 +368,12 @@ export const createVagrantHostVmClient = ( log.verbose('vagrant suspend response:\n', response); }; - const transfer: HostVm['transfer'] = async (localFilePath, destFilePath) => { + const upload: HostVm['upload'] = async (localFilePath, destFilePath) => { const response = await execa.command( `vagrant upload ${localFilePath} ${destFilePath}`, execaOptions ); - log.verbose(`Transferred file to VM [${name}]:`, response); + log.verbose(`Uploaded file to VM [${name}]:`, response); return { filePath: destFilePath, @@ -343,6 +383,34 @@ export const createVagrantHostVmClient = ( }; }; + const download: HostVm['download'] = async (vmFilePath, localFilePath) => { + const localFileAbsolutePath = path.resolve(localFilePath); + + // Vagrant will auto-mount the directory that includes the Vagrant file to the VM under `/vagrant`, + // and it keeps that sync'd to the local system. So we first copy the file in the VM there so we + // can retrieve it from the local machine + await exec(`cp ${vmFilePath} /vagrant`).catch((e) => { + log.error(`Error while attempting to copy file on VM:\n${dump(e)}`); + throw e; + }); + + // Now move the file from the local vagrant directory to the desired location + await execa.command(`mv ${VAGRANT_CWD}/${path.basename(vmFilePath)} ${localFileAbsolutePath}`); + + return { + filePath: localFileAbsolutePath, + delete: async () => { + return deleteFile(localFileAbsolutePath).then(() => { + return { + stdout: 'success', + stderr: '', + exitCode: 0, + }; + }); + }, + }; + }; + return { type: 'vagrant', name, @@ -351,7 +419,9 @@ export const createVagrantHostVmClient = ( info, mount, unmount, - transfer, + transfer: upload, + upload, + download, start, stop, }; diff --git a/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts b/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts index d4b4c8addc09..7cf8f68a1a1b 100644 --- a/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts +++ b/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts @@ -32,7 +32,7 @@ import { createKbnClient } from '../endpoint/common/stack_services'; import type { StartedFleetServer } from '../endpoint/common/fleet_server/fleet_server_services'; import { startFleetServer } from '../endpoint/common/fleet_server/fleet_server_services'; import { renderSummaryTable } from './print_run'; -import { parseTestFileConfig, retrieveIntegrations } from './utils'; +import { parseTestFileConfig, retrieveIntegrations, setDefaultToolingLoggingLevel } from './utils'; import { getFTRConfig } from './get_ftr_config'; export const cli = () => { @@ -68,9 +68,9 @@ ${JSON.stringify(argv, null, 2)} const cypressConfigFilePath = require.resolve(`../../${argv.configFile}`) as string; const cypressConfigFile = await import(cypressConfigFilePath); - if (cypressConfigFile.env?.TOOLING_LOG_LEVEL) { - createToolingLogger.defaultLogLevel = cypressConfigFile.env.TOOLING_LOG_LEVEL; - } + // Adjust tooling log level based on the `TOOLING_LOG_LEVEL` property, which can be + // defined in the cypress config file or set in the `env` + setDefaultToolingLoggingLevel(cypressConfigFile?.env?.TOOLING_LOG_LEVEL); const log = prefixedOutputLogger('cy.parallel()', createToolingLogger()); diff --git a/x-pack/plugins/security_solution/scripts/run_cypress/parallel_serverless.ts b/x-pack/plugins/security_solution/scripts/run_cypress/parallel_serverless.ts index 1ebdd2b2afbd..17dbdcdae75a 100644 --- a/x-pack/plugins/security_solution/scripts/run_cypress/parallel_serverless.ts +++ b/x-pack/plugins/security_solution/scripts/run_cypress/parallel_serverless.ts @@ -28,7 +28,12 @@ import { INITIAL_REST_VERSION } from '@kbn/data-views-plugin/server/constants'; import { catchAxiosErrorFormatAndThrow } from '../../common/endpoint/format_axios_error'; import { createToolingLogger } from '../../common/endpoint/data_loaders/utils'; import { renderSummaryTable } from './print_run'; -import { getOnBeforeHook, parseTestFileConfig, retrieveIntegrations } from './utils'; +import { + getOnBeforeHook, + parseTestFileConfig, + retrieveIntegrations, + setDefaultToolingLoggingLevel, +} from './utils'; import { prefixedOutputLogger } from '../endpoint/common/utils'; import type { ProductType, Credentials, ProjectHandler } from './project_handler/project_handler'; @@ -357,9 +362,8 @@ ${JSON.stringify(argv, null, 2)} cypressConfigFile.env.grepTags = '@serverlessQA --@skipInServerless --@skipInServerlessMKI'; } - if (cypressConfigFile.env?.TOOLING_LOG_LEVEL) { - createToolingLogger.defaultLogLevel = cypressConfigFile.env.TOOLING_LOG_LEVEL; - } + setDefaultToolingLoggingLevel(cypressConfigFile?.env?.TOOLING_LOG_LEVEL); + // eslint-disable-next-line require-atomic-updates log = prefixedOutputLogger('cy.parallel(svl)', createToolingLogger()); diff --git a/x-pack/plugins/security_solution/scripts/run_cypress/utils.ts b/x-pack/plugins/security_solution/scripts/run_cypress/utils.ts index ba1974565e10..6e292eff9a38 100644 --- a/x-pack/plugins/security_solution/scripts/run_cypress/utils.ts +++ b/x-pack/plugins/security_solution/scripts/run_cypress/utils.ts @@ -12,6 +12,8 @@ import generate from '@babel/generator'; import type { ExpressionStatement, ObjectExpression, ObjectProperty } from '@babel/types'; import { schema, type TypeOf } from '@kbn/config-schema'; import chalk from 'chalk'; +import type { ToolingLogTextWriterConfig } from '@kbn/tooling-log'; +import { createToolingLogger } from '../../common/endpoint/data_loaders/utils'; /** * Retrieve test files using a glob pattern. @@ -156,3 +158,22 @@ export const getOnBeforeHook = (module: unknown, beforeSpecFilePath: string): Fu return module.onBeforeHook; }; + +/** + * Sets the default log level for `ToolingLog` instances created by `createToolingLogger()`: + * `x-pack/plugins/security_solution/common/endpoint/data_loaders/utils.ts:148`. + * It will first check the NodeJs `process.env` to see if an Environment Variable was set + * and then, if provided, it will use the value defined in the Cypress Config. file. + */ +export const setDefaultToolingLoggingLevel = (defaultFallbackLoggingLevel?: string) => { + const logLevel = + process.env.TOOLING_LOG_LEVEL || + process.env.CYPRESS_TOOLING_LOG_LEVEL || + defaultFallbackLoggingLevel || + ''; + + if (logLevel) { + createToolingLogger('info').info(`Setting tooling log level to [${logLevel}]`); + createToolingLogger.defaultLogLevel = logLevel as ToolingLogTextWriterConfig['level']; + } +};