[Data Usage] Added AutoOps API service (#195844)

## Summary

Summarize your PR. If it involves visual changes include a screenshot or
gif.


### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [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
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces—unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes—Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#_add_your_labels)
- [ ] This will appear in the **Release Notes** and follow the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yuliia Naumenko 2024-10-13 21:37:48 -07:00 committed by GitHub
parent 288d41d61e
commit 6c4ac90f72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 311 additions and 1 deletions

View file

@ -0,0 +1,74 @@
/*
* 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 type { Observable } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import { kibanaPackageJson } from '@kbn/repo-info';
import type { LoggerFactory } from '@kbn/core/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import { DataUsageConfigType } from './config';
import type { DataUsageContext } from './types';
class AppContextService {
private config$?: Observable<DataUsageConfigType>;
private configSubject$?: BehaviorSubject<DataUsageConfigType>;
private kibanaVersion: DataUsageContext['kibanaVersion'] = kibanaPackageJson.version;
private kibanaBranch: DataUsageContext['kibanaBranch'] = kibanaPackageJson.branch;
private kibanaInstanceId: DataUsageContext['kibanaInstanceId'] = '';
private cloud?: CloudSetup;
private logFactory?: LoggerFactory;
public start(appContext: DataUsageContext) {
this.cloud = appContext.cloud;
this.logFactory = appContext.logFactory;
this.kibanaVersion = appContext.kibanaVersion;
this.kibanaBranch = appContext.kibanaBranch;
this.kibanaInstanceId = appContext.kibanaInstanceId;
if (appContext.config$) {
this.config$ = appContext.config$;
const initialValue = appContext.configInitialValue;
this.configSubject$ = new BehaviorSubject(initialValue);
}
}
public stop() {}
public getCloud() {
return this.cloud;
}
public getLogger() {
if (!this.logFactory) {
throw new Error('Logger not set.');
}
return this.logFactory;
}
public getConfig() {
return this.configSubject$?.value;
}
public getConfig$() {
return this.config$;
}
public getKibanaVersion() {
return this.kibanaVersion;
}
public getKibanaBranch() {
return this.kibanaBranch;
}
public getKibanaInstanceId() {
return this.kibanaInstanceId;
}
}
export const appContextService = new AppContextService();

View file

@ -10,6 +10,23 @@ import { PluginInitializerContext } from '@kbn/core/server';
export const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
autoops: schema.maybe(
schema.object({
enabled: schema.boolean({ defaultValue: false }),
api: schema.maybe(
schema.object({
url: schema.maybe(schema.uri({ scheme: ['http', 'https'] })),
tls: schema.maybe(
schema.object({
certificate: schema.maybe(schema.string()),
key: schema.maybe(schema.string()),
ca: schema.maybe(schema.string()),
})
),
})
),
})
),
});
export type DataUsageConfigType = TypeOf<typeof configSchema>;

View file

@ -18,6 +18,7 @@ import type {
} from './types';
import { registerDataUsageRoutes } from './routes';
import { PLUGIN_ID } from '../common';
import { appContextService } from './app_context';
export class DataUsagePlugin
implements
@ -37,11 +38,17 @@ export class DataUsagePlugin
this.logger = context.logger.get();
this.logger.debug('data usage plugin initialized');
this.dataUsageContext = {
config$: context.config.create<DataUsageConfigType>(),
configInitialValue: context.config.get(),
logFactory: context.logger,
get serverConfig() {
return serverConfig;
},
kibanaVersion: context.env.packageInfo.version,
kibanaBranch: context.env.packageInfo.branch,
kibanaInstanceId: context.env.instanceUuid,
};
}
setup(coreSetup: CoreSetup, pluginsSetup: DataUsageSetupDependencies): DataUsageServerSetup {
@ -64,7 +71,17 @@ export class DataUsagePlugin
return {};
}
start(coreStart: CoreStart, pluginsStart: DataUsageStartDependencies): DataUsageServerStart {
start(_coreStart: CoreStart, _pluginsStart: DataUsageStartDependencies): DataUsageServerStart {
appContextService.start({
logFactory: this.dataUsageContext.logFactory,
configInitialValue: this.dataUsageContext.configInitialValue,
serverConfig: this.dataUsageContext.serverConfig,
config$: this.dataUsageContext.config$,
kibanaVersion: this.dataUsageContext.kibanaVersion,
kibanaBranch: this.dataUsageContext.kibanaBranch,
kibanaInstanceId: this.dataUsageContext.kibanaInstanceId,
cloud: this.dataUsageContext.cloud,
});
return {};
}

View file

@ -0,0 +1,178 @@
/*
* 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 https from 'https';
import { SslConfig, sslSchema } from '@kbn/server-http-tools';
import apm from 'elastic-apm-node';
import type { AxiosError, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import { LogMeta } from '@kbn/core/server';
import {
UsageMetricsRequestSchemaQueryParams,
UsageMetricsResponseSchemaBody,
} from '../../common/rest_types';
import { appContextService } from '../app_context';
import { AutoOpsConfig } from '../types';
class AutoOpsAPIService {
public async autoOpsUsageMetricsAPI(requestBody: UsageMetricsRequestSchemaQueryParams) {
const logger = appContextService.getLogger().get();
const traceId = apm.currentTransaction?.traceparent;
const withRequestIdMessage = (message: string) => `${message} [Request Id: ${traceId}]`;
const errorMetadata: LogMeta = {
trace: {
id: traceId,
},
};
const autoopsConfig = appContextService.getConfig()?.autoops;
if (!autoopsConfig) {
logger.error('[AutoOps API] Missing autoops configuration', errorMetadata);
throw new Error('missing autoops configuration');
}
logger.debug(
`[AutoOps API] Creating autoops agent with TLS cert: ${
autoopsConfig?.api?.tls?.certificate ? '[REDACTED]' : 'undefined'
} and TLS key: ${autoopsConfig?.api?.tls?.key ? '[REDACTED]' : 'undefined'}
and TLS ca: ${autoopsConfig?.api?.tls?.ca ? '[REDACTED]' : 'undefined'}`
);
const tlsConfig = this.createTlsConfig(autoopsConfig);
const requestConfig: AxiosRequestConfig = {
url: autoopsConfig.api?.url,
data: requestBody,
method: 'POST',
headers: {
'Content-type': 'application/json',
'X-Request-ID': traceId,
},
httpsAgent: new https.Agent({
rejectUnauthorized: tlsConfig.rejectUnauthorized,
cert: tlsConfig.certificate,
key: tlsConfig.key,
ca: tlsConfig.certificateAuthorities,
}),
};
const cloudSetup = appContextService.getCloud();
if (!cloudSetup?.isServerlessEnabled) {
requestConfig.data.stack_version = appContextService.getKibanaVersion();
}
const requestConfigDebugStatus = this.createRequestConfigDebug(requestConfig);
logger.debug(
`[AutoOps API] Creating autoops agent with request config ${requestConfigDebugStatus}`
);
const errorMetadataWithRequestConfig: LogMeta = {
...errorMetadata,
http: {
request: {
id: traceId,
body: requestConfig.data,
},
},
};
const response = await axios<UsageMetricsResponseSchemaBody>(requestConfig).catch(
(error: Error | AxiosError) => {
if (!axios.isAxiosError(error)) {
logger.error(
`[AutoOps API] Creating autoops failed with an error ${error} ${requestConfigDebugStatus}`,
errorMetadataWithRequestConfig
);
throw new Error(withRequestIdMessage(error.message));
}
const errorLogCodeCause = `${error.code} ${this.convertCauseErrorsToString(error)}`;
if (error.response) {
// The request was made and the server responded with a status code and error data
logger.error(
`[AutoOps API] Creating autoops failed because the AutoOps API responding with a status code that falls out of the range of 2xx: ${JSON.stringify(
error.response.status
)}} ${JSON.stringify(error.response.data)}} ${requestConfigDebugStatus}`,
{
...errorMetadataWithRequestConfig,
http: {
...errorMetadataWithRequestConfig.http,
response: {
status_code: error.response.status,
body: error.response.data,
},
},
}
);
throw new Error(
withRequestIdMessage(`the AutoOps API could not create the autoops agent`)
);
} else if (error.request) {
// The request was made but no response was received
logger.error(
`[AutoOps API] Creating autoops agent failed while sending the request to the AutoOps API: ${errorLogCodeCause} ${requestConfigDebugStatus}`,
errorMetadataWithRequestConfig
);
throw new Error(withRequestIdMessage(`no response received from the AutoOps API`));
} else {
// Something happened in setting up the request that triggered an Error
logger.error(
`[AutoOps API] Creating autoops agent failed to be created ${errorLogCodeCause} ${requestConfigDebugStatus}`,
errorMetadataWithRequestConfig
);
throw new Error(
withRequestIdMessage('the AutoOps API could not create the autoops agent')
);
}
}
);
logger.debug(`[AutoOps API] Created an autoops agent ${response}`);
return response;
}
private createTlsConfig(autoopsConfig: AutoOpsConfig | undefined) {
return new SslConfig(
sslSchema.validate({
enabled: true,
certificate: autoopsConfig?.api?.tls?.certificate,
key: autoopsConfig?.api?.tls?.key,
certificateAuthorities: autoopsConfig?.api?.tls?.ca,
})
);
}
private createRequestConfigDebug(requestConfig: AxiosRequestConfig<any>) {
return JSON.stringify({
...requestConfig,
data: {
...requestConfig.data,
fleet_token: '[REDACTED]',
},
httpsAgent: {
...requestConfig.httpsAgent,
options: {
...requestConfig.httpsAgent.options,
cert: requestConfig.httpsAgent.options.cert ? 'REDACTED' : undefined,
key: requestConfig.httpsAgent.options.key ? 'REDACTED' : undefined,
ca: requestConfig.httpsAgent.options.ca ? 'REDACTED' : undefined,
},
},
});
}
private convertCauseErrorsToString = (error: AxiosError) => {
if (error.cause instanceof AggregateError) {
return error.cause.errors.map((e: Error) => e.message);
}
return error.cause;
};
}
export const autoopsApiService = new AutoOpsAPIService();

View file

@ -10,9 +10,12 @@ import type {
CustomRequestHandlerContext,
IRouter,
LoggerFactory,
PluginInitializerContext,
} from '@kbn/core/server';
import { DeepReadonly } from 'utility-types';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import { FeaturesPluginSetup } from '@kbn/features-plugin/server';
import { Observable } from 'rxjs';
import { DataUsageConfigType } from '../config';
export interface DataUsageSetupDependencies {
@ -36,7 +39,25 @@ export type DataUsageRequestHandlerContext = CustomRequestHandlerContext<{
export type DataUsageRouter = IRouter<DataUsageRequestHandlerContext>;
export interface AutoOpsConfig {
enabled?: boolean;
api?: {
url?: string;
tls?: {
certificate?: string;
key?: string;
ca?: string;
};
};
}
export interface DataUsageContext {
logFactory: LoggerFactory;
config$?: Observable<DataUsageConfigType>;
configInitialValue: DataUsageConfigType;
serverConfig: DeepReadonly<DataUsageConfigType>;
kibanaVersion: PluginInitializerContext['env']['packageInfo']['version'];
kibanaBranch: PluginInitializerContext['env']['packageInfo']['branch'];
kibanaInstanceId: PluginInitializerContext['env']['instanceUuid'];
cloud?: CloudSetup;
}

View file

@ -29,6 +29,9 @@
"@kbn/core-chrome-browser",
"@kbn/features-plugin",
"@kbn/index-management-shared-types",
"@kbn/repo-info",
"@kbn/cloud-plugin",
"@kbn/server-http-tools",
],
"exclude": ["target/**/*"]
}