[kbn-es] add required flag projectType for serverless script (#175549)

## Summary

in https://github.com/elastic/kibana/pull/174284 we split serverless
roles into individual files per project.

If you run `yarn es serverless --ssl` ES will be provisioned only with
roles specified for elastisearch project type.
To use roles for oblt/security projects, you have to override roles file
with `--resources` flag:
```
yarn es serverless --ssl --resources /Users/dmle/github/kibana/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml
```

Since it is confusing and not dev-friendly approach, this PR adds new
required flag to `serverless` script: `--projectType`

Usage:
`yarn es serverless --projectType=es --ssl`
`yarn es --serverless=oblt --ssl`

roles.yml file will be picked up based on `projectType` value, you still
have an option to override it using `--resources` flag

How to test:
`yarn es serverless --project-type=oblt --ssl`
`yarn start serverless=oblt --ssl`

You should be able to login with all roles defined or Observability (and
other) project.

Cli docs were updated:
```
[main][~/github/kibana]$ node scripts/es --help
usage: es <command> [<args>]

Assists with running Elasticsearch for Kibana development

Available commands:

  snapshot - Downloads and run from a nightly snapshot
  source - Build and run from source
  archive - Install and run from an Elasticsearch tar
  build_snapshots - Build and collect ES snapshots
  docker - Run an Elasticsearch Docker image
  serverless - Run Serverless Elasticsearch through Docker

To start a serverless instance use the 'serverless' command with
  '--projectType' flag or use the '--serverless=<ProjectType>'
  shortcut, for example:

    es --serverless=es

Global options:

  --help
```

---------

Co-authored-by: Robert Oskamp <traeluki@gmail.com>
This commit is contained in:
Dzmitry Lemechko 2024-02-02 17:42:02 +02:00 committed by GitHub
parent f9125ba079
commit e2fc23f57e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 104 additions and 38 deletions

View file

@ -9,7 +9,7 @@
import Path from 'path';
import { defaultsDeep } from 'lodash';
import { Client, HttpConnection } from '@elastic/elasticsearch';
import { Cluster } from '@kbn/es';
import { Cluster, ServerlessProjectType } from '@kbn/es';
import { REPO_ROOT } from '@kbn/repo-info';
import { ToolingLog } from '@kbn/tooling-log';
import { esTestConfig } from '@kbn/test';
@ -28,6 +28,8 @@ export interface TestServerlessUtils {
const ES_BASE_PATH_DIR = Path.join(REPO_ROOT, '.es/es_test_serverless');
const projectType: ServerlessProjectType = 'es';
/**
* See docs in {@link TestUtils}. This function provides the same utilities but
* configured for serverless.
@ -79,6 +81,7 @@ function createServerlessES() {
es,
start: async () => {
await es.runServerless({
projectType,
basePath: ES_BASE_PATH_DIR,
port: esPort,
background: true,

View file

@ -29,6 +29,12 @@ function help() {
${availableCommands.join('\n ')}
To start a serverless instance use the 'serverless' command with
'--projectType' flag or use the '--serverless=<ProjectType>'
shortcut, for example:
es --serverless=es
Global options:
--help
@ -46,7 +52,16 @@ export async function run(defaults = {}) {
default: defaults,
});
const args = options._;
const commandName = args[0];
let commandName = args[0];
// Converting --serverless flag to command
// `es --serverless=<projectType>` is just a shortcut for
// `es serverless --projectType=<projectType>`
if (options.serverless) {
const projectType: string = options.serverless;
commandName = 'serverless';
args.push('--projectType', projectType);
}
if (args.length === 0 || (!commandName && options.help)) {
help();

View file

@ -19,8 +19,13 @@ import {
ES_SERVERLESS_DEFAULT_IMAGE,
DEFAULT_PORT,
ServerlessOptions,
isServerlessProjectType,
serverlessProjectTypes,
} from '../utils';
import { Command } from './types';
import { createCliError } from '../errors';
const supportedProjectTypesStr = Array.from(serverlessProjectTypes).join(' | ').trim();
export const serverless: Command = {
description: 'Run Serverless Elasticsearch through Docker',
@ -29,6 +34,7 @@ export const serverless: Command = {
return dedent`
Options:
--projectType Serverless project type: ${supportedProjectTypesStr}
--tag Image tag of ES serverless to run from ${ES_SERVERLESS_REPO_ELASTICSEARCH}
--image Full path of ES serverless image to run, has precedence over tag. [default: ${ES_SERVERLESS_DEFAULT_IMAGE}]
--background Start ES serverless without attaching to the first node's logs
@ -54,8 +60,8 @@ export const serverless: Command = {
Examples:
es serverless --tag git-fec36430fba2-x86_64 # loads ${ES_SERVERLESS_REPO_ELASTICSEARCH}:git-fec36430fba2-x86_64
es serverless --image docker.elastic.co/kibana-ci/elasticsearch-serverless:latest-verified
es serverless --projectType es --tag git-fec36430fba2-x86_64 # loads ${ES_SERVERLESS_REPO_ELASTICSEARCH}:git-fec36430fba2-x86_64
es serverless --projectType oblt --image docker.elastic.co/kibana-ci/elasticsearch-serverless:latest-verified
`;
},
run: async (defaults = {}) => {
@ -66,20 +72,41 @@ export const serverless: Command = {
});
const reportTime = getTimeReporter(log, 'scripts/es serverless');
// replacing --serverless with --projectType when flag is passed from 'scripts/es'
// `es --serverless=<projectType>` is just a shortcut for
// `es serverless --projectType=<projectType>`
const argv = process.argv.slice(2);
if (argv[0].startsWith('--serverless')) {
const projectTypeArg = argv[0].replace('--serverless', '--projectType');
argv[0] = projectTypeArg;
}
const options = getopts(argv, {
alias: {
basePath: 'base-path',
esArgs: 'E',
files: 'F',
projectType: 'project-type',
},
string: ['tag', 'image', 'basePath', 'resources', 'host', 'kibanaUrl'],
string: ['projectType', 'tag', 'image', 'basePath', 'resources', 'host', 'kibanaUrl'],
boolean: ['clean', 'ssl', 'kill', 'background', 'skipTeardown', 'waitForReady'],
default: defaults,
}) as unknown as ServerlessOptions;
if (!options.projectType) {
throw createCliError(
`--projectType flag is required and must be a string: ${supportedProjectTypesStr}`
);
}
if (!isServerlessProjectType(options.projectType)) {
throw createCliError(
`Invalid projectPype '${options.projectType}', supported values: ${supportedProjectTypesStr}`
);
}
/*
* The nodes will be killed immediately if background = true and skipTeardown = false
* because the CLI process exits after starting the nodes. We handle this here instead of

View file

@ -723,12 +723,16 @@ describe('#kill()', () => {
});
describe('#runServerless()', () => {
const defaultOptions = {
projectType: 'es' as dockerUtils.ServerlessProjectType,
basePath: installPath,
};
test(`rejects if #start() was called before`, async () => {
mockEsBin({ start: true });
const cluster = new Cluster({ log });
await cluster.start(installPath, esClusterExecOptions);
await expect(cluster.runServerless({ basePath: installPath })).rejects.toThrowError(
await expect(cluster.runServerless(defaultOptions)).rejects.toThrowError(
'ES stateful cluster has already been started'
);
});
@ -738,7 +742,7 @@ describe('#runServerless()', () => {
const cluster = new Cluster({ log });
await cluster.run(installPath, esClusterExecOptions);
await expect(cluster.runServerless({ basePath: installPath })).rejects.toThrowError(
await expect(cluster.runServerless(defaultOptions)).rejects.toThrowError(
'ES stateful cluster has already been started'
);
});
@ -756,7 +760,7 @@ describe('#runServerless()', () => {
);
const cluster = new Cluster({ log });
const promise = cluster.runServerless({ basePath: installPath });
const promise = cluster.runServerless(defaultOptions);
await ensureNoResolve(promise);
resolveRunServerlessCluster!();
await expect(ensureResolve(promise, 'runServerless()')).resolves.toEqual(nodeNames);
@ -767,8 +771,8 @@ describe('#runServerless()', () => {
runServerlessClusterMock.mockResolvedValueOnce(nodeNames);
const cluster = new Cluster({ log });
await cluster.runServerless({ basePath: installPath });
await expect(cluster.runServerless({ basePath: installPath })).rejects.toThrowError(
await cluster.runServerless(defaultOptions);
await expect(cluster.runServerless(defaultOptions)).rejects.toThrowError(
'ES serverless docker cluster has already been started'
);
});
@ -776,7 +780,7 @@ describe('#runServerless()', () => {
test('rejects if #runServerlessCluster() rejects', async () => {
runServerlessClusterMock.mockRejectedValueOnce(new Error('foo'));
const cluster = new Cluster({ log });
await expect(cluster.runServerless({ basePath: installPath })).rejects.toThrowError('foo');
await expect(cluster.runServerless(defaultOptions)).rejects.toThrowError('foo');
});
test('passes through all options+log to #runServerlessCluster()', async () => {
@ -785,6 +789,7 @@ describe('#runServerless()', () => {
const cluster = new Cluster({ log });
const serverlessOptions = {
projectType: 'es' as dockerUtils.ServerlessProjectType,
clean: true,
basePath: installPath,
teardown: true,

View file

@ -29,6 +29,7 @@ import {
verifyDockerInstalled,
getESp12Volume,
ServerlessOptions,
ServerlessProjectType,
} from './docker';
import { ToolingLog, ToolingLogCollectingWriter } from '@kbn/tooling-log';
import { CA_CERT_PATH, ES_P12_PATH } from '@kbn/dev-utils';
@ -66,6 +67,7 @@ const logWriter = new ToolingLogCollectingWriter();
log.setWriters([logWriter]);
const KIBANA_ROOT = process.cwd();
const projectType: ServerlessProjectType = 'es';
const baseEsPath = `${KIBANA_ROOT}/.es`;
const serverlessDir = 'stateless';
const serverlessObjectStorePath = `${baseEsPath}/${serverlessDir}`;
@ -504,7 +506,10 @@ describe('setupServerlessVolumes()', () => {
[baseEsPath]: {},
});
const volumeCmd = await setupServerlessVolumes(log, { basePath: baseEsPath });
const volumeCmd = await setupServerlessVolumes(log, {
projectType,
basePath: baseEsPath,
});
volumeCmdTest(volumeCmd);
await expect(Fsp.access(serverlessObjectStorePath)).resolves.not.toThrow();
@ -513,7 +518,7 @@ describe('setupServerlessVolumes()', () => {
test('should use an existing object store', async () => {
mockFs(existingObjectStore);
const volumeCmd = await setupServerlessVolumes(log, { basePath: baseEsPath });
const volumeCmd = await setupServerlessVolumes(log, { projectType, basePath: baseEsPath });
volumeCmdTest(volumeCmd);
await expect(
@ -524,7 +529,11 @@ describe('setupServerlessVolumes()', () => {
test('should remove an existing object store when clean is passed', async () => {
mockFs(existingObjectStore);
const volumeCmd = await setupServerlessVolumes(log, { basePath: baseEsPath, clean: true });
const volumeCmd = await setupServerlessVolumes(log, {
projectType,
basePath: baseEsPath,
clean: true,
});
volumeCmdTest(volumeCmd);
await expect(
@ -537,6 +546,7 @@ describe('setupServerlessVolumes()', () => {
createMockIdpMetadataMock.mockResolvedValue('<xml/>');
const volumeCmd = await setupServerlessVolumes(log, {
projectType,
basePath: baseEsPath,
ssl: true,
kibanaUrl: 'https://localhost:5603/',
@ -561,6 +571,7 @@ describe('setupServerlessVolumes()', () => {
test('should use resource overrides', async () => {
mockFs(existingObjectStore);
const volumeCmd = await setupServerlessVolumes(log, {
projectType,
basePath: baseEsPath,
resources: ['./relative/path/users', '/absolute/path/users_roles'],
});
@ -578,6 +589,7 @@ describe('setupServerlessVolumes()', () => {
await expect(async () => {
await setupServerlessVolumes(log, {
projectType,
basePath: baseEsPath,
resources: ['/absolute/path/invalid'],
});
@ -626,7 +638,7 @@ describe('runServerlessCluster()', () => {
});
execa.mockImplementation(() => Promise.resolve({ stdout: '' }));
await runServerlessCluster(log, { basePath: baseEsPath });
await runServerlessCluster(log, { projectType, basePath: baseEsPath });
// setupDocker execa calls then run three nodes and attach logger
expect(execa.mock.calls).toHaveLength(8);
@ -639,7 +651,7 @@ describe('runServerlessCluster()', () => {
});
execa.mockImplementation(() => Promise.resolve({ stdout: '' }));
await runServerlessCluster(log, { basePath: baseEsPath, waitForReady: true });
await runServerlessCluster(log, { projectType, basePath: baseEsPath, waitForReady: true });
expect(waitUntilClusterReadyMock).toHaveBeenCalledTimes(1);
expect(waitUntilClusterReadyMock.mock.calls[0][0].expectedStatus).toEqual('green');
expect(waitUntilClusterReadyMock.mock.calls[0][0].readyTimeout).toEqual(undefined);
@ -655,6 +667,7 @@ describe('runServerlessCluster()', () => {
createMockIdpMetadataMock.mockResolvedValue('<xml/>');
await runServerlessCluster(log, {
projectType,
basePath: baseEsPath,
waitForReady: true,
ssl: true,
@ -672,7 +685,7 @@ describe('runServerlessCluster()', () => {
});
execa.mockImplementation(() => Promise.resolve({ stdout: '' }));
await runServerlessCluster(log, { basePath: baseEsPath, waitForReady: true });
await runServerlessCluster(log, { projectType, basePath: baseEsPath, waitForReady: true });
expect(waitForSecurityIndexMock).toHaveBeenCalledTimes(1);
expect(waitForSecurityIndexMock.mock.calls[0][0].readyTimeout).toEqual(undefined);
});
@ -685,6 +698,7 @@ describe('runServerlessCluster()', () => {
execa.mockImplementation(() => Promise.resolve({ stdout: '' }));
await runServerlessCluster(log, {
projectType,
basePath: baseEsPath,
waitForReady: true,
esArgs: ['xpack.security.enabled=false'],
@ -708,7 +722,7 @@ describe('stopServerlessCluster()', () => {
});
describe('teardownServerlessClusterSync()', () => {
const defaultOptions = { basePath: 'foo/bar' };
const defaultOptions = { projectType, basePath: 'foo/bar' };
test('should kill running serverless nodes', () => {
const nodes = ['es01', 'es02', 'es03'];

View file

@ -59,8 +59,8 @@ interface BaseOptions extends ImageOptions {
files?: string | string[];
}
const serverlessProjectTypes = new Set<string>(['es', 'oblt', 'security']);
const isServerlessProjectType = (value: string): value is ServerlessProjectType => {
export const serverlessProjectTypes = new Set<string>(['es', 'oblt', 'security']);
export const isServerlessProjectType = (value: string): value is ServerlessProjectType => {
return serverlessProjectTypes.has(value);
};
@ -74,7 +74,7 @@ export interface ServerlessOptions extends EsClusterExecOptions, BaseOptions {
/** Publish ES docker container on additional host IP */
host?: string;
/** Serverless project type */
projectType?: ServerlessProjectType;
projectType: ServerlessProjectType;
/** Clean (or delete) all data created by the ES cluster after it is stopped */
clean?: boolean;
/** Path to the directory where the ES cluster will store data */
@ -599,16 +599,8 @@ export async function setupServerlessVolumes(log: ToolingLog, options: Serverles
}, {} as Record<string, string>)
: {};
// Check if projectType is valid
if (projectType && !isServerlessProjectType(projectType)) {
throw new Error(
`Incorrect serverless project type: ${projectType}, use one of ${Array.from(
serverlessProjectTypes
).join(', ')}`
);
}
// Read roles for the specified projectType, 'es' if it is not defined
const rolesResourcePath = resolve(SERVERLESS_ROLES_ROOT_PATH, projectType ?? 'es', 'roles.yml');
// Read roles for the specified projectType
const rolesResourcePath = resolve(SERVERLESS_ROLES_ROOT_PATH, projectType, 'roles.yml');
const resourcesPaths = [...SERVERLESS_RESOURCES_PATHS, rolesResourcePath];

View file

@ -242,6 +242,11 @@ export function createTestEsCluster<
} else if (esFrom === 'snapshot') {
installPath = (await firstNode.installSnapshot(config)).installPath;
} else if (esFrom === 'serverless') {
if (!esServerlessOptions) {
throw new Error(
`'esServerlessOptions' must be defined to start Elasticsearch in serverless mode`
);
}
await firstNode.runServerless({
basePath,
esArgs: customEsArgs,

View file

@ -12,6 +12,7 @@ import type { ToolingLog } from '@kbn/tooling-log';
import getPort from 'get-port';
import { REPO_ROOT } from '@kbn/repo-info';
import type { ArtifactLicense, ServerlessProjectType } from '@kbn/es';
import { isServerlessProjectType } from '@kbn/es/src/utils';
import type { Config } from '../../functional_test_runner';
import { createTestEsCluster, esTestConfig } from '../../es';
@ -53,7 +54,9 @@ function getEsConfig({
const serverless: boolean = config.get('serverless');
const files: string[] | undefined = config.get('esTestCluster.files');
const esServerlessOptions = getESServerlessOptions(esServerlessImage, config);
const esServerlessOptions = serverless
? getESServerlessOptions(esServerlessImage, config)
: undefined;
return {
ssl,
@ -162,6 +165,7 @@ async function startEsNode({
}
interface EsServerlessOptions {
projectType: ServerlessProjectType;
host?: string;
resources: string[];
kibanaUrl: string;
@ -189,19 +193,20 @@ function getESServerlessOptions(
(config.get('kbnTestServer.serverArgs') as string[])) ||
[];
const projectTypeFromArgs = kbnServerArgs
const projectType = kbnServerArgs
.filter((arg) => arg.startsWith('--serverless'))
.reduce((acc, arg) => {
const match = arg.match(/--serverless[=\s](\w+)/);
return acc + (match ? match[1] : '');
}, '');
const projectType = projectTypeFromArgs.length
? (projectTypeFromArgs as ServerlessProjectType)
: undefined;
}, '') as ServerlessProjectType;
if (!isServerlessProjectType(projectType)) {
throw new Error(`Unsupported serverless projectType: ${projectType}`);
}
const commonOptions = {
host: serverlessHost,
projectType,
host: serverlessHost,
resources: serverlessResources,
kibanaUrl: Url.format({
protocol: config.get('servers.kibana.protocol'),