Adds base implementation of the Kibana Health Gateway. (#141172)

This commit is contained in:
Luke Elmers 2022-11-07 09:14:42 -07:00 committed by GitHub
parent 20b8741263
commit 4b863abc18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1812 additions and 1 deletions

1
.github/CODEOWNERS vendored
View file

@ -931,6 +931,7 @@ packages/kbn-get-repo-files @elastic/kibana-operations
packages/kbn-guided-onboarding @elastic/platform-onboarding
packages/kbn-handlebars @elastic/kibana-security
packages/kbn-hapi-mocks @elastic/kibana-core
packages/kbn-health-gateway-server @elastic/kibana-core
packages/kbn-i18n @elastic/kibana-core
packages/kbn-i18n-react @elastic/kibana-core
packages/kbn-import-resolver @elastic/kibana-operations

View file

@ -335,6 +335,7 @@
"@kbn/guided-onboarding": "link:bazel-bin/packages/kbn-guided-onboarding",
"@kbn/handlebars": "link:bazel-bin/packages/kbn-handlebars",
"@kbn/hapi-mocks": "link:bazel-bin/packages/kbn-hapi-mocks",
"@kbn/health-gateway-server": "link:bazel-bin/packages/kbn-health-gateway-server",
"@kbn/home-sample-data-card": "link:bazel-bin/packages/home/sample_data_card",
"@kbn/home-sample-data-tab": "link:bazel-bin/packages/home/sample_data_tab",
"@kbn/home-sample-data-types": "link:bazel-bin/packages/home/sample_data_types",

View file

@ -244,6 +244,7 @@ filegroup(
"//packages/kbn-guided-onboarding:build",
"//packages/kbn-handlebars:build",
"//packages/kbn-hapi-mocks:build",
"//packages/kbn-health-gateway-server:build",
"//packages/kbn-i18n:build",
"//packages/kbn-i18n-react:build",
"//packages/kbn-import-resolver:build",
@ -593,6 +594,7 @@ filegroup(
"//packages/kbn-guided-onboarding:build_types",
"//packages/kbn-handlebars:build_types",
"//packages/kbn-hapi-mocks:build_types",
"//packages/kbn-health-gateway-server:build_types",
"//packages/kbn-i18n:build_types",
"//packages/kbn-i18n-react:build_types",
"//packages/kbn-import-resolver:build_types",

View file

@ -0,0 +1 @@
scripts/.env

View file

@ -0,0 +1,127 @@
load("@npm//@bazel/typescript:index.bzl", "ts_config")
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project")
PKG_DIRNAME = "kbn-health-gateway-server"
PKG_REQUIRE_NAME = "@kbn/health-gateway-server"
SOURCE_FILES = glob(
[
"**/*.ts",
],
exclude = [
"**/*.config.js",
"**/*.mock.*",
"**/*.test.*",
"**/*.stories.*",
"**/__snapshots__",
"**/integration_tests",
"**/mocks",
"**/scripts",
"**/storybook",
"**/test_fixtures",
"**/test_helpers",
],
)
SRCS = SOURCE_FILES
filegroup(
name = "srcs",
srcs = SRCS,
)
NPM_MODULE_EXTRA_FILES = [
"package.json",
]
RUNTIME_DEPS = [
"@npm//@hapi/hapi",
"@npm//node-fetch",
"//packages/kbn-config",
"//packages/kbn-config-mocks",
"//packages/kbn-config-schema",
"//packages/kbn-logging-mocks",
"//packages/kbn-server-http-tools",
"//packages/kbn-utils",
"//packages/core/logging/core-logging-server-internal",
]
TYPES_DEPS = [
"@npm//@types/node",
"@npm//@types/jest",
"@npm//@types/hapi__hapi",
"@npm//@types/node-fetch",
"@npm//moment",
"//packages/core/base/core-base-server-internal:npm_module_types",
"//packages/core/logging/core-logging-server-internal:npm_module_types",
"//packages/kbn-config:npm_module_types",
"//packages/kbn-config-mocks:npm_module_types",
"//packages/kbn-config-schema:npm_module_types",
"//packages/kbn-logging:npm_module_types",
"//packages/kbn-logging-mocks:npm_module_types",
"//packages/kbn-server-http-tools:npm_module_types",
"//packages/kbn-utils:npm_module_types",
"//packages/kbn-utility-types:npm_module_types",
"//packages/kbn-utility-types-jest:npm_module_types",
]
jsts_transpiler(
name = "target_node",
srcs = SRCS,
build_pkg_name = package_name(),
)
ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = [
"//:tsconfig.base.json",
"//:tsconfig.bazel.json",
],
)
ts_project(
name = "tsc_types",
args = ['--pretty'],
srcs = SRCS,
deps = TYPES_DEPS,
declaration = True,
emit_declaration_only = True,
out_dir = "target_types",
root_dir = ".",
tsconfig = ":tsconfig",
)
js_library(
name = PKG_DIRNAME,
srcs = NPM_MODULE_EXTRA_FILES,
deps = RUNTIME_DEPS + [":target_node"],
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
js_library(
name = "npm_module_types",
srcs = NPM_MODULE_EXTRA_FILES,
deps = RUNTIME_DEPS + [":target_node", ":tsc_types"],
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
pkg_npm(
name = "npm_module",
deps = [":" + PKG_DIRNAME],
)
filegroup(
name = "build",
srcs = [":npm_module"],
visibility = ["//visibility:public"],
)
pkg_npm(
name = "build_types",
deps = [":npm_module_types"],
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,77 @@
# @kbn/health-gateway-server
This package runs a small server called the Health Gateway, which exists to query
the status APIs of multiple Kibana instances and return an aggregated result.
This is used by the Elastic Cloud infrastructure to run two different Kibana processes
with different `node.roles`: one process for handling UI requests, and one for background
tasks.
## Configuration
Similar to Kibana, the gateway has a yml configuration file that it reads from. By default
this lives alongside the `kibana.yml` at `<REPO_ROOT>/config/gateway.yml`. Like Kibana,
you can provide a `-c` or `--config` CLI argument to override the location of the config
file.
For example:
```bash
$ yarn start --config /path/to/some/other/config.yml
```
Here is a sample configuration file recommended for use in development:
```yaml
# config/gateway.yml
server:
port: 3000
host: 'localhost'
ssl:
enabled: true
# Using Kibana test certs
key: /path/to/packages/kbn-dev-utils/certs/kibana.key
certificate: /path/to/packages/kbn-dev-utils/certs/kibana.crt
certificateAuthorities: /path/to/packages/kbn-dev-utils/certs/ca.crt
kibana:
hosts:
- 'https://localhost:5605'
- 'https://localhost:5606'
ssl:
# Using Kibana test certs
certificate: /path/to/packages/kbn-dev-utils/certs/kibana.crt
certificateAuthorities: /path/to/packages/kbn-dev-utils/certs/ca.crt
verificationMode: certificate
logging:
root:
appenders: ['console']
level: 'all'
```
Note that the gateway supports the same logging configuration as Kibana, including
all of the same appenders.
## Development & Testing
To run this locally, first you need to create a `config/gateway.yml` file. There's a
`docker-compose.yml` intended for development, which will run Elasticsearch and
two different Kibana instances for testing. Before using it, you'll want to create
a `.env` file:
```bash
# From the /packages/kbn-health-gateway-server/scripts directory
$ cp .env.example .env
# (modify the .env settings if desired)
$ docker-compose up
```
This will automatically run Kibana on the ports from the sample `gateway.yml`
above (5605-5606).
Once you have your `gateway.yml` and have started docker-compose, you can run the
server from the `/packages/kbn-health-gateway-server` directory with `yarn start`. Then you should
be able to make requests to the `/api/status` endpoint:
```bash
$ curl "https://localhost:3000/api/status"
```

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { bootstrap } from './src';

View file

@ -0,0 +1,13 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-health-gateway-server'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-server",
"id": "@kbn/health-gateway-server",
"owner": "@elastic/kibana-core",
"runtimeDeps": [],
"typeDeps": []
}

View file

@ -0,0 +1,12 @@
{
"name": "@kbn/health-gateway-server",
"private": true,
"version": "1.0.0",
"main": "./target_node/index.js",
"author": "Kibana Core",
"license": "SSPL-1.0 OR Elastic License 2.0",
"scripts": {
"start": "node ../../bazel-bin/packages/kbn-health-gateway-server/target_node/scripts/init.js"
},
"types": "./target_types/index.d.ts"
}

View file

@ -0,0 +1,22 @@
# Password for the 'elastic' user (at least 6 characters)
ELASTIC_PASSWORD=changeme
# Password for the 'kibana_system' user (at least 6 characters)
KIBANA_PASSWORD=changeme
# Version of Elastic products
STACK_VERSION=8.4.0
# Set to 'basic' or 'trial' to automatically start the 30-day trial
LICENSE=basic
# Port to expose Elasticsearch HTTP API to the host
ES_PORT=9205
# Ports to expose Kibana to the host
KIBANA_01_PORT=5605
KIBANA_02_PORT=5606
# Increase or decrease based on the available host memory (in bytes)
MEM_LIMIT=2147483648

View file

@ -0,0 +1,89 @@
version: "3"
services:
setup:
depends_on:
elasticsearch:
condition: service_healthy
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
# Adapted from https://github.com/elastic/elasticsearch/blob/main/docs/reference/setup/install/docker/docker-compose.yml
command: >
bash -c '
echo "Setting kibana_system password";
until curl -s -X POST -u "elastic:${ELASTIC_PASSWORD}" -H "Content-Type: application/json" http://elasticsearch:9200/_security/user/kibana_system/_password -d "{\"password\":\"${KIBANA_PASSWORD}\"}" | grep -q "^{}"; do sleep 10; done;
echo "All done!";
'
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
volumes:
- ../../kbn-dev-utils/certs:/usr/share/elasticsearch/config/certs
ports:
- ${ES_PORT}:9200
environment:
node.name: elasticsearch
cluster.name: health-gateway-test-cluster
discovery.type: single-node
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD}
bootstrap.memory_lock: true
xpack.security.enabled: true
xpack.license.self_generated.type: ${LICENSE}
mem_limit: ${MEM_LIMIT}
ulimits:
memlock:
soft: -1
hard: -1
healthcheck:
test:
[
"CMD-SHELL",
"curl -s http://localhost:9200 | grep -q 'missing authentication credentials'",
]
interval: 10s
timeout: 10s
retries: 120
kbn01:
depends_on:
elasticsearch:
condition: service_healthy
image: docker.elastic.co/kibana/kibana:${STACK_VERSION}
volumes:
- ../../kbn-dev-utils/certs:/usr/share/kibana/config/certs
ports:
- ${KIBANA_01_PORT}:5601
environment:
SERVERNAME: kbn01
NODE_ROLES: '["ui"]'
STATUS_ALLOWANONYMOUS: true
SERVER_SSL_ENABLED: true
SERVER_SSL_KEY: config/certs/kibana.key
SERVER_SSL_CERTIFICATE: config/certs/kibana.crt
SERVER_SSL_CERTIFICATEAUTHORITIES: config/certs/ca.crt
ELASTICSEARCH_HOSTS: http://elasticsearch:9200
ELASTICSEARCH_USERNAME: kibana_system
ELASTICSEARCH_PASSWORD: ${KIBANA_PASSWORD}
mem_limit: ${MEM_LIMIT}
kbn02:
depends_on:
elasticsearch:
condition: service_healthy
image: docker.elastic.co/kibana/kibana:${STACK_VERSION}
volumes:
- ../../kbn-dev-utils/certs:/usr/share/kibana/config/certs
ports:
- ${KIBANA_02_PORT}:5601
environment:
SERVERNAME: kbn02
NODE_ROLES: '["background_tasks"]'
STATUS_ALLOWANONYMOUS: true
SERVER_SSL_ENABLED: true
SERVER_SSL_KEY: config/certs/kibana.key
SERVER_SSL_CERTIFICATE: config/certs/kibana.crt
SERVER_SSL_CERTIFICATEAUTHORITIES: config/certs/ca.crt
ELASTICSEARCH_HOSTS: http://elasticsearch:9200
ELASTICSEARCH_USERNAME: kibana_system
ELASTICSEARCH_PASSWORD: ${KIBANA_PASSWORD}
mem_limit: ${MEM_LIMIT}

View file

@ -0,0 +1,13 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { bootstrap } from '../src';
(async () => {
await bootstrap();
})();

View file

@ -0,0 +1,24 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
createTestEnv,
configServiceMock as configMock,
rawConfigServiceMock as rawMock,
} from '@kbn/config-mocks';
export const envCreateDefaultMock = jest.fn().mockImplementation(() => createTestEnv);
export const configServiceMock = jest.fn().mockImplementation(() => configMock.create());
export const rawConfigServiceMock = jest.fn().mockImplementation(() => rawMock.create());
jest.doMock('@kbn/config', () => ({
Env: {
createDefault: envCreateDefaultMock,
},
ConfigService: configServiceMock,
RawConfigService: rawConfigServiceMock,
}));

View file

@ -0,0 +1,92 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
envCreateDefaultMock,
configServiceMock,
rawConfigServiceMock,
} from './config_service.test.mocks';
import { loggerMock, MockedLogger } from '@kbn/logging-mocks';
import { fromRoot } from '@kbn/utils';
import { getConfigService } from './config_service';
const DEFAULT_CONFIG_PATH = fromRoot('config/gateway.yml');
describe('getConfigService', () => {
let logger: MockedLogger;
beforeEach(() => {
logger = loggerMock.create();
});
afterEach(() => {
envCreateDefaultMock.mockClear();
configServiceMock.mockClear();
rawConfigServiceMock.mockClear();
});
test('instantiates RawConfigService with the default config path', () => {
const oldArgv = process.argv;
process.argv = [];
getConfigService({ logger });
expect(rawConfigServiceMock).toHaveBeenCalledTimes(1);
expect(rawConfigServiceMock).toHaveBeenCalledWith([DEFAULT_CONFIG_PATH]);
process.argv = oldArgv;
});
test('instantiates RawConfigService with a custom config path provided via -c flag', () => {
const oldArgv = process.argv;
process.argv = ['-a', 'bc', '-c', 'a/b/c.yml', '-x', 'yz'];
getConfigService({ logger });
expect(rawConfigServiceMock).toHaveBeenCalledTimes(1);
expect(rawConfigServiceMock).toHaveBeenCalledWith(['a/b/c.yml']);
process.argv = oldArgv;
});
test('instantiates RawConfigService with a custom config path provided via --config flag', () => {
const oldArgv = process.argv;
process.argv = ['-a', 'bc', '--config', 'a/b/c.yml', '-x', 'yz'];
getConfigService({ logger });
expect(rawConfigServiceMock).toHaveBeenCalledTimes(1);
expect(rawConfigServiceMock).toHaveBeenCalledWith(['a/b/c.yml']);
process.argv = oldArgv;
});
test('creates default env', async () => {
const oldArgv = process.argv;
process.argv = [];
getConfigService({ logger });
expect(envCreateDefaultMock).toHaveBeenCalledTimes(1);
expect(envCreateDefaultMock.mock.calls[0][1].configs).toEqual([DEFAULT_CONFIG_PATH]);
process.argv = oldArgv;
});
test('attempts to load the config', () => {
const mockLoadConfig = jest.fn();
rawConfigServiceMock.mockImplementationOnce(() => ({
loadConfig: mockLoadConfig,
}));
getConfigService({ logger });
expect(mockLoadConfig).toHaveBeenCalledTimes(1);
});
test('instantiates the config service', async () => {
getConfigService({ logger });
expect(configServiceMock).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,44 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { fromRoot, REPO_ROOT } from '@kbn/utils';
import type { LoggerFactory } from '@kbn/logging';
import { ConfigService as KbnConfigService, CliArgs, Env, RawConfigService } from '@kbn/config';
import { getArgValues } from './read_argv';
const CONFIG_CLI_FLAGS = ['-c', '--config'];
const DEFAULT_CONFIG_PATH = fromRoot('config/gateway.yml');
// These `cliArgs` are required by `Env` for use with Kibana,
// however they have no effect on the health gateway.
const KIBANA_CLI_ARGS: CliArgs = {
dev: false,
silent: false,
watch: false,
basePath: false,
disableOptimizer: true,
cache: false,
dist: false,
oss: false,
runExamples: false,
};
export function getConfigService({ logger }: { logger: LoggerFactory }) {
const configPathOverride = getArgValues(process.argv, CONFIG_CLI_FLAGS);
const configPath = configPathOverride.length ? configPathOverride : [DEFAULT_CONFIG_PATH];
const rawConfigService = new RawConfigService(configPath);
rawConfigService.loadConfig();
const env = Env.createDefault(REPO_ROOT, {
configs: configPath,
cliArgs: KIBANA_CLI_ARGS,
});
return new KbnConfigService(rawConfigService, env, logger);
}

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { getConfigService } from './config_service';

View file

@ -0,0 +1,43 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { getArgValues } from './read_argv';
describe('getArgValues', () => {
it('retrieve the arg value from the provided argv arguments', () => {
const argValues = getArgValues(
['--config', 'my-config', '--foo', '-b', 'bar', '--config', 'other-config', '--baz'],
'--config'
);
expect(argValues).toEqual(['my-config', 'other-config']);
});
it('accept aliases', () => {
const argValues = getArgValues(
['--config', 'my-config', '--foo', '-b', 'bar', '-c', 'other-config', '--baz'],
['--config', '-c']
);
expect(argValues).toEqual(['my-config', 'other-config']);
});
it('returns an empty array when the arg is not found', () => {
const argValues = getArgValues(
['--config', 'my-config', '--foo', '-b', 'bar', '-c', 'other-config', '--baz'],
'--unicorn'
);
expect(argValues).toEqual([]);
});
it('ignores the flag when no value is provided', () => {
const argValues = getArgValues(
['-c', 'my-config', '--foo', '-b', 'bar', '--config'],
['--config', '-c']
);
expect(argValues).toEqual(['my-config']);
});
});

View file

@ -0,0 +1,21 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/**
* Borrowed from @kbn/apm-config-loader.
*/
export const getArgValues = (argv: string[], flag: string | string[]): string[] => {
const flags = typeof flag === 'string' ? [flag] : flag;
const values: string[] = [];
for (let i = 0; i < argv.length; i++) {
if (flags.includes(argv[i]) && argv[i + 1]) {
values.push(argv[++i]);
}
}
return values;
};

View file

@ -0,0 +1,67 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ServiceConfigDescriptor } from '@kbn/core-base-server-internal';
import {
config as loggingConfig,
LoggingSystem,
LoggingConfigType,
} from '@kbn/core-logging-server-internal';
import { getConfigService } from './config';
import { config as kibanaConfig, KibanaService } from './kibana';
import { config as serverConfig, Server, ServerStart } from './server';
export async function bootstrap() {
const loggingSystem = new LoggingSystem();
const logger = loggingSystem.asLoggerFactory();
const configService = getConfigService({ logger });
const configDescriptors: ServiceConfigDescriptor[] = [loggingConfig, kibanaConfig, serverConfig];
for (const { path, schema } of configDescriptors) {
configService.setSchema(path, schema);
}
await configService.validate();
await loggingSystem.upgrade(configService.atPathSync<LoggingConfigType>('logging'));
const log = logger.get('root');
let server: Server;
let serverStart: ServerStart;
try {
server = new Server({ config: configService, logger });
serverStart = await server.start();
} catch (e) {
log.error(`Failed to start Server: ${e}`);
process.exit(1);
}
let kibanaService: KibanaService;
try {
kibanaService = new KibanaService({ config: configService, logger });
await kibanaService.start({ server: serverStart });
} catch (e) {
log.error(`Failed to start Kibana service: ${e}`);
process.exit(1);
}
const attemptGracefulShutdown = async (exitCode: number = 0) => {
await server.stop();
kibanaService.stop();
await loggingSystem.stop();
process.exit(exitCode);
};
process.on('unhandledRejection', async (err: Error) => {
log.error(err);
await attemptGracefulShutdown(1);
});
process.on('SIGINT', async () => await attemptGracefulShutdown());
process.on('SIGTERM', async () => await attemptGracefulShutdown());
}

View file

@ -0,0 +1,10 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { config } from './kibana_config';
export { KibanaService } from './kibana_service';

View file

@ -0,0 +1,79 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { config } from './kibana_config';
describe('kibana config', () => {
test('has defaults for config', () => {
const configSchema = config.schema;
const obj = {
hosts: ['http://localhost:5601'],
};
expect(configSchema.validate(obj)).toMatchInlineSnapshot(`
Object {
"hosts": Array [
"http://localhost:5601",
],
"requestTimeout": "PT30S",
"ssl": Object {
"verificationMode": "full",
},
}
`);
});
describe('hosts', () => {
test('accepts valid hosts', () => {
const configSchema = config.schema;
const validHosts = ['http://some.host:1234', 'https://some.other.host'];
expect(configSchema.validate({ hosts: validHosts })).toEqual(
expect.objectContaining({ hosts: validHosts })
);
});
test('throws if invalid hosts', () => {
const invalidHosts = ['https://localhost:3000', 'abcxyz'];
const configSchema = config.schema;
expect(() => configSchema.validate({ hosts: invalidHosts })).toThrowError(
'[hosts.1]: expected URI with scheme [http|https].'
);
});
});
describe('ssl', () => {
test('accepts valid ssl config', () => {
const configSchema = config.schema;
const valid = {
certificate: '/herp/derp',
certificateAuthorities: ['/beep/boop'],
verificationMode: 'certificate',
};
expect(
configSchema.validate({
hosts: ['http://localhost:5601'],
ssl: valid,
})
).toEqual(expect.objectContaining({ ssl: valid }));
});
test('throws if invalid ssl config', () => {
const configSchema = config.schema;
const hosts = ['http://localhost:5601'];
const invalid = {
verificationMode: 'nope',
};
expect(() => configSchema.validate({ hosts, ssl: invalid }))
.toThrowErrorMatchingInlineSnapshot(`
"[ssl.verificationMode]: types that failed validation:
- [ssl.verificationMode.0]: expected value to equal [none]
- [ssl.verificationMode.1]: expected value to equal [certificate]
- [ssl.verificationMode.2]: expected value to equal [full]"
`);
});
});
});

View file

@ -0,0 +1,109 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { readFileSync } from 'fs';
import type { Duration } from 'moment';
import { schema, TypeOf } from '@kbn/config-schema';
import { ServiceConfigDescriptor } from '@kbn/core-base-server-internal';
const hostURISchema = schema.uri({ scheme: ['http', 'https'] });
const configSchema = schema.object({
hosts: schema.arrayOf(hostURISchema, {
minSize: 1,
}),
requestTimeout: schema.duration({ defaultValue: '30s' }),
ssl: schema.object({
verificationMode: schema.oneOf(
[schema.literal('none'), schema.literal('certificate'), schema.literal('full')],
{ defaultValue: 'full' }
),
certificateAuthorities: schema.maybe(
schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })])
),
certificate: schema.maybe(schema.string()),
}),
});
export type KibanaConfigType = TypeOf<typeof configSchema>;
export const config: ServiceConfigDescriptor<KibanaConfigType> = {
path: 'kibana' as const,
schema: configSchema,
};
export class KibanaConfig {
/**
* Kibana hosts that the gateway will connect to.
*/
public readonly hosts: string[];
/**
* Timeout after which HTTP requests to the Kibana hosts will be aborted.
*/
public readonly requestTimeout: Duration;
/**
* Settings to configure SSL connection between the gateway and Kibana hosts.
*/
public readonly ssl: SslConfig;
constructor(rawConfig: KibanaConfigType) {
this.hosts = rawConfig.hosts;
this.requestTimeout = rawConfig.requestTimeout;
const { verificationMode } = rawConfig.ssl;
const { certificate, certificateAuthorities } = readKeyAndCerts(rawConfig);
this.ssl = {
certificate,
certificateAuthorities,
verificationMode,
};
}
}
interface SslConfig {
verificationMode: 'none' | 'certificate' | 'full';
certificate?: string;
certificateAuthorities?: string[];
}
const readKeyAndCerts = (rawConfig: KibanaConfigType) => {
let certificate: string | undefined;
let certificateAuthorities: string[] | undefined;
const addCAs = (ca: string[] | undefined) => {
if (ca && ca.length) {
certificateAuthorities = [...(certificateAuthorities || []), ...ca];
}
};
if (rawConfig.ssl.certificate) {
certificate = readFile(rawConfig.ssl.certificate);
}
const ca = rawConfig.ssl.certificateAuthorities;
if (ca) {
const parsed: string[] = [];
const paths = Array.isArray(ca) ? ca : [ca];
if (paths.length > 0) {
for (const path of paths) {
parsed.push(readFile(path));
}
addCAs(parsed);
}
}
return {
certificate,
certificateAuthorities,
};
};
const readFile = (file: string) => readFileSync(file, 'utf8');

