kibana/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts
Brad White 06ebc3120c
ESS support for FTR serverless tests. SSL support in kbn/es. kbn/es DX improvements. (#162673)
Closes #162593
Closes #163939 
Closes #162625

The original intention of this PR was to add FTR support for ESS.
However the scope increased as that also required adding SSL support due
to tests failing from disabled `security` and no authentication.
Additionally, after using serverless in `kbn/es` extensively for this,
there was a bit of friction in regards to DX.

## Summary
- Switch `x-pack/test_serverless` FTR to use ES serverless instead of
(stateful) snapshot
- Adds SSL support to Docker and Serverless in `kbn/es`
- Adds `port` option override
- Adds `teardown` option to kill running nodes if the process exits
without shutdown
- Adds `kill` option to kill running nodes on startup if detected
- Adds `--esFrom serverless` to FTR CLI
- Adds `files` option to mount extra files into containers
- For serverless, automatically attach to first node with `docker logs
-f es01` on startup for better DX.
- Added `background` flag to not attach `logs`.
- Adds graceful shutdown for ESS cluster
- Separate `docker pull` from `run` for better logging, ensures latest
image and stops multiple pulls of the same image occurring in parallel
- Align (most) default settings for ES serverless with `gradlew`
[settings](https://github.com/elastic/elasticsearch-serverless/blob/main/serverless-build-tools/src/main/kotlin/elasticsearch.serverless-run.gradle.kts#L8)
- Fixes Docker bind mount permissions in CI
- Fixes issue where `esFrom` would default to `snapshot` and override
FTR config settings.

### Checklist

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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

## Related Issues for Skipped Tests
Security Threat Hunting: #165135
Observability: #165138
Response Ops: #165145

---------

Co-authored-by: Dzmitry Lemechko <dzmitry.lemechko@elastic.co>
Co-authored-by: Tiago Costa <tiago.costa@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>
2023-08-30 13:28:29 -07:00

400 lines
13 KiB
TypeScript

/*
* 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 yargs from 'yargs';
import _ from 'lodash';
import globby from 'globby';
import pMap from 'p-map';
import { ToolingLog } from '@kbn/tooling-log';
import { withProcRunner } from '@kbn/dev-proc-runner';
import cypress from 'cypress';
import { findChangedFiles } from 'find-cypress-specs';
import path from 'path';
import grep from '@cypress/grep/src/plugin';
import { EsVersion, FunctionalTestRunner, runElasticsearch, runKibanaServer } from '@kbn/test';
import {
Lifecycle,
ProviderCollection,
readProviderSpec,
} from '@kbn/test/src/functional_test_runner/lib';
import { createFailError } from '@kbn/dev-cli-errors';
import pRetry from 'p-retry';
import { renderSummaryTable } from './print_run';
import { isSkipped, parseTestFileConfig } from './utils';
import { getFTRConfig } from './get_ftr_config';
/**
* Retrieve test files using a glob pattern.
* If process.env.RUN_ALL_TESTS is true, returns all matching files, otherwise, return files that should be run by this job based on process.env.BUILDKITE_PARALLEL_JOB_COUNT and process.env.BUILDKITE_PARALLEL_JOB
*/
const retrieveIntegrations = (integrationsPaths: string[]) => {
const nonSkippedSpecs = integrationsPaths.filter((filePath) => !isSkipped(filePath));
if (process.env.RUN_ALL_TESTS === 'true') {
return nonSkippedSpecs;
} else {
// The number of instances of this job were created
const chunksTotal: number = process.env.BUILDKITE_PARALLEL_JOB_COUNT
? parseInt(process.env.BUILDKITE_PARALLEL_JOB_COUNT, 10)
: 1;
// An index which uniquely identifies this instance of the job
const chunkIndex: number = process.env.BUILDKITE_PARALLEL_JOB
? parseInt(process.env.BUILDKITE_PARALLEL_JOB, 10)
: 0;
const nonSkippedSpecsForChunk: string[] = [];
for (let i = chunkIndex; i < nonSkippedSpecs.length; i += chunksTotal) {
nonSkippedSpecsForChunk.push(nonSkippedSpecs[i]);
}
return nonSkippedSpecsForChunk;
}
};
export const cli = () => {
run(
async () => {
const { argv } = yargs(process.argv.slice(2))
.coerce('spec', (arg) => (_.isArray(arg) ? [_.last(arg)] : [arg]))
.coerce('env', (arg: string) =>
arg.split(',').reduce((acc, curr) => {
const [key, value] = curr.split('=');
if (key === 'burn') {
acc[key] = parseInt(value, 10);
} else {
acc[key] = value;
}
return acc;
}, {} as Record<string, string | number>)
);
const isOpen = argv._[0] === 'open';
const cypressConfigFilePath = require.resolve(
`../../${_.isArray(argv.configFile) ? _.last(argv.configFile) : argv.configFile}`
) as string;
const cypressConfigFile = await import(cypressConfigFilePath);
const grepSpecPattern = grep({
...cypressConfigFile,
specPattern: argv.spec ?? cypressConfigFile.e2e.specPattern,
excludeSpecPattern: [],
}).specPattern;
let files = retrieveIntegrations(
_.isArray(grepSpecPattern)
? grepSpecPattern
: globby.sync(argv.spec ?? cypressConfigFile.e2e.specPattern)
);
if (argv.changedSpecsOnly) {
files = (findChangedFiles('main', false) as string[]).reduce((acc, itemPath) => {
const existing = files.find((grepFilePath) => grepFilePath.includes(itemPath));
if (existing) {
acc.push(existing);
}
return acc;
}, [] as string[]);
// to avoid running too many tests, we limit the number of files to 3
// we may extend this in the future
files = files.slice(0, 3);
}
const log = new ToolingLog({
level: 'info',
writeTo: process.stdout,
});
if (!files?.length) {
log.info('No tests found');
// eslint-disable-next-line no-process-exit
return process.exit(0);
}
const esPorts: number[] = [9200, 9220];
const kibanaPorts: number[] = [5601, 5620];
const fleetServerPorts: number[] = [8220];
const getEsPort = <T>(): T | number => {
const esPort = parseInt(`92${Math.floor(Math.random() * 89) + 10}`, 10);
if (esPorts.includes(esPort)) {
return getEsPort();
}
esPorts.push(esPort);
return esPort;
};
const getKibanaPort = <T>(): T | number => {
if (isOpen) {
return 5620;
}
const kibanaPort = parseInt(`56${Math.floor(Math.random() * 89) + 10}`, 10);
if (kibanaPorts.includes(kibanaPort)) {
return getKibanaPort();
}
kibanaPorts.push(kibanaPort);
return kibanaPort;
};
const getFleetServerPort = <T>(): T | number => {
if (isOpen) {
return 8220;
}
const fleetServerPort = parseInt(`82${Math.floor(Math.random() * 89) + 10}`, 10);
if (fleetServerPorts.includes(fleetServerPort)) {
return getFleetServerPort();
}
fleetServerPorts.push(fleetServerPort);
return fleetServerPort;
};
const cleanupServerPorts = ({
esPort,
kibanaPort,
fleetServerPort,
}: {
esPort: number;
kibanaPort: number;
fleetServerPort: number;
}) => {
_.pull(esPorts, esPort);
_.pull(kibanaPorts, kibanaPort);
_.pull(fleetServerPorts, fleetServerPort);
};
await pMap(
files,
async (filePath) => {
let result:
| CypressCommandLine.CypressRunResult
| CypressCommandLine.CypressFailedRunResult
| undefined;
await withProcRunner(log, async (procs) => {
const abortCtrl = new AbortController();
const onEarlyExit = (msg: string) => {
log.error(msg);
abortCtrl.abort();
};
const esPort: number = getEsPort();
const kibanaPort: number = getKibanaPort();
const fleetServerPort: number = getFleetServerPort();
const specFileFTRConfig = parseTestFileConfig(filePath);
const ftrConfigFilePath = path.resolve(
_.isArray(argv.ftrConfigFile) ? _.last(argv.ftrConfigFile) : argv.ftrConfigFile
);
const config = await getFTRConfig({
log,
esPort,
kibanaPort,
fleetServerPort,
ftrConfigFilePath,
specFilePath: filePath,
specFileFTRConfig,
isOpen,
});
log.info(`
----------------------------------------------
Cypress FTR setup for file: ${filePath}:
----------------------------------------------
${JSON.stringify(config.getAll(), null, 2)}
----------------------------------------------
`);
const lifecycle = new Lifecycle(log);
const providers = new ProviderCollection(log, [
...readProviderSpec('Service', {
lifecycle: () => lifecycle,
log: () => log,
config: () => config,
}),
...readProviderSpec('Service', config.get('services')),
]);
const options = {
installDir: process.env.KIBANA_INSTALL_DIR,
ci: process.env.CI,
};
const shutdownEs = await pRetry(
async () =>
runElasticsearch({
config,
log,
name: `ftr-${esPort}`,
esFrom: config.get('esTestCluster')?.from || 'snapshot',
onEarlyExit,
}),
{ retries: 2, forever: false }
);
await runKibanaServer({
procs,
config,
installDir: options?.installDir,
extraKbnOpts:
options?.installDir || options?.ci || !isOpen
? []
: ['--dev', '--no-dev-config', '--no-dev-credentials'],
onEarlyExit,
});
await providers.loadAll();
const functionalTestRunner = new FunctionalTestRunner(
log,
config,
EsVersion.getDefault()
);
const createUrlFromFtrConfig = (
type: 'elasticsearch' | 'kibana' | 'fleetserver',
withAuth: boolean = false
): string => {
const getKeyPath = (keyPath: string = ''): string => {
return `servers.${type}${keyPath ? `.${keyPath}` : ''}`;
};
if (!config.get(getKeyPath())) {
throw new Error(`Unable to create URL for ${type}. Not found in FTR config at `);
}
const url = new URL('http://localhost');
url.port = config.get(getKeyPath('port'));
url.protocol = config.get(getKeyPath('protocol'));
url.hostname = config.get(getKeyPath('hostname'));
if (withAuth) {
url.username = config.get(getKeyPath('username'));
url.password = config.get(getKeyPath('password'));
}
return url.toString().replace(/\/$/, '');
};
const baseUrl = createUrlFromFtrConfig('kibana');
const ftrEnv = await pRetry(() => functionalTestRunner.run(abortCtrl.signal), {
retries: 1,
});
log.debug(
`Env. variables returned by [functionalTestRunner.run()]:\n`,
JSON.stringify(ftrEnv, null, 2)
);
// Normalized the set of available env vars in cypress
const cyCustomEnv = {
...ftrEnv,
// NOTE:
// ELASTICSEARCH_URL needs to be created here with auth because SIEM cypress setup depends on it. At some
// points we should probably try to refactor that code to use `ELASTICSEARCH_URL_WITH_AUTH` instead
ELASTICSEARCH_URL:
ftrEnv.ELASTICSEARCH_URL ?? createUrlFromFtrConfig('elasticsearch', true),
ELASTICSEARCH_URL_WITH_AUTH: createUrlFromFtrConfig('elasticsearch', true),
ELASTICSEARCH_USERNAME:
ftrEnv.ELASTICSEARCH_USERNAME ?? config.get('servers.elasticsearch.username'),
ELASTICSEARCH_PASSWORD:
ftrEnv.ELASTICSEARCH_PASSWORD ?? config.get('servers.elasticsearch.password'),
FLEET_SERVER_URL: createUrlFromFtrConfig('fleetserver'),
KIBANA_URL: baseUrl,
KIBANA_URL_WITH_AUTH: createUrlFromFtrConfig('kibana', true),
KIBANA_USERNAME: config.get('servers.kibana.username'),
KIBANA_PASSWORD: config.get('servers.kibana.password'),
IS_SERVERLESS: config.get('serverless'),
...argv.env,
};
log.info(`
----------------------------------------------
Cypress run ENV for file: ${filePath}:
----------------------------------------------
${JSON.stringify(cyCustomEnv, null, 2)}
----------------------------------------------
`);
if (isOpen) {
await cypress.open({
configFile: cypressConfigFilePath,
config: {
e2e: {
baseUrl,
},
env: cyCustomEnv,
},
});
} else {
try {
result = await cypress.run({
browser: 'chrome',
spec: filePath,
configFile: cypressConfigFilePath,
reporter: argv.reporter as string,
reporterOptions: argv.reporterOptions,
headed: argv.headed as boolean,
config: {
e2e: {
baseUrl,
},
numTestsKeptInMemory: 0,
env: cyCustomEnv,
},
});
} catch (error) {
result = error;
}
}
await procs.stop('kibana');
await shutdownEs();
cleanupServerPorts({ esPort, kibanaPort, fleetServerPort });
return result;
});
return result;
},
{
concurrency: 1,
}
).then((results) => {
renderSummaryTable(results as CypressCommandLine.CypressRunResult[]);
const hasFailedTests = _.some(
results,
(result) => result?.status === 'finished' && result.totalFailed > 0
);
if (hasFailedTests) {
throw createFailError('Not all tests passed');
}
});
},
{
flags: {
allowUnexpected: true,
},
}
);
};