View file

@ -0,0 +1,10 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const mockReadFileSync = jest.fn();
jest.doMock('fs', () => ({ readFileSync: mockReadFileSync }));

View file

@ -0,0 +1,57 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { configServiceMock, IConfigServiceMock } from '@kbn/config-mocks';
import { loggerMock, MockedLogger } from '@kbn/logging-mocks';
import type { ServerStart } from '../server';
import { serverMock } from '../server/server.mock';
import { mockReadFileSync } from './kibana_service.test.mocks';
import { KibanaService } from './kibana_service';
describe('KibanaService', () => {
let config: IConfigServiceMock;
let logger: MockedLogger;
let server: ServerStart;
const mockConfig = {
hosts: ['https://localhost:5605', 'https://localhost:5606'],
requestTimeout: '30s',
ssl: {
certificate: '/herp/derp',
certificateAuthorities: '/beep/boop',
verificationMode: 'certificate',
},
};
beforeEach(() => {
mockReadFileSync.mockReset();
mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`);
config = configServiceMock.create();
config.atPathSync.mockReturnValue(mockConfig);
logger = loggerMock.create();
server = serverMock.createStartContract();
});
describe('start', () => {
test(`doesn't return a start contract`, async () => {
const kibanaService = new KibanaService({ config, logger });
const kibanaStart = await kibanaService.start({ server });
expect(kibanaStart).toBeUndefined();
});
test('registers /api/status route with the server', async () => {
const kibanaService = new KibanaService({ config, logger });
await kibanaService.start({ server });
expect(server.addRoute).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
path: '/api/status',
})
);
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { IConfigService } from '@kbn/config';
import type { Logger, LoggerFactory } from '@kbn/logging';
import { ServerStart } from '../server';
import { createStatusRoute } from './routes';
interface KibanaServiceStartDependencies {
server: ServerStart;
}
interface KibanaServiceDependencies {
logger: LoggerFactory;
config: IConfigService;
}
/**
* A service to interact with the configured `kibana.hosts`.
*/
export class KibanaService {
private readonly log: Logger;
private readonly config: IConfigService;
constructor({ logger, config }: KibanaServiceDependencies) {
this.log = logger.get('kibana-service');
this.config = config;
}
async start({ server }: KibanaServiceStartDependencies) {
server.addRoute(createStatusRoute({ config: this.config, log: this.log }));
}
stop() {
// nothing to do here yet
}
}

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { createStatusRoute } from './status';

View file

@ -0,0 +1,146 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import https from 'https';
import { URL } from 'url';
import type { Request, ResponseToolkit } from '@hapi/hapi';
import nodeFetch, { RequestInit, Response } from 'node-fetch';
import type { IConfigService } from '@kbn/config';
import type { Logger } from '@kbn/logging';
import type { KibanaConfigType } from '../kibana_config';
import { KibanaConfig } from '../kibana_config';
const HTTPS = 'https:';
const GATEWAY_STATUS_ROUTE = '/api/status';
const KIBANA_STATUS_ROUTE = '/api/status';
interface StatusRouteDependencies {
log: Logger;
config: IConfigService;
}
type Fetch = (path: string) => Promise<Response>;
export function createStatusRoute({ config, log }: StatusRouteDependencies) {
const kibanaConfig = new KibanaConfig(config.atPathSync<KibanaConfigType>('kibana'));
const fetch = configureFetch(kibanaConfig);
return {
method: 'GET',
path: GATEWAY_STATUS_ROUTE,
handler: async (req: Request, h: ResponseToolkit) => {
const responses = await fetchKibanaStatuses({ fetch, kibanaConfig, log });
const { body, statusCode } = mergeStatusResponses(responses);
return h.response(body).type('application/json').code(statusCode);
},
};
}
async function fetchKibanaStatuses({
fetch,
kibanaConfig,
log,
}: {
fetch: Fetch;
kibanaConfig: KibanaConfig;
log: Logger;
}) {
const requests = await Promise.allSettled(
kibanaConfig.hosts.map(async (host) => {
log.debug(`Fetching response from ${host}${KIBANA_STATUS_ROUTE}`);
const response = fetch(`${host}${KIBANA_STATUS_ROUTE}`).then((res) => res.json());
return response;
})
);
return requests.map((r, i) => {
if (r.status === 'rejected') {
log.error(`Unable to retrieve status from ${kibanaConfig.hosts[i]}${KIBANA_STATUS_ROUTE}`);
} else {
log.info(
`Got response from ${kibanaConfig.hosts[i]}${KIBANA_STATUS_ROUTE}: ${JSON.stringify(
r.value.status?.overall ? r.value.status.overall : r.value
)}`
);
}
return r;
});
}
function mergeStatusResponses(
responses: Array<PromiseFulfilledResult<Response> | PromiseRejectedResult>
) {
let statusCode = 200;
for (const response of responses) {
if (response.status === 'rejected') {
statusCode = 503;
}
}
return {
body: {}, // Need to determine what response body, if any, we want to include
statusCode,
};
}
function generateAgentConfig(sslConfig: KibanaConfig['ssl']) {
const options: https.AgentOptions = {
ca: sslConfig.certificateAuthorities,
cert: sslConfig.certificate,
};
const verificationMode = sslConfig.verificationMode;
switch (verificationMode) {
case 'none':
options.rejectUnauthorized = false;
break;
case 'certificate':
options.rejectUnauthorized = true;
// by default, NodeJS is checking the server identify
options.checkServerIdentity = () => undefined;
break;
case 'full':
options.rejectUnauthorized = true;
break;
default:
throw new Error(`Unknown ssl verificationMode: ${verificationMode}`);
}
return options;
}
function configureFetch(kibanaConfig: KibanaConfig) {
let agent: https.Agent;
return async (url: string) => {
const { protocol } = new URL(url);
if (protocol === HTTPS && !agent) {
agent = new https.Agent(generateAgentConfig(kibanaConfig.ssl));
}
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
kibanaConfig.requestTimeout.asMilliseconds()
);
const fetchOptions: RequestInit = {
...(protocol === HTTPS && { agent }),
signal: controller.signal,
};
try {
const response = await nodeFetch(url, fetchOptions);
clearTimeout(timeoutId);
return response;
} catch (e) {
clearTimeout(timeoutId);
throw e;
}
};
}

View file

@ -0,0 +1,11 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { config } from './server_config';
export type { ServerStart } from './server';
export { Server } from './server';

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { ServerStart } from './server';
import { Server } from './server';
const createStartMock = (): jest.Mocked<ServerStart> => ({
addRoute: jest.fn(),
});
type ServerContract = PublicMethodsOf<Server>;
const createMock = (): jest.Mocked<ServerContract> => {
const service: jest.Mocked<ServerContract> = {
start: jest.fn(),
stop: jest.fn(),
};
service.start.mockResolvedValue(createStartMock());
return service;
};
export const serverMock = {
create: createMock,
createStartContract: createStartMock,
};

View file

@ -0,0 +1,29 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { sslSchema, getServerOptions, getListenerOptions } from '@kbn/server-http-tools';
export const hapiStartMock = jest.fn();
export const hapiStopMock = jest.fn();
export const hapiRouteMock = jest.fn();
export const createServerMock = jest.fn().mockImplementation(() => ({
info: { uri: 'http://localhost:3000' },
start: hapiStartMock,
stop: hapiStopMock,
route: hapiRouteMock,
}));
export const getServerOptionsMock = jest.fn().mockImplementation(getServerOptions);
export const getListenerOptionsMock = jest.fn().mockImplementation(getListenerOptions);
jest.doMock('@kbn/server-http-tools', () => ({
createServer: createServerMock,
getServerOptions: getServerOptionsMock,
getListenerOptions: getListenerOptionsMock,
sslSchema,
SslConfig: jest.fn(),
}));

View file

@ -0,0 +1,95 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
createServerMock,
getServerOptionsMock,
getListenerOptionsMock,
hapiStartMock,
hapiStopMock,
hapiRouteMock,
} from './server.test.mocks';
import { configServiceMock, IConfigServiceMock } from '@kbn/config-mocks';
import { loggerMock, MockedLogger } from '@kbn/logging-mocks';
import { Server } from './server';
const mockConfig = {
port: 3000,
host: 'localhost',
maxPayload: { getValueInBytes: () => '1048576b' },
keepaliveTimeout: 120000,
shutdownTimeout: '30s',
socketTimeout: 120000,
};
describe('Server', () => {
let config: IConfigServiceMock;
let logger: MockedLogger;
beforeEach(() => {
config = configServiceMock.create();
config.atPathSync.mockReturnValue(mockConfig);
logger = loggerMock.create();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('start', () => {
test('logs the uri on server start', async () => {
const server = new Server({ config, logger });
await server.start();
expect(logger.info).toHaveBeenCalledWith('Server running on http://localhost:3000');
});
test('provides the correct server options', async () => {
const server = new Server({ config, logger });
await server.start();
expect(createServerMock).toHaveBeenCalledTimes(1);
expect(getServerOptionsMock).toHaveBeenCalledTimes(1);
expect(getServerOptionsMock.mock.calls[0][0]).toEqual(
expect.objectContaining({ ...mockConfig })
);
expect(getListenerOptionsMock.mock.calls[0][0]).toEqual(
expect.objectContaining({ ...mockConfig })
);
});
test('starts the Hapi server', async () => {
const server = new Server({ config, logger });
await server.start();
expect(hapiStartMock).toHaveBeenCalledTimes(1);
});
describe('addRoute', () => {
test('registers route with Hapi', async () => {
const server = new Server({ config, logger });
const { addRoute } = await server.start();
addRoute({
method: 'GET',
path: '/api/whatever',
});
expect(hapiRouteMock).toHaveBeenCalledTimes(1);
expect(hapiRouteMock).toHaveBeenCalledWith({
method: 'GET',
path: '/api/whatever',
});
});
});
});
describe('stop', () => {
test('attempts graceful shutdown', async () => {
const server = new Server({ config, logger });
await server.start();
await server.stop();
expect(hapiStopMock).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,62 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Server as HapiServer, ServerRoute as HapiServerRoute } from '@hapi/hapi';
import { createServer, getServerOptions, getListenerOptions } from '@kbn/server-http-tools';
import type { IConfigService } from '@kbn/config';
import type { Logger, LoggerFactory } from '@kbn/logging';
import { ServerConfig } from './server_config';
import type { ServerConfigType } from './server_config';
interface ServerDeps {
logger: LoggerFactory;
config: IConfigService;
}
type RouteDefinition = HapiServerRoute;
export interface ServerStart {
addRoute: (routeDefinition: RouteDefinition) => void;
}
/**
* A very thin wrapper around Hapi, which only exposes the functionality we
* need for this app.
*/
export class Server {
private readonly log: Logger;
private readonly config: IConfigService;
private server?: HapiServer;
constructor({ logger, config }: ServerDeps) {
this.log = logger.get('server');
this.config = config;
}
async start(): Promise<ServerStart> {
const serverConfig = new ServerConfig(this.config.atPathSync<ServerConfigType>('server'));
this.server = createServer(getServerOptions(serverConfig), getListenerOptions(serverConfig));
await this.server.start();
this.log.info(`Server running on ${this.server.info.uri}`);
return {
addRoute: (definition) => {
this.log.debug(`registering route handler for [${definition.path}]`);
this.server!.route(definition);
},
};
}
async stop() {
this.log.debug('Attempting graceful shutdown');
if (this.server) {
await this.server.stop();
}
}
}

View file

@ -0,0 +1,191 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { config, ServerConfig } from './server_config';
describe('server config', () => {
test('has defaults for config', () => {
const configSchema = config.schema;
const obj = {};
expect(configSchema.validate(obj)).toMatchInlineSnapshot(`
Object {
"host": "localhost",
"keepaliveTimeout": 120000,
"maxPayload": ByteSizeValue {
"valueInBytes": 1048576,
},
"port": 3000,
"shutdownTimeout": "PT30S",
"socketTimeout": 120000,
"ssl": Object {
"cipherSuites": Array [
"TLS_AES_256_GCM_SHA384",
"TLS_CHACHA20_POLY1305_SHA256",
"TLS_AES_128_GCM_SHA256",
"ECDHE-RSA-AES128-GCM-SHA256",
"ECDHE-ECDSA-AES128-GCM-SHA256",
"ECDHE-RSA-AES256-GCM-SHA384",
"ECDHE-ECDSA-AES256-GCM-SHA384",
"DHE-RSA-AES128-GCM-SHA256",
"ECDHE-RSA-AES128-SHA256",
"DHE-RSA-AES128-SHA256",
"ECDHE-RSA-AES256-SHA384",
"DHE-RSA-AES256-SHA384",
"ECDHE-RSA-AES256-SHA256",
"DHE-RSA-AES256-SHA256",
"HIGH",
"!aNULL",
"!eNULL",
"!EXPORT",
"!DES",
"!RC4",
"!MD5",
"!PSK",
"!SRP",
"!CAMELLIA",
],
"clientAuthentication": "none",
"enabled": false,
"keystore": Object {},
"supportedProtocols": Array [
"TLSv1.1",
"TLSv1.2",
"TLSv1.3",
],
"truststore": Object {},
},
}
`);
});
describe('host', () => {
const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost', '0.0.0.0'];
const invalidHostnames = ['asdf$%^', '0'];
test('accepts valid hostnames', () => {
for (const val of validHostnames) {
const { host } = config.schema.validate({ host: val });
expect(host).toBe(val);
}
});
test('throws if invalid hostname', () => {
for (const host of invalidHostnames) {
const configSchema = config.schema;
expect(() => configSchema.validate({ host })).toThrowError(
'[host]: value must be a valid hostname (see RFC 1123).'
);
}
});
});
describe('port', () => {
test('accepts valid ports', () => {
const validPorts = [80, 3000, 5601];
for (const val of validPorts) {
const { port } = config.schema.validate({ port: val });
expect(port).toBe(val);
}
});
test('throws if invalid ports', () => {
const configSchema = config.schema;
expect(() => configSchema.validate({ port: false })).toThrowError(
'port]: expected value of type [number] but got [boolean]'
);
expect(() => configSchema.validate({ port: 'oops' })).toThrowError(
'port]: expected value of type [number] but got [string]'
);
});
});
describe('maxPayload', () => {
test('can specify max payload as string', () => {
const obj = {
maxPayload: '2mb',
};
const configValue = config.schema.validate(obj);
expect(configValue.maxPayload.getValueInBytes()).toBe(2 * 1024 * 1024);
});
});
describe('shutdownTimeout', () => {
test('can specify a valid shutdownTimeout', () => {
const configValue = config.schema.validate({ shutdownTimeout: '5s' });
expect(configValue.shutdownTimeout.asMilliseconds()).toBe(5000);
});
test('can specify a valid shutdownTimeout (lower-edge of 1 second)', () => {
const configValue = config.schema.validate({ shutdownTimeout: '1s' });
expect(configValue.shutdownTimeout.asMilliseconds()).toBe(1000);
});
test('can specify a valid shutdownTimeout (upper-edge of 2 minutes)', () => {
const configValue = config.schema.validate({ shutdownTimeout: '2m' });
expect(configValue.shutdownTimeout.asMilliseconds()).toBe(120000);
});
test('should error if below 1s', () => {
expect(() => config.schema.validate({ shutdownTimeout: '100ms' })).toThrow(
'[shutdownTimeout]: the value should be between 1 second and 2 minutes'
);
});
test('should error if over 2 minutes', () => {
expect(() => config.schema.validate({ shutdownTimeout: '3m' })).toThrow(
'[shutdownTimeout]: the value should be between 1 second and 2 minutes'
);
});
});
describe('with TLS', () => {
test('throws if TLS is enabled but `redirectHttpFromPort` is equal to `port`', () => {
const configSchema = config.schema;
const obj = {
port: 1234,
ssl: {
certificate: '/path/to/certificate',
enabled: true,
key: '/path/to/key',
redirectHttpFromPort: 1234,
},
};
expect(() => configSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot(
`"The health gateway does not accept http traffic to [port] when ssl is enabled (only https is allowed), so [ssl.redirectHttpFromPort] cannot be configured to the same value. Both are [1234]."`
);
});
});
describe('socketTimeout', () => {
test('can specify socket timeouts', () => {
const obj = {
keepaliveTimeout: 1e5,
socketTimeout: 5e5,
};
const { keepaliveTimeout, socketTimeout } = config.schema.validate(obj);
expect(keepaliveTimeout).toBe(1e5);
expect(socketTimeout).toBe(5e5);
});
});
describe('cors', () => {
test('is always disabled', () => {
const configSchema = config.schema;
const obj = {};
expect(new ServerConfig(configSchema.validate(obj)).cors).toMatchInlineSnapshot(`
Object {
"allowCredentials": false,
"allowOrigin": Array [
"*",
],
"enabled": false,
}
`);
});
});
});

View file

@ -0,0 +1,92 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Duration } from 'moment';
import { schema, TypeOf, ByteSizeValue } from '@kbn/config-schema';
import { ServiceConfigDescriptor } from '@kbn/core-base-server-internal';
import type { ISslConfig, ICorsConfig, IHttpConfig } from '@kbn/server-http-tools';
import { sslSchema, SslConfig } from '@kbn/server-http-tools';
const configSchema = schema.object(
{
host: schema.string({
defaultValue: 'localhost',
hostname: true,
}),
port: schema.number({
defaultValue: 3000,
}),
maxPayload: schema.byteSize({
defaultValue: '1048576b',
}),
keepaliveTimeout: schema.number({
defaultValue: 120000,
}),
shutdownTimeout: schema.duration({
defaultValue: '30s',
validate: (duration) => {
const durationMs = duration.asMilliseconds();
if (durationMs < 1000 || durationMs > 2 * 60 * 1000) {
return 'the value should be between 1 second and 2 minutes';
}
},
}),
socketTimeout: schema.number({
defaultValue: 120000,
}),
ssl: sslSchema,
},
{
validate: (rawConfig) => {
if (
rawConfig.ssl.enabled &&
rawConfig.ssl.redirectHttpFromPort !== undefined &&
rawConfig.ssl.redirectHttpFromPort === rawConfig.port
) {
return (
'The health gateway does not accept http traffic to [port] when ssl is ' +
'enabled (only https is allowed), so [ssl.redirectHttpFromPort] ' +
`cannot be configured to the same value. Both are [${rawConfig.port}].`
);
}
},
}
);
export type ServerConfigType = TypeOf<typeof configSchema>;
export const config: ServiceConfigDescriptor<ServerConfigType> = {
path: 'server' as const,
schema: configSchema,
};
export class ServerConfig implements IHttpConfig {
host: string;
port: number;
maxPayload: ByteSizeValue;
keepaliveTimeout: number;
shutdownTimeout: Duration;
socketTimeout: number;
ssl: ISslConfig;
cors: ICorsConfig;
constructor(rawConfig: ServerConfigType) {
this.host = rawConfig.host;
this.port = rawConfig.port;
this.maxPayload = rawConfig.maxPayload;
this.keepaliveTimeout = rawConfig.keepaliveTimeout;
this.shutdownTimeout = rawConfig.shutdownTimeout;
this.socketTimeout = rawConfig.socketTimeout;
this.ssl = new SslConfig(rawConfig.ssl);
this.cors = {
enabled: false,
allowCredentials: false,
allowOrigin: ['*'],
};
}
}

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.bazel.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "target_types",
"stripInternal": false,
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
]
}

View file

@ -0,0 +1,26 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Command } from 'commander';
import { kibanaPackageJson } from '@kbn/utils';
import { bootstrap } from '@kbn/health-gateway-server';
const program = new Command('bin/kibana-health-gateway');
program
.version(kibanaPackageJson.version)
.description(
'This command starts up a health gateway server that can be ' +
'configured to send requests to multiple Kibana instances'
)
.option('-c, --config', 'Path to a gateway.yml configuration file')
.action(async () => {
return await bootstrap();
});
program.parse(process.argv);

View file

@ -0,0 +1,10 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
require('../setup_node_env');
require('./cli_health_gateway');

View file

@ -0,0 +1,10 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
require('../setup_node_env/dist');
require('./cli_health_gateway');

View file

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target/types",
"emitDeclarationOnly": true,
"declaration": true,
},
"include": [
"*.js",
"*.ts",
],
"kbn_references": [
{ "path": "../cli/tsconfig.json" },
]
}

View file

@ -0,0 +1,29 @@
#!/bin/sh
SCRIPT=$0
# SCRIPT may be an arbitrarily deep series of symlinks. Loop until we have the concrete path.
while [ -h "$SCRIPT" ] ; do
ls=$(ls -ld "$SCRIPT")
# Drop everything prior to ->
link=$(expr "$ls" : '.*-> \(.*\)$')
if expr "$link" : '/.*' > /dev/null; then
SCRIPT="$link"
else
SCRIPT=$(dirname "$SCRIPT")/"$link"
fi
done
DIR="$(dirname "${SCRIPT}")/.."
CONFIG_DIR=${KBN_PATH_CONF:-"$DIR/config"}
NODE="${DIR}/node/bin/node"
test -x "$NODE"
if [ ! -x "$NODE" ]; then
echo "unable to find usable node.js executable."
exit 1
fi
if [ -f "${CONFIG_DIR}/node.options" ]; then
KBN_NODE_OPTS="$(grep -v ^# < ${CONFIG_DIR}/node.options | xargs)"
fi
NODE_OPTIONS="$KBN_NODE_OPTS $NODE_OPTIONS" NODE_ENV=production exec "${NODE}" "${DIR}/src/cli_health_gateway/dist" "$@"

View file

@ -0,0 +1,36 @@
@echo off
SETLOCAL ENABLEDELAYEDEXPANSION
set SCRIPT_DIR=%~dp0
for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI
set NODE=%DIR%\node\node.exe
set NODE_ENV=production
If Not Exist "%NODE%" (
Echo unable to find usable node.js executable.
Exit /B 1
)
set CONFIG_DIR=%KBN_PATH_CONF%
If ["%KBN_PATH_CONF%"] == [] (
set "CONFIG_DIR=%DIR%\config"
)
IF EXIST "%CONFIG_DIR%\node.options" (
for /F "usebackq eol=# tokens=*" %%i in ("%CONFIG_DIR%\node.options") do (
If [!NODE_OPTIONS!] == [] (
set "NODE_OPTIONS=%%i"
) Else (
set "NODE_OPTIONS=!NODE_OPTIONS! %%i"
)
)
)
TITLE Health Gateway
"%NODE%" "%DIR%\src\cli_health_gateway\dist" %*
:finally
ENDLOCAL

View file

@ -7,6 +7,7 @@
- assert_encryption_keys_cli
- assert_plugin_cli
- assert_setup_cli
- assert_health_gateway_cli
- assert_verification_code_cli
- assert_kibana_yml
- assert_kibana_listening

View file

@ -0,0 +1,13 @@
- name: "--help"
become: true
command:
cmd: /usr/share/kibana/bin/kibana-health-gateway --help
register: health_gateway_help
- debug:
msg: "{{ health_gateway_help }}"
- name: assert health-gateway provides help
assert:
that:
- health_gateway_help.failed == false

View file

@ -7,6 +7,7 @@
- assert_encryption_keys_cli
- assert_plugin_cli
- assert_setup_cli
- assert_health_gateway_cli
- assert_verification_code_cli
- assert_kibana_yml
- assert_kibana_listening

View file

@ -476,6 +476,8 @@
"@kbn/handlebars/*": ["packages/kbn-handlebars/*"],
"@kbn/hapi-mocks": ["packages/kbn-hapi-mocks"],
"@kbn/hapi-mocks/*": ["packages/kbn-hapi-mocks/*"],
"@kbn/health-gateway-server": ["packages/kbn-health-gateway-server"],
"@kbn/health-gateway-server/*": ["packages/kbn-health-gateway-server/*"],
"@kbn/i18n": ["packages/kbn-i18n"],
"@kbn/i18n/*": ["packages/kbn-i18n/*"],
"@kbn/i18n-react": ["packages/kbn-i18n-react"],

View file

@ -10,4 +10,4 @@
"kbn_references": [
{ "path": "./src/core/tsconfig.json" },
]
}
}

View file

@ -3595,6 +3595,10 @@
version "0.0.0"
uid ""
"@kbn/health-gateway-server@link:bazel-bin/packages/kbn-health-gateway-server":
version "0.0.0"
uid ""
"@kbn/home-sample-data-card@link:bazel-bin/packages/home/sample_data_card":
version "0.0.0"
uid ""