Migrate status & stats APIs to KP + remove legacy status lib (#76054) (#77752)

* Complete migration of legacy status API

* PR comments

* Remove optional auth on stats endpoint

* Regen docs

* PR comments

* PR nits

* Set ReplaySubject buffer to 1

* Fix exclude_usage logic

Co-authored-by: pgayvallet <pierre.gayvallet@elastic.co>

Co-authored-by: Josh Dover <me@joshdover.com>
This commit is contained in:
Pierre Gayvallet 2020-09-17 16:05:56 +02:00 committed by GitHub
parent 475ba3e636
commit a676670218
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 1414 additions and 2463 deletions

View file

@ -57,7 +57,8 @@
"visTypeXy": "src/plugins/vis_type_xy",
"visualizations": "src/plugins/visualizations",
"visualize": "src/plugins/visualize",
"apmOss": "src/plugins/apm_oss"
"apmOss": "src/plugins/apm_oss",
"usageCollection": "src/plugins/usage_collection"
},
"exclude": [
"src/legacy/ui/ui_render/ui_render_mixin.js"

View file

@ -23,6 +23,7 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
| [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) | <code>StartServicesAccessor&lt;TPluginsStart, TStart&gt;</code> | [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) |
| [http](./kibana-plugin-core-server.coresetup.http.md) | <code>HttpServiceSetup &amp; {</code><br/><code> resources: HttpResources;</code><br/><code> }</code> | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) |
| [logging](./kibana-plugin-core-server.coresetup.logging.md) | <code>LoggingServiceSetup</code> | [LoggingServiceSetup](./kibana-plugin-core-server.loggingservicesetup.md) |
| [metrics](./kibana-plugin-core-server.coresetup.metrics.md) | <code>MetricsServiceSetup</code> | [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) |
| [savedObjects](./kibana-plugin-core-server.coresetup.savedobjects.md) | <code>SavedObjectsServiceSetup</code> | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) |
| [status](./kibana-plugin-core-server.coresetup.status.md) | <code>StatusServiceSetup</code> | [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) |
| [uiSettings](./kibana-plugin-core-server.coresetup.uisettings.md) | <code>UiSettingsServiceSetup</code> | [UiSettingsServiceSetup](./kibana-plugin-core-server.uisettingsservicesetup.md) |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [CoreSetup](./kibana-plugin-core-server.coresetup.md) &gt; [metrics](./kibana-plugin-core-server.coresetup.metrics.md)
## CoreSetup.metrics property
[MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md)
<b>Signature:</b>
```typescript
metrics: MetricsServiceSetup;
```

View file

@ -20,7 +20,7 @@ export interface CoreStart
| [capabilities](./kibana-plugin-core-server.corestart.capabilities.md) | <code>CapabilitiesStart</code> | [CapabilitiesStart](./kibana-plugin-core-server.capabilitiesstart.md) |
| [elasticsearch](./kibana-plugin-core-server.corestart.elasticsearch.md) | <code>ElasticsearchServiceStart</code> | [ElasticsearchServiceStart](./kibana-plugin-core-server.elasticsearchservicestart.md) |
| [http](./kibana-plugin-core-server.corestart.http.md) | <code>HttpServiceStart</code> | [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) |
| [metrics](./kibana-plugin-core-server.corestart.metrics.md) | <code>MetricsServiceStart</code> | |
| [metrics](./kibana-plugin-core-server.corestart.metrics.md) | <code>MetricsServiceStart</code> | [MetricsServiceStart](./kibana-plugin-core-server.metricsservicestart.md) |
| [savedObjects](./kibana-plugin-core-server.corestart.savedobjects.md) | <code>SavedObjectsServiceStart</code> | [SavedObjectsServiceStart](./kibana-plugin-core-server.savedobjectsservicestart.md) |
| [uiSettings](./kibana-plugin-core-server.corestart.uisettings.md) | <code>UiSettingsServiceStart</code> | [UiSettingsServiceStart](./kibana-plugin-core-server.uisettingsservicestart.md) |

View file

@ -4,6 +4,7 @@
## CoreStart.metrics property
[MetricsServiceStart](./kibana-plugin-core-server.metricsservicestart.md)
<b>Signature:</b>

View file

@ -253,6 +253,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [LegacyElasticsearchClientConfig](./kibana-plugin-core-server.legacyelasticsearchclientconfig.md) | |
| [LifecycleResponseFactory](./kibana-plugin-core-server.lifecycleresponsefactory.md) | Creates an object containing redirection or error response with error details, HTTP headers, and other data transmitted to the client. |
| [LoggerConfigType](./kibana-plugin-core-server.loggerconfigtype.md) | |
| [MetricsServiceStart](./kibana-plugin-core-server.metricsservicestart.md) | APIs to retrieves metrics gathered and exposed by the core platform. |
| [MIGRATION\_ASSISTANCE\_INDEX\_ACTION](./kibana-plugin-core-server.migration_assistance_index_action.md) | |
| [MIGRATION\_DEPRECATION\_LEVEL](./kibana-plugin-core-server.migration_deprecation_level.md) | |
| [MutatingOperationRefreshSetting](./kibana-plugin-core-server.mutatingoperationrefreshsetting.md) | Elasticsearch Refresh setting for mutating operation |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [MetricsServiceStart](./kibana-plugin-core-server.metricsservicestart.md)
## MetricsServiceStart type
APIs to retrieves metrics gathered and exposed by the core platform.
<b>Signature:</b>
```typescript
export declare type MetricsServiceStart = MetricsServiceSetup;
```

View file

@ -13,18 +13,22 @@ ServiceStatusLevels: Readonly<{
available: Readonly<{
toString: () => "available";
valueOf: () => 0;
toJSON: () => "available";
}>;
degraded: Readonly<{
toString: () => "degraded";
valueOf: () => 1;
toJSON: () => "degraded";
}>;
unavailable: Readonly<{
toString: () => "unavailable";
valueOf: () => 2;
toJSON: () => "unavailable";
}>;
critical: Readonly<{
toString: () => "critical";
valueOf: () => 3;
toJSON: () => "critical";
}>;
}>
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) &gt; [isStatusPageAnonymous](./kibana-plugin-core-server.statusservicesetup.isstatuspageanonymous.md)
## StatusServiceSetup.isStatusPageAnonymous property
Whether or not the status HTTP APIs are available to unauthenticated users when an authentication provider is present.
<b>Signature:</b>
```typescript
isStatusPageAnonymous: () => boolean;
```

View file

@ -74,6 +74,7 @@ core.status.set(
| [core$](./kibana-plugin-core-server.statusservicesetup.core_.md) | <code>Observable&lt;CoreStatus&gt;</code> | Current status for all Core services. |
| [dependencies$](./kibana-plugin-core-server.statusservicesetup.dependencies_.md) | <code>Observable&lt;Record&lt;string, ServiceStatus&gt;&gt;</code> | Current status for all plugins this plugin depends on. Each key of the <code>Record</code> is a plugin id. |
| [derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md) | <code>Observable&lt;ServiceStatus&gt;</code> | The status of this plugin as derived from its dependencies. |
| [isStatusPageAnonymous](./kibana-plugin-core-server.statusservicesetup.isstatuspageanonymous.md) | <code>() =&gt; boolean</code> | Whether or not the status HTTP APIs are available to unauthenticated users when an authentication provider is present. |
| [overall$](./kibana-plugin-core-server.statusservicesetup.overall_.md) | <code>Observable&lt;ServiceStatus&gt;</code> | Overall system status for all of Kibana. |
## Methods

View file

@ -72,7 +72,7 @@ export function mapNodesVersionCompatibility(
kibanaVersion: string,
ignoreVersionMismatch: boolean
): NodesVersionCompatibility {
if (Object.keys(nodesInfo.nodes).length === 0) {
if (Object.keys(nodesInfo.nodes ?? {}).length === 0) {
return {
isCompatible: false,
message: 'Unable to retrieve version information from Elasticsearch nodes.',

View file

@ -60,7 +60,7 @@ import {
SavedObjectsServiceStart,
} from './saved_objects';
import { CapabilitiesSetup, CapabilitiesStart } from './capabilities';
import { MetricsServiceStart } from './metrics';
import { MetricsServiceSetup, MetricsServiceStart } from './metrics';
import { StatusServiceSetup } from './status';
import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail';
import { AppenderConfigType, appendersSchema, LoggingServiceSetup } from './logging';
@ -320,6 +320,7 @@ export {
OpsServerMetrics,
OpsProcessMetrics,
MetricsServiceSetup,
MetricsServiceStart,
} from './metrics';
export { DEFAULT_APP_CATEGORIES } from '../utils';
@ -414,6 +415,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
};
/** {@link LoggingServiceSetup} */
logging: LoggingServiceSetup;
/** {@link MetricsServiceSetup} */
metrics: MetricsServiceSetup;
/** {@link SavedObjectsServiceSetup} */
savedObjects: SavedObjectsServiceSetup;
/** {@link StatusServiceSetup} */

View file

@ -33,7 +33,7 @@ import {
} from './saved_objects';
import { InternalUiSettingsServiceSetup, InternalUiSettingsServiceStart } from './ui_settings';
import { InternalEnvironmentServiceSetup } from './environment';
import { InternalMetricsServiceStart } from './metrics';
import { InternalMetricsServiceSetup, InternalMetricsServiceStart } from './metrics';
import { InternalRenderingServiceSetup } from './rendering';
import { InternalHttpResourcesSetup } from './http_resources';
import { InternalStatusServiceSetup } from './status';
@ -54,6 +54,7 @@ export interface InternalCoreSetup {
httpResources: InternalHttpResourcesSetup;
auditTrail: AuditTrailSetup;
logging: InternalLoggingServiceSetup;
metrics: InternalMetricsServiceSetup;
}
/**

View file

@ -51,6 +51,7 @@ import { coreMock } from '../mocks';
import { statusServiceMock } from '../status/status_service.mock';
import { auditTrailServiceMock } from '../audit_trail/audit_trail_service.mock';
import { loggingServiceMock } from '../logging/logging_service.mock';
import { metricsServiceMock } from '../metrics/metrics_service.mock';
const MockKbnServer: jest.Mock<KbnServer> = KbnServer as any;
@ -99,6 +100,7 @@ beforeEach(() => {
status: statusServiceMock.createInternalSetupContract(),
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createInternalSetupContract(),
metrics: metricsServiceMock.createInternalSetupContract(),
},
plugins: { 'plugin-id': 'plugin-value' },
uiPlugins: {

View file

@ -302,6 +302,10 @@ export class LegacyService implements CoreService {
logging: {
configure: (config$) => setupDeps.core.logging.configure([], config$),
},
metrics: {
collectionInterval: setupDeps.core.metrics.collectionInterval,
getOpsMetrics$: setupDeps.core.metrics.getOpsMetrics$,
},
savedObjects: {
setClientFactoryProvider: setupDeps.core.savedObjects.setClientFactoryProvider,
addClientWrapper: setupDeps.core.savedObjects.addClientWrapper,
@ -309,6 +313,7 @@ export class LegacyService implements CoreService {
getImportExportObjectLimit: setupDeps.core.savedObjects.getImportExportObjectLimit,
},
status: {
isStatusPageAnonymous: setupDeps.core.status.isStatusPageAnonymous,
core$: setupDeps.core.status.core$,
overall$: setupDeps.core.status.overall$,
set: () => {

View file

@ -78,8 +78,8 @@ type MetricsServiceContract = PublicMethodsOf<MetricsService>;
const createMock = () => {
const mocked: jest.Mocked<MetricsServiceContract> = {
setup: jest.fn().mockReturnValue(createInternalSetupContractMock()),
start: jest.fn().mockReturnValue(createInternalStartContractMock()),
setup: jest.fn().mockReturnValue(createSetupContractMock()),
start: jest.fn().mockReturnValue(createStartContractMock()),
stop: jest.fn(),
};
return mocked;

View file

@ -52,6 +52,7 @@ export { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_object
export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';
export { metricsServiceMock } from './metrics/metrics_service.mock';
export { renderingMock } from './rendering/rendering_service.mock';
export { statusServiceMock } from './status/status_service.mock';
export { contextServiceMock } from './context/context_service.mock';
export function pluginInitializerContextConfigMock<T>(config: T) {
@ -138,6 +139,7 @@ function createCoreSetupMock({
uiSettings: uiSettingsMock,
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createSetupContract(),
metrics: metricsServiceMock.createSetupContract(),
getStartServices: jest
.fn<Promise<[ReturnType<typeof createCoreStartMock>, object, any]>, []>()
.mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]),
@ -174,6 +176,7 @@ function createInternalCoreSetupMock() {
uiSettings: uiSettingsServiceMock.createSetupContract(),
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createInternalSetupContract(),
metrics: metricsServiceMock.createInternalSetupContract(),
};
return setupDeps;
}
@ -183,7 +186,7 @@ function createInternalCoreStartMock() {
capabilities: capabilitiesServiceMock.createStartContract(),
elasticsearch: elasticsearchServiceMock.createInternalStart(),
http: httpServiceMock.createInternalStartContract(),
metrics: metricsServiceMock.createStartContract(),
metrics: metricsServiceMock.createInternalStartContract(),
savedObjects: savedObjectsServiceMock.createInternalStartContract(),
uiSettings: uiSettingsServiceMock.createStartContract(),
auditTrail: auditTrailServiceMock.createStartContract(),

View file

@ -179,6 +179,10 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
logging: {
configure: (config$) => deps.logging.configure(['plugins', plugin.name], config$),
},
metrics: {
collectionInterval: deps.metrics.collectionInterval,
getOpsMetrics$: deps.metrics.getOpsMetrics$,
},
savedObjects: {
setClientFactoryProvider: deps.savedObjects.setClientFactoryProvider,
addClientWrapper: deps.savedObjects.addClientWrapper,
@ -191,6 +195,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
set: deps.status.plugins.set.bind(null, plugin.name),
dependencies$: deps.status.plugins.getDependenciesStatus$(plugin.name),
derivedStatus$: deps.status.plugins.getDerivedStatus$(plugin.name),
isStatusPageAnonymous: deps.status.isStatusPageAnonymous,
},
uiSettings: {
register: deps.uiSettings.register,

View file

@ -416,6 +416,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
// (undocumented)
logging: LoggingServiceSetup;
// (undocumented)
metrics: MetricsServiceSetup;
// (undocumented)
savedObjects: SavedObjectsServiceSetup;
// (undocumented)
status: StatusServiceSetup;
@ -433,9 +435,6 @@ export interface CoreStart {
elasticsearch: ElasticsearchServiceStart;
// (undocumented)
http: HttpServiceStart;
// Warning: (ae-forgotten-export) The symbol "MetricsServiceStart" needs to be exported by the entry point index.d.ts
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "MetricsServiceStart"
//
// (undocumented)
metrics: MetricsServiceStart;
// (undocumented)
@ -1431,6 +1430,9 @@ export interface MetricsServiceSetup {
getOpsMetrics$: () => Observable<OpsMetrics>;
}
// @public
export type MetricsServiceStart = MetricsServiceSetup;
// @public @deprecated (undocumented)
export type MIGRATION_ASSISTANCE_INDEX_ACTION = 'upgrade' | 'reindex';
@ -2589,18 +2591,22 @@ export const ServiceStatusLevels: Readonly<{
available: Readonly<{
toString: () => "available";
valueOf: () => 0;
toJSON: () => "available";
}>;
degraded: Readonly<{
toString: () => "degraded";
valueOf: () => 1;
toJSON: () => "degraded";
}>;
unavailable: Readonly<{
toString: () => "unavailable";
valueOf: () => 2;
toJSON: () => "unavailable";
}>;
critical: Readonly<{
toString: () => "critical";
valueOf: () => 3;
toJSON: () => "critical";
}>;
}>;
@ -2676,6 +2682,7 @@ export interface StatusServiceSetup {
dependencies$: Observable<Record<string, ServiceStatus>>;
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "StatusSetup"
derivedStatus$: Observable<ServiceStatus>;
isStatusPageAnonymous: () => boolean;
overall$: Observable<ServiceStatus>;
set(status$: Observable<ServiceStatus>): void;
}

View file

@ -152,12 +152,15 @@ export class Server {
savedObjects: savedObjectsSetup,
});
await this.metrics.setup({ http: httpSetup });
const metricsSetup = await this.metrics.setup({ http: httpSetup });
const statusSetup = await this.status.setup({
elasticsearch: elasticsearchServiceSetup,
pluginDependencies: pluginTree.asNames,
savedObjects: savedObjectsSetup,
environment: environmentSetup,
http: httpSetup,
metrics: metricsSetup,
});
const renderingSetup = await this.rendering.setup({
@ -189,6 +192,7 @@ export class Server {
httpResources: httpResourcesSetup,
auditTrail: auditTrailSetup,
logging: loggingSetup,
metrics: metricsSetup,
};
const pluginsSetup = await this.plugins.setup(coreSetup);

View file

@ -0,0 +1,114 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ServiceStatus, ServiceStatusLevels } from './types';
import { calculateLegacyStatus } from './legacy_status';
const available: ServiceStatus = { level: ServiceStatusLevels.available, summary: 'Available' };
const degraded: ServiceStatus = {
level: ServiceStatusLevels.degraded,
summary: 'This is degraded!',
};
const unavailable: ServiceStatus = {
level: ServiceStatusLevels.unavailable,
summary: 'This is unavailable!',
};
const critical: ServiceStatus = {
level: ServiceStatusLevels.critical,
summary: 'This is critical!',
};
describe('calculateLegacyStatus', () => {
it('translates the overall status to the legacy format', () => {
const legacyStatus = calculateLegacyStatus({
overall: available,
core: {} as any,
plugins: {},
versionWithoutSnapshot: '1.1.1',
});
expect(legacyStatus.overall).toEqual({
state: 'green',
title: 'Green',
nickname: 'Looking good',
icon: 'success',
uiColor: 'secondary',
since: expect.any(String),
});
});
it('combines core and plugins statuses into statuses array in legacy format', () => {
const legacyStatus = calculateLegacyStatus({
overall: available,
core: {
elasticsearch: degraded,
savedObjects: critical,
},
plugins: {
a: available,
b: unavailable,
c: degraded,
},
versionWithoutSnapshot: '1.1.1',
});
expect(legacyStatus.statuses).toEqual([
{
icon: 'warning',
id: 'core:elasticsearch@1.1.1',
message: 'This is degraded!',
since: expect.any(String),
state: 'yellow',
uiColor: 'warning',
},
{
icon: 'danger',
id: 'core:savedObjects@1.1.1',
message: 'This is critical!',
since: expect.any(String),
state: 'red',
uiColor: 'danger',
},
{
icon: 'success',
id: 'plugin:a@1.1.1',
message: 'Available',
since: expect.any(String),
state: 'green',
uiColor: 'secondary',
},
{
icon: 'danger',
id: 'plugin:b@1.1.1',
message: 'This is unavailable!',
since: expect.any(String),
state: 'red',
uiColor: 'danger',
},
{
icon: 'warning',
id: 'plugin:c@1.1.1',
message: 'This is degraded!',
since: expect.any(String),
state: 'yellow',
uiColor: 'warning',
},
]);
});
});

View file

@ -0,0 +1,158 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { pick } from 'lodash';
import { i18n } from '@kbn/i18n';
import { deepFreeze } from '@kbn/std';
import { ServiceStatusLevels, ServiceStatus, CoreStatus } from './types';
import { PluginName } from '../plugins';
interface Deps {
overall: ServiceStatus;
core: CoreStatus;
plugins: Record<PluginName, ServiceStatus>;
versionWithoutSnapshot: string;
}
export interface LegacyStatusInfo {
overall: LegacyStatusOverall;
statuses: StatusComponentHttp[];
}
interface LegacyStatusOverall {
state: LegacyStatusState;
title: string;
nickname: string;
uiColor: LegacyStatusUiColor;
/** ISO-8601 date string w/o timezone */
since: string;
icon?: string;
}
type LegacyStatusState = 'green' | 'yellow' | 'red';
type LegacyStatusIcon = 'danger' | 'warning' | 'success';
type LegacyStatusUiColor = 'secondary' | 'warning' | 'danger';
interface LegacyStateAttr {
id: LegacyStatusState;
state: LegacyStatusState;
title: string;
icon: LegacyStatusIcon;
uiColor: LegacyStatusUiColor;
nickname: string;
}
export const calculateLegacyStatus = ({
core,
overall,
plugins,
versionWithoutSnapshot,
}: Deps): LegacyStatusInfo => {
const since = new Date().toISOString();
const overallLegacy: LegacyStatusOverall = {
since,
...pick(STATUS_LEVEL_LEGACY_ATTRS[overall.level.toString()], [
'state',
'title',
'nickname',
'icon',
'uiColor',
]),
};
const coreStatuses = Object.entries(core).map(([serviceName, s]) =>
serviceStatusToHttpComponent(`core:${serviceName}@${versionWithoutSnapshot}`, s, since)
);
const pluginStatuses = Object.entries(plugins).map(([pluginName, s]) =>
serviceStatusToHttpComponent(`plugin:${pluginName}@${versionWithoutSnapshot}`, s, since)
);
const componentStatuses: StatusComponentHttp[] = [...coreStatuses, ...pluginStatuses];
return {
overall: overallLegacy,
statuses: componentStatuses,
};
};
interface StatusComponentHttp {
id: string;
state: LegacyStatusState;
message: string;
uiColor: LegacyStatusUiColor;
icon: string;
since: string;
}
const serviceStatusToHttpComponent = (
serviceName: string,
status: ServiceStatus,
since: string
): StatusComponentHttp => ({
id: serviceName,
message: status.summary,
since,
...serviceStatusAttrs(status),
});
const serviceStatusAttrs = (status: ServiceStatus) =>
pick(STATUS_LEVEL_LEGACY_ATTRS[status.level.toString()], ['state', 'icon', 'uiColor']);
const STATUS_LEVEL_LEGACY_ATTRS = deepFreeze<Record<string, LegacyStateAttr>>({
[ServiceStatusLevels.critical.toString()]: {
id: 'red',
state: 'red',
title: i18n.translate('core.status.redTitle', {
defaultMessage: 'Red',
}),
icon: 'danger',
uiColor: 'danger',
nickname: 'Danger Will Robinson! Danger!',
},
[ServiceStatusLevels.unavailable.toString()]: {
id: 'red',
state: 'red',
title: i18n.translate('core.status.redTitle', {
defaultMessage: 'Red',
}),
icon: 'danger',
uiColor: 'danger',
nickname: 'Danger Will Robinson! Danger!',
},
[ServiceStatusLevels.degraded.toString()]: {
id: 'yellow',
state: 'yellow',
title: i18n.translate('core.status.yellowTitle', {
defaultMessage: 'Yellow',
}),
icon: 'warning',
uiColor: 'warning',
nickname: "I'll be back",
},
[ServiceStatusLevels.available.toString()]: {
id: 'green',
state: 'green',
title: i18n.translate('core.status.greenTitle', {
defaultMessage: 'Green',
}),
icon: 'success',
uiColor: 'secondary',
nickname: 'Looking good',
},
});

View file

@ -17,4 +17,4 @@
* under the License.
*/
export { getKibanaInfoForStats } from './get_kibana_info_for_stats';
export { registerStatusRoute } from './status';

View file

@ -0,0 +1,322 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { BehaviorSubject } from 'rxjs';
import { first } from 'rxjs/operators';
import supertest from 'supertest';
import { omit } from 'lodash';
import { createCoreContext, createHttpServer } from '../../../http/test_utils';
import { ContextService } from '../../../context';
import { metricsServiceMock } from '../../../metrics/metrics_service.mock';
import { MetricsServiceSetup } from '../../../metrics';
import { HttpService, InternalHttpServiceSetup } from '../../../http';
import { registerStatusRoute } from '../status';
import { ServiceStatus, ServiceStatusLevels } from '../../types';
import { statusServiceMock } from '../../status_service.mock';
const coreId = Symbol('core');
describe('GET /api/status', () => {
let server: HttpService;
let httpSetup: InternalHttpServiceSetup;
let metrics: jest.Mocked<MetricsServiceSetup>;
const setupServer = async ({ allowAnonymous = true }: { allowAnonymous?: boolean } = {}) => {
const coreContext = createCoreContext({ coreId });
const contextService = new ContextService(coreContext);
server = createHttpServer(coreContext);
httpSetup = await server.setup({
context: contextService.setup({ pluginDependencies: new Map() }),
});
metrics = metricsServiceMock.createSetupContract();
const status = statusServiceMock.createSetupContract();
const pluginsStatus$ = new BehaviorSubject<Record<string, ServiceStatus>>({
a: { level: ServiceStatusLevels.available, summary: 'a is available' },
b: { level: ServiceStatusLevels.degraded, summary: 'b is degraded' },
c: { level: ServiceStatusLevels.unavailable, summary: 'c is unavailable' },
d: { level: ServiceStatusLevels.critical, summary: 'd is critical' },
});
const router = httpSetup.createRouter('');
registerStatusRoute({
router,
config: {
allowAnonymous,
packageInfo: {
branch: 'xbranch',
buildNum: 1234,
buildSha: 'xsha',
dist: true,
version: '9.9.9-SNAPSHOT',
},
serverName: 'xkibana',
uuid: 'xxxx-xxxxx',
},
metrics,
status: {
overall$: status.overall$,
core$: status.core$,
plugins$: pluginsStatus$,
},
});
// Register dummy auth provider for testing auth
httpSetup.registerAuth((req, res, auth) => {
if (req.headers.authorization === 'let me in') {
return auth.authenticated();
} else {
return auth.notHandled();
}
});
await server.start();
};
afterEach(async () => {
await server.stop();
});
describe('allowAnonymous: false', () => {
it('rejects requests with no credentials', async () => {
await setupServer({ allowAnonymous: false });
await supertest(httpSetup.server.listener).get('/api/status').expect(401);
});
it('rejects requests with bad credentials', async () => {
await setupServer({ allowAnonymous: false });
await supertest(httpSetup.server.listener)
.get('/api/status')
.set('Authorization', 'fake creds')
.expect(401);
});
it('accepts authenticated requests', async () => {
await setupServer({ allowAnonymous: false });
await supertest(httpSetup.server.listener)
.get('/api/status')
.set('Authorization', 'let me in')
.expect(200);
});
});
it('returns basic server info & metrics', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener).get('/api/status').expect(200);
expect(result.body.name).toEqual('xkibana');
expect(result.body.uuid).toEqual('xxxx-xxxxx');
expect(result.body.version).toEqual({
number: '9.9.9',
build_hash: 'xsha',
build_number: 1234,
build_snapshot: true,
});
const metricsMockValue = await metrics.getOpsMetrics$().pipe(first()).toPromise();
expect(result.body.metrics).toEqual({
last_updated: expect.any(String),
collection_interval_in_millis: metrics.collectionInterval,
...omit(metricsMockValue, ['collected_at']),
requests: {
...metricsMockValue.requests,
status_codes: metricsMockValue.requests.statusCodes,
},
});
});
describe('legacy status format', () => {
it('returns legacy status format when no query params provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener).get('/api/status').expect(200);
expect(result.body.status).toEqual({
overall: {
icon: 'success',
nickname: 'Looking good',
since: expect.any(String),
state: 'green',
title: 'Green',
uiColor: 'secondary',
},
statuses: [
{
icon: 'success',
id: 'core:elasticsearch@9.9.9',
message: 'Service is working',
since: expect.any(String),
state: 'green',
uiColor: 'secondary',
},
{
icon: 'success',
id: 'core:savedObjects@9.9.9',
message: 'Service is working',
since: expect.any(String),
state: 'green',
uiColor: 'secondary',
},
{
icon: 'success',
id: 'plugin:a@9.9.9',
message: 'a is available',
since: expect.any(String),
state: 'green',
uiColor: 'secondary',
},
{
icon: 'warning',
id: 'plugin:b@9.9.9',
message: 'b is degraded',
since: expect.any(String),
state: 'yellow',
uiColor: 'warning',
},
{
icon: 'danger',
id: 'plugin:c@9.9.9',
message: 'c is unavailable',
since: expect.any(String),
state: 'red',
uiColor: 'danger',
},
{
icon: 'danger',
id: 'plugin:d@9.9.9',
message: 'd is critical',
since: expect.any(String),
state: 'red',
uiColor: 'danger',
},
],
});
});
it('returns legacy status format when v8format=false is provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener)
.get('/api/status?v8format=false')
.expect(200);
expect(result.body.status).toEqual({
overall: {
icon: 'success',
nickname: 'Looking good',
since: expect.any(String),
state: 'green',
title: 'Green',
uiColor: 'secondary',
},
statuses: [
{
icon: 'success',
id: 'core:elasticsearch@9.9.9',
message: 'Service is working',
since: expect.any(String),
state: 'green',
uiColor: 'secondary',
},
{
icon: 'success',
id: 'core:savedObjects@9.9.9',
message: 'Service is working',
since: expect.any(String),
state: 'green',
uiColor: 'secondary',
},
{
icon: 'success',
id: 'plugin:a@9.9.9',
message: 'a is available',
since: expect.any(String),
state: 'green',
uiColor: 'secondary',
},
{
icon: 'warning',
id: 'plugin:b@9.9.9',
message: 'b is degraded',
since: expect.any(String),
state: 'yellow',
uiColor: 'warning',
},
{
icon: 'danger',
id: 'plugin:c@9.9.9',
message: 'c is unavailable',
since: expect.any(String),
state: 'red',
uiColor: 'danger',
},
{
icon: 'danger',
id: 'plugin:d@9.9.9',
message: 'd is critical',
since: expect.any(String),
state: 'red',
uiColor: 'danger',
},
],
});
});
});
describe('v8format', () => {
it('returns new status format when v8format=true is provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener)
.get('/api/status?v8format=true')
.expect(200);
expect(result.body.status).toEqual({
core: {
elasticsearch: {
level: 'available',
summary: 'Service is working',
},
savedObjects: {
level: 'available',
summary: 'Service is working',
},
},
overall: {
level: 'available',
summary: 'Service is working',
},
plugins: {
a: {
level: 'available',
summary: 'a is available',
},
b: {
level: 'degraded',
summary: 'b is degraded',
},
c: {
level: 'unavailable',
summary: 'c is unavailable',
},
d: {
level: 'critical',
summary: 'd is critical',
},
},
});
});
});
});

View file

@ -0,0 +1,177 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Observable, combineLatest, ReplaySubject } from 'rxjs';
import { first } from 'rxjs/operators';
import { schema } from '@kbn/config-schema';
import { IRouter } from '../../http';
import { MetricsServiceSetup } from '../../metrics';
import { ServiceStatus, CoreStatus } from '../types';
import { PluginName } from '../../plugins';
import { calculateLegacyStatus, LegacyStatusInfo } from '../legacy_status';
import { PackageInfo } from '../../config';
const SNAPSHOT_POSTFIX = /-SNAPSHOT$/;
interface Deps {
router: IRouter;
config: {
allowAnonymous: boolean;
packageInfo: PackageInfo;
serverName: string;
uuid: string;
};
metrics: MetricsServiceSetup;
status: {
overall$: Observable<ServiceStatus>;
core$: Observable<CoreStatus>;
plugins$: Observable<Record<PluginName, ServiceStatus>>;
};
}
interface StatusInfo {
overall: ServiceStatus;
core: CoreStatus;
plugins: Record<string, ServiceStatus>;
}
interface StatusHttpBody {
name: string;
uuid: string;
version: {
number: string;
build_hash: string;
build_number: number;
build_snapshot: boolean;
};
status: StatusInfo | LegacyStatusInfo;
metrics: {
/** ISO-8601 date string w/o timezone */
last_updated: string;
collection_interval_in_millis: number;
process: {
memory: {
heap: {
total_in_bytes: number;
used_in_bytes: number;
size_limit: number;
};
resident_set_size_in_bytes: number;
};
event_loop_delay: number;
pid: number;
uptime_in_millis: number;
};
os: {
load: Record<string, number>;
memory: {
total_in_bytes: number;
used_in_bytes: number;
free_in_bytes: number;
};
uptime_in_millis: number;
platform: string;
platformRelease: string;
};
response_times: {
max_in_millis: number;
};
requests: {
total: number;
disconnects: number;
statusCodes: Record<number, number>;
status_codes: Record<number, number>;
};
concurrent_connections: number;
};
}
export const registerStatusRoute = ({ router, config, metrics, status }: Deps) => {
// Since the status.plugins$ observable is not subscribed to elsewhere, we need to subscribe it here to eagerly load
// the plugins status when Kibana starts up so this endpoint responds quickly on first boot.
const combinedStatus$ = new ReplaySubject<
[ServiceStatus<unknown>, CoreStatus, Record<string, ServiceStatus<unknown>>]
>(1);
combineLatest([status.overall$, status.core$, status.plugins$]).subscribe(combinedStatus$);
router.get(
{
path: '/api/status',
options: {
authRequired: !config.allowAnonymous,
tags: ['api'], // ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page
},
validate: {
query: schema.object({
v8format: schema.boolean({ defaultValue: false }),
}),
},
},
async (context, req, res) => {
const { version, buildSha, buildNum } = config.packageInfo;
const versionWithoutSnapshot = version.replace(SNAPSHOT_POSTFIX, '');
const [overall, core, plugins] = await combinedStatus$.pipe(first()).toPromise();
let statusInfo: StatusInfo | LegacyStatusInfo;
if (req.query?.v8format) {
statusInfo = {
overall,
core,
plugins,
};
} else {
statusInfo = calculateLegacyStatus({
overall,
core,
plugins,
versionWithoutSnapshot,
});
}
const lastMetrics = await metrics.getOpsMetrics$().pipe(first()).toPromise();
const body: StatusHttpBody = {
name: config.serverName,
uuid: config.uuid,
version: {
number: versionWithoutSnapshot,
build_hash: buildSha,
build_number: buildNum,
build_snapshot: SNAPSHOT_POSTFIX.test(version),
},
status: statusInfo,
metrics: {
last_updated: lastMetrics.collected_at.toISOString(),
collection_interval_in_millis: metrics.collectionInterval,
os: lastMetrics.os,
process: lastMetrics.process,
response_times: lastMetrics.response_times,
concurrent_connections: lastMetrics.concurrent_connections,
requests: {
...lastMetrics.requests,
status_codes: lastMetrics.requests.statusCodes,
},
},
};
return res.ok({ body });
}
);
};

View file

@ -43,6 +43,7 @@ const createSetupContractMock = () => {
set: jest.fn(),
dependencies$: new BehaviorSubject({}),
derivedStatus$: new BehaviorSubject(available),
isStatusPageAnonymous: jest.fn().mockReturnValue(false),
};
return setupContract;

View file

@ -24,6 +24,9 @@ import { StatusService } from './status_service';
import { first } from 'rxjs/operators';
import { mockCoreContext } from '../core_context.mock';
import { ServiceStatusLevelSnapshotSerializer } from './test_utils';
import { environmentServiceMock } from '../environment/environment_service.mock';
import { httpServiceMock } from '../http/http_service.mock';
import { metricsServiceMock } from '../metrics/metrics_service.mock';
expect.addSnapshotSerializer(ServiceStatusLevelSnapshotSerializer);
@ -44,18 +47,36 @@ describe('StatusService', () => {
summary: 'This is degraded!',
};
type SetupDeps = Parameters<StatusService['setup']>[0];
const setupDeps = (overrides: Partial<SetupDeps>): SetupDeps => {
return {
elasticsearch: {
status$: of(available),
},
savedObjects: {
status$: of(available),
},
pluginDependencies: new Map(),
environment: environmentServiceMock.createSetupContract(),
http: httpServiceMock.createInternalSetupContract(),
metrics: metricsServiceMock.createInternalSetupContract(),
...overrides,
};
};
describe('setup', () => {
describe('core$', () => {
it('rolls up core status observables into single observable', async () => {
const setup = await service.setup({
elasticsearch: {
status$: of(available),
},
savedObjects: {
status$: of(degraded),
},
pluginDependencies: new Map(),
});
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: of(available),
},
savedObjects: {
status$: of(degraded),
},
})
);
expect(await setup.core$.pipe(first()).toPromise()).toEqual({
elasticsearch: available,
savedObjects: degraded,
@ -63,15 +84,16 @@ describe('StatusService', () => {
});
it('replays last event', async () => {
const setup = await service.setup({
elasticsearch: {
status$: of(available),
},
savedObjects: {
status$: of(degraded),
},
pluginDependencies: new Map(),
});
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: of(available),
},
savedObjects: {
status$: of(degraded),
},
})
);
const subResult1 = await setup.core$.pipe(first()).toPromise();
const subResult2 = await setup.core$.pipe(first()).toPromise();
const subResult3 = await setup.core$.pipe(first()).toPromise();
@ -92,15 +114,16 @@ describe('StatusService', () => {
it('does not emit duplicate events', async () => {
const elasticsearch$ = new BehaviorSubject(available);
const savedObjects$ = new BehaviorSubject(degraded);
const setup = await service.setup({
elasticsearch: {
status$: elasticsearch$,
},
savedObjects: {
status$: savedObjects$,
},
pluginDependencies: new Map(),
});
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: elasticsearch$,
},
savedObjects: {
status$: savedObjects$,
},
})
);
const statusUpdates: CoreStatus[] = [];
const subscription = setup.core$.subscribe((status) => statusUpdates.push(status));
@ -155,15 +178,16 @@ describe('StatusService', () => {
describe('overall$', () => {
it('exposes an overall summary', async () => {
const setup = await service.setup({
elasticsearch: {
status$: of(degraded),
},
savedObjects: {
status$: of(degraded),
},
pluginDependencies: new Map(),
});
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: of(degraded),
},
savedObjects: {
status$: of(degraded),
},
})
);
expect(await setup.overall$.pipe(first()).toPromise()).toMatchObject({
level: ServiceStatusLevels.degraded,
summary: '[2] services are degraded',
@ -171,15 +195,16 @@ describe('StatusService', () => {
});
it('replays last event', async () => {
const setup = await service.setup({
elasticsearch: {
status$: of(degraded),
},
savedObjects: {
status$: of(degraded),
},
pluginDependencies: new Map(),
});
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: of(degraded),
},
savedObjects: {
status$: of(degraded),
},
})
);
const subResult1 = await setup.overall$.pipe(first()).toPromise();
const subResult2 = await setup.overall$.pipe(first()).toPromise();
const subResult3 = await setup.overall$.pipe(first()).toPromise();
@ -200,15 +225,16 @@ describe('StatusService', () => {
it('does not emit duplicate events', async () => {
const elasticsearch$ = new BehaviorSubject(available);
const savedObjects$ = new BehaviorSubject(degraded);
const setup = await service.setup({
elasticsearch: {
status$: elasticsearch$,
},
savedObjects: {
status$: savedObjects$,
},
pluginDependencies: new Map(),
});
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: elasticsearch$,
},
savedObjects: {
status$: savedObjects$,
},
})
);
const statusUpdates: ServiceStatus[] = [];
const subscription = setup.overall$.subscribe((status) => statusUpdates.push(status));
@ -256,15 +282,16 @@ describe('StatusService', () => {
it('debounces events in quick succession', async () => {
const savedObjects$ = new BehaviorSubject(available);
const setup = await service.setup({
elasticsearch: {
status$: new BehaviorSubject(available),
},
savedObjects: {
status$: savedObjects$,
},
pluginDependencies: new Map(),
});
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: new BehaviorSubject(available),
},
savedObjects: {
status$: savedObjects$,
},
})
);
const statusUpdates: ServiceStatus[] = [];
const subscription = setup.overall$.subscribe((status) => statusUpdates.push(status));

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { Observable, combineLatest } from 'rxjs';
import { Observable, combineLatest, Subscription } from 'rxjs';
import { map, distinctUntilChanged, shareReplay, take, debounceTime } from 'rxjs/operators';
import { isDeepStrictEqual } from 'util';
@ -25,8 +25,12 @@ import { CoreService } from '../../types';
import { CoreContext } from '../core_context';
import { Logger } from '../logging';
import { InternalElasticsearchServiceSetup } from '../elasticsearch';
import { InternalHttpServiceSetup } from '../http';
import { InternalSavedObjectsServiceSetup } from '../saved_objects';
import { PluginName } from '../plugins';
import { InternalMetricsServiceSetup } from '../metrics';
import { registerStatusRoute } from './routes';
import { InternalEnvironmentServiceSetup } from '../environment';
import { config, StatusConfigType } from './status_config';
import { ServiceStatus, CoreStatus, InternalStatusServiceSetup } from './types';
@ -35,7 +39,10 @@ import { PluginsStatusService } from './plugins_status';
interface SetupDeps {
elasticsearch: Pick<InternalElasticsearchServiceSetup, 'status$'>;
environment: InternalEnvironmentServiceSetup;
pluginDependencies: ReadonlyMap<PluginName, PluginName[]>;
http: InternalHttpServiceSetup;
metrics: InternalMetricsServiceSetup;
savedObjects: Pick<InternalSavedObjectsServiceSetup, 'status$'>;
}
@ -44,13 +51,21 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
private readonly config$: Observable<StatusConfigType>;
private pluginsStatus?: PluginsStatusService;
private overallSubscription?: Subscription;
constructor(coreContext: CoreContext) {
constructor(private readonly coreContext: CoreContext) {
this.logger = coreContext.logger.get('status');
this.config$ = coreContext.configService.atPath<StatusConfigType>(config.path);
}
public async setup({ elasticsearch, pluginDependencies, savedObjects }: SetupDeps) {
public async setup({
elasticsearch,
pluginDependencies,
http,
metrics,
savedObjects,
environment,
}: SetupDeps) {
const statusConfig = await this.config$.pipe(take(1)).toPromise();
const core$ = this.setupCoreStatus({ elasticsearch, savedObjects });
this.pluginsStatus = new PluginsStatusService({ core$, pluginDependencies });
@ -73,6 +88,26 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
shareReplay(1)
);
// Create an unused subscription to ensure all underlying lazy observables are started.
this.overallSubscription = overall$.subscribe();
const router = http.createRouter('');
registerStatusRoute({
router,
config: {
allowAnonymous: statusConfig.allowAnonymous,
packageInfo: this.coreContext.env.packageInfo,
serverName: http.getServerInfo().name,
uuid: environment.instanceUuid,
},
metrics,
status: {
overall$,
plugins$: this.pluginsStatus.getAll$(),
core$,
},
});
return {
core$,
overall$,
@ -87,7 +122,12 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
public start() {}
public stop() {}
public stop() {
if (this.overallSubscription) {
this.overallSubscription.unsubscribe();
this.overallSubscription = undefined;
}
}
private setupCoreStatus({
elasticsearch,

View file

@ -71,6 +71,9 @@ export const ServiceStatusLevels = deepFreeze({
available: {
toString: () => 'available',
valueOf: () => 0,
toJSON() {
return this.toString();
},
},
/**
* Some features may not be working.
@ -78,6 +81,9 @@ export const ServiceStatusLevels = deepFreeze({
degraded: {
toString: () => 'degraded',
valueOf: () => 1,
toJSON() {
return this.toString();
},
},
/**
* The service is unavailable, but other functions that do not depend on this service should work.
@ -85,6 +91,9 @@ export const ServiceStatusLevels = deepFreeze({
unavailable: {
toString: () => 'unavailable',
valueOf: () => 2,
toJSON() {
return this.toString();
},
},
/**
* Block all user functions and display the status page, reserved for Core services only.
@ -92,6 +101,9 @@ export const ServiceStatusLevels = deepFreeze({
critical: {
toString: () => 'critical',
valueOf: () => 3,
toJSON() {
return this.toString();
},
},
});
@ -217,11 +229,17 @@ export interface StatusServiceSetup {
* through the dependency tree
*/
derivedStatus$: Observable<ServiceStatus>;
/**
* Whether or not the status HTTP APIs are available to unauthenticated users when an authentication provider is
* present.
*/
isStatusPageAnonymous: () => boolean;
}
/** @internal */
export interface InternalStatusServiceSetup extends Pick<StatusServiceSetup, 'core$' | 'overall$'> {
isStatusPageAnonymous: () => boolean;
export interface InternalStatusServiceSetup
extends Pick<StatusServiceSetup, 'core$' | 'overall$' | 'isStatusPageAnonymous'> {
// Namespaced under `plugins` key to improve clarity that these are APIs for plugins specifically.
plugins: {
set(plugin: PluginName, status$: Observable<ServiceStatus>): void;

View file

@ -28,7 +28,6 @@ import httpMixin from './http';
import { coreMixin } from './core';
import { loggingMixin } from './logging';
import warningsMixin from './warnings';
import { statusMixin } from './status';
import configCompleteMixin from './config/complete';
import { optimizeMixin } from '../../optimize';
import * as Plugins from './plugins';
@ -90,7 +89,6 @@ export default class KbnServer {
loggingMixin,
warningsMixin,
statusMixin,
// scan translations dirs, register locale files and initialize i18n engine.
i18nMixin,

View file

@ -79,12 +79,7 @@ export class Plugin {
);
}
// Many of the plugins are simply adding static assets to the server and we don't need
// to track their "status". Since plugins must have an init() function to even set its status
// we shouldn't even create a status unless the plugin can use it.
if (this.externalInit) {
this.status = kbnServer.status.createForPlugin(this);
server.expose('status', this.status);
await this.externalInit(server, options);
}
};
@ -93,12 +88,6 @@ export class Plugin {
plugin: { register, name: id, version },
options: config.has(configPrefix) ? config.get(configPrefix) : null,
});
// Only change the plugin status to green if the
// initial status has not been changed
if (this.status && this.status.state === 'uninitialized') {
this.status.green('Ready');
}
}
async postInit() {

View file

@ -1,61 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import ServerStatus from './server_status';
import { Metrics } from './lib/metrics';
import { registerStatusApi, registerStatsApi } from './routes';
import Oppsy from 'oppsy';
import { cloneDeep } from 'lodash';
import { getOSInfo } from './lib/get_os_info';
export function statusMixin(kbnServer, server, config) {
kbnServer.status = new ServerStatus(kbnServer.server);
const { usageCollection } = server.newPlatform.setup.plugins;
const metrics = new Metrics(config, server);
const oppsy = new Oppsy(server);
oppsy.on('ops', (event) => {
// Oppsy has a bad race condition that will modify this data before
// we ship it off to the buffer. Let's create our copy first.
event = cloneDeep(event);
// Oppsy used to provide this, but doesn't anymore. Grab it ourselves.
server.listener.getConnections((_, count) => {
event.concurrent_connections = count;
// captures (performs transforms on) the latest event data and stashes
// the metrics for status/stats API payload
metrics.capture(event).then((data) => {
kbnServer.metrics = data;
});
});
});
oppsy.start(config.get('ops.interval'));
server.events.on('stop', () => {
oppsy.stop();
});
// init routes
registerStatusApi(kbnServer, server, config);
registerStatsApi(usageCollection, server, config, kbnServer);
// expore shared functionality
server.decorate('server', 'getOSInfo', getOSInfo);
}

View file

@ -1,86 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export function cGroups(hierarchy) {
if (!hierarchy) {
hierarchy = Math.random().toString(36).substring(7);
}
const cpuAcctDir = `/sys/fs/cgroup/cpuacct/${hierarchy}`;
const cpuDir = `/sys/fs/cgroup/cpu/${hierarchy}`;
const cGroupContents = [
'10:freezer:/',
'9:net_cls,net_prio:/',
'8:pids:/',
'7:blkio:/',
'6:memory:/',
'5:devices:/user.slice',
'4:hugetlb:/',
'3:perf_event:/',
'2:cpu,cpuacct,cpuset:/' + hierarchy,
'1:name=systemd:/user.slice/user-1000.slice/session-2359.scope',
].join('\n');
const cpuStatContents = ['nr_periods 0', 'nr_throttled 10', 'throttled_time 20'].join('\n');
return {
hierarchy,
cGroupContents,
cpuStatContents,
cpuAcctDir,
cpuDir,
files: {
'/proc/self/cgroup': cGroupContents,
[`${cpuAcctDir}/cpuacct.usage`]: '357753491408',
[`${cpuDir}/cpu.cfs_period_us`]: '100000',
[`${cpuDir}/cpu.cfs_quota_us`]: '5000',
[`${cpuDir}/cpu.stat`]: cpuStatContents,
},
};
}
class FSError extends Error {
constructor(fileName, code) {
super('Stub File System Stub Error: ' + fileName);
this.code = code;
this.stack = null;
}
}
let _mockFiles = Object.create({});
export const setMockFiles = (mockFiles) => {
_mockFiles = Object.create({});
if (mockFiles) {
const files = Object.keys(mockFiles);
for (const file of files) {
_mockFiles[file] = mockFiles[file];
}
}
};
export const readFileMock = (fileName, callback) => {
if (_mockFiles.hasOwnProperty(fileName)) {
callback(null, _mockFiles[fileName]);
} else {
const err = new FSError(fileName, 'ENOENT');
callback(err, null);
}
};

View file

@ -1,36 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { keysToSnakeCaseShallow } from './case_conversion';
describe('keysToSnakeCaseShallow', () => {
test("should convert all of an object's keys to snake case", () => {
const data = {
camelCase: 'camel_case',
'kebab-case': 'kebab_case',
snake_case: 'snake_case',
};
const result = keysToSnakeCaseShallow(data);
expect(result.camel_case).toBe('camel_case');
expect(result.kebab_case).toBe('kebab_case');
expect(result.snake_case).toBe('snake_case');
});
});

View file

@ -1,24 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { mapKeys, snakeCase } from 'lodash';
export function keysToSnakeCaseShallow(object: Record<string, any>) {
return mapKeys(object, (value, key) => snakeCase(key));
}

View file

@ -1,173 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fs from 'fs';
import { promisify } from 'bluebird';
import { join as joinPath } from 'path';
// Logic from elasticsearch/core/src/main/java/org/elasticsearch/monitor/os/OsProbe.java
const CONTROL_GROUP_RE = new RegExp('\\d+:([^:]+):(/.*)');
const CONTROLLER_SEPARATOR_RE = ',';
const PROC_SELF_CGROUP_FILE = '/proc/self/cgroup';
const PROC_CGROUP_CPU_DIR = '/sys/fs/cgroup/cpu';
const PROC_CGROUP_CPUACCT_DIR = '/sys/fs/cgroup/cpuacct';
const GROUP_CPUACCT = 'cpuacct';
const CPUACCT_USAGE_FILE = 'cpuacct.usage';
const GROUP_CPU = 'cpu';
const CPU_FS_PERIOD_US_FILE = 'cpu.cfs_period_us';
const CPU_FS_QUOTA_US_FILE = 'cpu.cfs_quota_us';
const CPU_STATS_FILE = 'cpu.stat';
const readFile = promisify(fs.readFile);
export function readControlGroups() {
return readFile(PROC_SELF_CGROUP_FILE).then((data) => {
const response = {};
data
.toString()
.split(/\n/)
.forEach((line) => {
const matches = line.match(CONTROL_GROUP_RE);
if (matches === null) {
return;
}
const controllers = matches[1].split(CONTROLLER_SEPARATOR_RE);
controllers.forEach((controller) => {
response[controller] = matches[2];
});
});
return response;
});
}
function fileContentsToInteger(path) {
return readFile(path).then((data) => {
return parseInt(data.toString(), 10);
});
}
function readCPUAcctUsage(controlGroup) {
return fileContentsToInteger(joinPath(PROC_CGROUP_CPUACCT_DIR, controlGroup, CPUACCT_USAGE_FILE));
}
function readCPUFsPeriod(controlGroup) {
return fileContentsToInteger(joinPath(PROC_CGROUP_CPU_DIR, controlGroup, CPU_FS_PERIOD_US_FILE));
}
function readCPUFsQuota(controlGroup) {
return fileContentsToInteger(joinPath(PROC_CGROUP_CPU_DIR, controlGroup, CPU_FS_QUOTA_US_FILE));
}
export function readCPUStat(controlGroup) {
return new Promise((resolve, reject) => {
const stat = {
number_of_elapsed_periods: -1,
number_of_times_throttled: -1,
time_throttled_nanos: -1,
};
readFile(joinPath(PROC_CGROUP_CPU_DIR, controlGroup, CPU_STATS_FILE))
.then((data) => {
data
.toString()
.split(/\n/)
.forEach((line) => {
const fields = line.split(/\s+/);
switch (fields[0]) {
case 'nr_periods':
stat.number_of_elapsed_periods = parseInt(fields[1], 10);
break;
case 'nr_throttled':
stat.number_of_times_throttled = parseInt(fields[1], 10);
break;
case 'throttled_time':
stat.time_throttled_nanos = parseInt(fields[1], 10);
break;
}
});
resolve(stat);
})
.catch((err) => {
if (err.code === 'ENOENT') {
return resolve(stat);
}
reject(err);
});
});
}
export function getAllStats(options = {}) {
return new Promise((resolve, reject) => {
readControlGroups()
.then((groups) => {
const cpuPath = options.cpuPath || groups[GROUP_CPU];
const cpuAcctPath = options.cpuAcctPath || groups[GROUP_CPUACCT];
// prevents undefined cgroup paths
if (!cpuPath || !cpuAcctPath) {
return resolve(null);
}
return Promise.all([
readCPUAcctUsage(cpuAcctPath),
readCPUFsPeriod(cpuPath),
readCPUFsQuota(cpuPath),
readCPUStat(cpuPath),
])
.then(([cpuAcctUsage, cpuFsPeriod, cpuFsQuota, cpuStat]) => {
resolve({
cpuacct: {
control_group: cpuAcctPath,
usage_nanos: cpuAcctUsage,
},
cpu: {
control_group: cpuPath,
cfs_period_micros: cpuFsPeriod,
cfs_quota_micros: cpuFsQuota,
stat: cpuStat,
},
});
})
.catch(rejectUnlessFileNotFound);
})
.catch(rejectUnlessFileNotFound);
function rejectUnlessFileNotFound(err) {
if (err.code === 'ENOENT') {
resolve(null);
} else {
reject(err);
}
}
});
}

View file

@ -1,224 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
jest.mock('fs', () => ({
readFile: jest.fn(),
}));
import fs from 'fs';
import { cGroups as cGroupsFsStub, setMockFiles, readFileMock } from './__mocks__/_fs_stubs';
import { getAllStats, readControlGroups, readCPUStat } from './cgroup';
describe('Control Group', function () {
const fsStub = cGroupsFsStub();
beforeAll(() => {
fs.readFile.mockImplementation(readFileMock);
});
afterEach(() => {
setMockFiles();
});
describe('readControlGroups', () => {
it('parses the file', async () => {
setMockFiles({ '/proc/self/cgroup': fsStub.cGroupContents });
const cGroup = await readControlGroups();
expect(cGroup).toEqual({
freezer: '/',
net_cls: '/',
net_prio: '/',
pids: '/',
blkio: '/',
memory: '/',
devices: '/user.slice',
hugetlb: '/',
perf_event: '/',
cpu: `/${fsStub.hierarchy}`,
cpuacct: `/${fsStub.hierarchy}`,
cpuset: `/${fsStub.hierarchy}`,
'name=systemd': '/user.slice/user-1000.slice/session-2359.scope',
});
});
});
describe('readCPUStat', () => {
it('parses the file', async () => {
setMockFiles({ '/sys/fs/cgroup/cpu/fakeGroup/cpu.stat': fsStub.cpuStatContents });
const cpuStat = await readCPUStat('fakeGroup');
expect(cpuStat).toEqual({
number_of_elapsed_periods: 0,
number_of_times_throttled: 10,
time_throttled_nanos: 20,
});
});
it('returns default stats for missing file', async () => {
setMockFiles();
const cpuStat = await readCPUStat('fakeGroup');
expect(cpuStat).toEqual({
number_of_elapsed_periods: -1,
number_of_times_throttled: -1,
time_throttled_nanos: -1,
});
});
});
describe('getAllStats', () => {
it('can override the cpu group path', async () => {
setMockFiles({
'/proc/self/cgroup': fsStub.cGroupContents,
[`${fsStub.cpuAcctDir}/cpuacct.usage`]: '357753491408',
'/sys/fs/cgroup/cpu/docker/cpu.cfs_period_us': '100000',
'/sys/fs/cgroup/cpu/docker/cpu.cfs_quota_us': '5000',
'/sys/fs/cgroup/cpu/docker/cpu.stat': fsStub.cpuStatContents,
});
const stats = await getAllStats({ cpuPath: '/docker' });
expect(stats).toEqual({
cpuacct: {
control_group: `/${fsStub.hierarchy}`,
usage_nanos: 357753491408,
},
cpu: {
control_group: '/docker',
cfs_period_micros: 100000,
cfs_quota_micros: 5000,
stat: {
number_of_elapsed_periods: 0,
number_of_times_throttled: 10,
time_throttled_nanos: 20,
},
},
});
});
it('handles an undefined control group', async () => {
setMockFiles({
'/proc/self/cgroup': '',
[`${fsStub.cpuAcctDir}/cpuacct.usage`]: '357753491408',
[`${fsStub.cpuDir}/cpu.stat`]: fsStub.cpuStatContents,
[`${fsStub.cpuDir}/cpu.cfs_period_us`]: '100000',
[`${fsStub.cpuDir}/cpu.cfs_quota_us`]: '5000',
});
const stats = await getAllStats();
expect(stats).toBe(null);
});
it('can override the cpuacct group path', async () => {
setMockFiles({
'/proc/self/cgroup': fsStub.cGroupContents,
'/sys/fs/cgroup/cpuacct/docker/cpuacct.usage': '357753491408',
[`${fsStub.cpuDir}/cpu.cfs_period_us`]: '100000',
[`${fsStub.cpuDir}/cpu.cfs_quota_us`]: '5000',
[`${fsStub.cpuDir}/cpu.stat`]: fsStub.cpuStatContents,
});
const stats = await getAllStats({ cpuAcctPath: '/docker' });
expect(stats).toEqual({
cpuacct: {
control_group: '/docker',
usage_nanos: 357753491408,
},
cpu: {
control_group: `/${fsStub.hierarchy}`,
cfs_period_micros: 100000,
cfs_quota_micros: 5000,
stat: {
number_of_elapsed_periods: 0,
number_of_times_throttled: 10,
time_throttled_nanos: 20,
},
},
});
});
it('extracts control group stats', async () => {
setMockFiles(fsStub.files);
const stats = await getAllStats();
expect(stats).toEqual({
cpuacct: {
control_group: `/${fsStub.hierarchy}`,
usage_nanos: 357753491408,
},
cpu: {
control_group: `/${fsStub.hierarchy}`,
cfs_period_micros: 100000,
cfs_quota_micros: 5000,
stat: {
number_of_elapsed_periods: 0,
number_of_times_throttled: 10,
time_throttled_nanos: 20,
},
},
});
});
it('returns null when all files are missing', async () => {
setMockFiles();
const stats = await getAllStats();
expect(stats).toBeNull();
});
it('returns null if CPU accounting files are missing', async () => {
setMockFiles({
'/proc/self/cgroup': fsStub.cGroupContents,
[`${fsStub.cpuDir}/cpu.stat`]: fsStub.cpuStatContents,
});
const stats = await getAllStats();
expect(stats).toBeNull();
});
it('returns -1 stat values if cpuStat file is missing', async () => {
setMockFiles({
'/proc/self/cgroup': fsStub.cGroupContents,
[`${fsStub.cpuAcctDir}/cpuacct.usage`]: '357753491408',
[`${fsStub.cpuDir}/cpu.cfs_period_us`]: '100000',
[`${fsStub.cpuDir}/cpu.cfs_quota_us`]: '5000',
});
const stats = await getAllStats();
expect(stats).toEqual({
cpu: {
cfs_period_micros: 100000,
cfs_quota_micros: 5000,
control_group: `/${fsStub.hierarchy}`,
stat: {
number_of_elapsed_periods: -1,
number_of_times_throttled: -1,
time_throttled_nanos: -1,
},
},
cpuacct: {
control_group: `/${fsStub.hierarchy}`,
usage_nanos: 357753491408,
},
});
});
});
});

View file

@ -1,47 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { get } from 'lodash';
const snapshotRegex = /-snapshot/i;
/**
* This provides a meta data attribute along with Kibana stats.
*
* @param {Object} kbnServer manager of Kibana services - see `src/legacy/server/kbn_server` in Kibana core
* @param {Object} config Server config
* @param {String} host Kibana host
* @return {Object} The object containing a "kibana" field and source instance details.
*/
export function getKibanaInfoForStats(server, kbnServer) {
const config = server.config();
const status = kbnServer.status.toJSON();
return {
uuid: config.get('server.uuid'),
name: config.get('server.name'),
index: config.get('kibana.index'),
host: config.get('server.host'),
locale: config.get('i18n.locale'),
transport_address: `${config.get('server.host')}:${config.get('server.port')}`,
version: kbnServer.version.replace(snapshotRegex, ''),
snapshot: snapshotRegex.test(kbnServer.version),
status: get(status, 'overall.state'),
};
}

View file

@ -1,48 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import os from 'os';
import getos from 'getos';
import { promisify } from 'util';
/**
* Returns an object of OS information/
*/
export async function getOSInfo() {
const osInfo = {
platform: os.platform(),
// Include the platform name in the release to avoid grouping unrelated platforms together.
// release 1.0 across windows, linux, and darwin don't mean anything useful.
platformRelease: `${os.platform()}-${os.release()}`,
};
// Get distribution information for linux
if (os.platform() === 'linux') {
try {
const distro = await promisify(getos)();
osInfo.distro = distro.dist;
// Include distro name in release for same reason as above.
osInfo.distroRelease = `${distro.dist}-${distro.release}`;
} catch (e) {
// ignore errors
}
}
return osInfo;
}

View file

@ -1,68 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
jest.mock('os', () => ({
platform: jest.fn(),
release: jest.fn(),
}));
jest.mock('getos');
import os from 'os';
import getos from 'getos';
import { getOSInfo } from './get_os_info';
describe('getOSInfo', () => {
it('returns basic OS info on non-linux', async () => {
os.platform.mockImplementation(() => 'darwin');
os.release.mockImplementation(() => '1.0.0');
const osInfo = await getOSInfo();
expect(osInfo).toEqual({
platform: 'darwin',
platformRelease: 'darwin-1.0.0',
});
});
it('returns basic OS info and distro info on linux', async () => {
os.platform.mockImplementation(() => 'linux');
os.release.mockImplementation(() => '4.9.93-linuxkit-aufs');
// Mock getos response
getos.mockImplementation((cb) =>
cb(null, {
os: 'linux',
dist: 'Ubuntu Linux',
codename: 'precise',
release: '12.04',
})
);
const osInfo = await getOSInfo();
expect(osInfo).toEqual({
platform: 'linux',
platformRelease: 'linux-4.9.93-linuxkit-aufs',
// linux distro info
distro: 'Ubuntu Linux',
distroRelease: 'Ubuntu Linux-12.04',
});
});
});

View file

@ -1,146 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import os from 'os';
import v8 from 'v8';
import { get, isObject, merge } from 'lodash';
import { keysToSnakeCaseShallow } from './case_conversion';
import { getAllStats as cGroupStats } from './cgroup';
import { getOSInfo } from './get_os_info';
const requestDefaults = {
disconnects: 0,
statusCodes: {},
total: 0,
};
export class Metrics {
constructor(config, server) {
this.config = config;
this.server = server;
this.checkCGroupStats = true;
}
static getStubMetrics() {
return {
process: {
memory: {
heap: {},
},
},
os: {
cpu: {},
memory: {},
},
response_times: {},
requests: {},
};
}
async capture(hapiEvent) {
const timestamp = new Date().toISOString();
const event = await this.captureEvent(hapiEvent);
const cgroup = await this.captureCGroupsIfAvailable();
const metrics = {
last_updated: timestamp,
collection_interval_in_millis: this.config.get('ops.interval'),
};
return merge(metrics, event, cgroup);
}
async captureEvent(hapiEvent) {
const heapStats = v8.getHeapStatistics();
const port = this.config.get('server.port');
const avgInMillis = get(hapiEvent, ['responseTimes', port, 'avg']); // sadly, it's possible for this to be NaN
const maxInMillis = get(hapiEvent, ['responseTimes', port, 'max']);
return {
process: {
memory: {
heap: {
// https://nodejs.org/docs/latest-v8.x/api/process.html#process_process_memoryusage
total_in_bytes: get(hapiEvent, 'psmem.heapTotal'),
used_in_bytes: get(hapiEvent, 'psmem.heapUsed'),
size_limit: heapStats.heap_size_limit,
},
resident_set_size_in_bytes: get(hapiEvent, 'psmem.rss'),
},
event_loop_delay: get(hapiEvent, 'psdelay'),
pid: process.pid,
uptime_in_millis: process.uptime() * 1000,
},
os: {
load: {
'1m': get(hapiEvent, 'osload.0'),
'5m': get(hapiEvent, 'osload.1'),
'15m': get(hapiEvent, 'osload.2'),
},
memory: {
total_in_bytes: os.totalmem(),
free_in_bytes: os.freemem(),
used_in_bytes: get(hapiEvent, 'osmem.total') - get(hapiEvent, 'osmem.free'),
},
uptime_in_millis: os.uptime() * 1000,
...(await getOSInfo()),
},
response_times: {
avg_in_millis: isNaN(avgInMillis) ? undefined : avgInMillis, // convert NaN to undefined
max_in_millis: maxInMillis,
},
requests: {
...requestDefaults,
...keysToSnakeCaseShallow(get(hapiEvent, ['requests', port])),
},
concurrent_connections: hapiEvent.concurrent_connections,
};
}
async captureCGroups() {
try {
const cgroup = await cGroupStats({
cpuPath: this.config.get('ops.cGroupOverrides.cpuPath'),
cpuAcctPath: this.config.get('ops.cGroupOverrides.cpuAcctPath'),
});
if (isObject(cgroup)) {
return {
os: {
cgroup,
},
};
}
} catch (e) {
this.server.log(['error', 'metrics', 'cgroup'], e);
}
}
async captureCGroupsIfAvailable() {
if (this.checkCGroupStats === true) {
const cgroup = await this.captureCGroups();
if (isObject(cgroup)) {
return cgroup;
}
this.checkCGroupStats = false;
}
}
}

View file

@ -1,245 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
jest.mock('fs', () => ({
readFile: jest.fn(),
}));
jest.mock('os', () => ({
freemem: jest.fn(),
totalmem: jest.fn(),
uptime: jest.fn(),
platform: jest.fn(),
release: jest.fn(),
}));
jest.mock('process', () => ({
uptime: jest.fn(),
}));
import fs from 'fs';
import os from 'os';
import _ from 'lodash';
import sinon from 'sinon';
import { cGroups as cGroupsFsStub, setMockFiles, readFileMock } from './__mocks__/_fs_stubs';
import { Metrics } from './metrics';
describe('Metrics', function () {
fs.readFile.mockImplementation(readFileMock);
const sampleConfig = {
ops: {
interval: 5000,
},
server: {
port: 5603,
},
};
const config = { get: (path) => _.get(sampleConfig, path) };
let metrics;
beforeEach(() => {
const server = { log: sinon.mock() };
metrics = new Metrics(config, server);
});
afterEach(() => {
setMockFiles();
});
describe('capture', () => {
it('merges all metrics', async () => {
setMockFiles();
sinon
.stub(metrics, 'captureEvent')
.returns({ a: [{ b: 2 }, { d: 4 }], process: { uptime_ms: 1980 } });
sinon.stub(metrics, 'captureCGroupsIfAvailable').returns({ a: [{ c: 3 }, { e: 5 }] });
sinon.stub(Date.prototype, 'toISOString').returns('2017-04-14T18:35:41.534Z');
const capturedMetrics = await metrics.capture();
expect(capturedMetrics).toMatchObject({
last_updated: '2017-04-14T18:35:41.534Z',
collection_interval_in_millis: 5000,
a: [
{ b: 2, c: 3 },
{ d: 4, e: 5 },
],
process: { uptime_ms: 1980 },
});
});
});
describe('captureEvent', () => {
it('parses the hapi event', async () => {
sinon.stub(os, 'uptime').returns(12000);
sinon.stub(process, 'uptime').returns(5000);
os.freemem.mockImplementation(() => 12);
os.totalmem.mockImplementation(() => 24);
const pidMock = jest.fn();
pidMock.mockReturnValue(8675309);
Object.defineProperty(process, 'pid', { get: pidMock }); //
const hapiEvent = {
requests: { 5603: { total: 22, disconnects: 0, statusCodes: { 200: 22 } } },
responseTimes: { 5603: { avg: 1.8636363636363635, max: 4 } },
osload: [2.20751953125, 2.02294921875, 1.89794921875],
osmem: { total: 17179869184, free: 102318080 },
osup: 1008991,
psup: 7.168,
psmem: { rss: 193716224, heapTotal: 168194048, heapUsed: 130553400, external: 1779619 },
concurrent_connections: 0,
psdelay: 1.6091690063476562,
host: 'blahblah.local',
};
expect(await metrics.captureEvent(hapiEvent)).toMatchObject({
concurrent_connections: 0,
os: {
load: {
'15m': 1.89794921875,
'1m': 2.20751953125,
'5m': 2.02294921875,
},
memory: {
free_in_bytes: 12,
total_in_bytes: 24,
},
uptime_in_millis: 12000000,
},
process: {
memory: {
heap: {
total_in_bytes: 168194048,
used_in_bytes: 130553400,
},
resident_set_size_in_bytes: 193716224,
},
pid: 8675309,
},
requests: {
disconnects: 0,
total: 22,
},
response_times: {
avg_in_millis: 1.8636363636363635,
max_in_millis: 4,
},
});
});
it('parses event with missing fields / NaN for responseTimes.avg', async () => {
const hapiEvent = {
requests: {
5603: { total: 22, disconnects: 0, statusCodes: { 200: 22 } },
},
responseTimes: { 5603: { avg: NaN, max: 4 } },
host: 'blahblah.local',
};
expect(await metrics.captureEvent(hapiEvent)).toMatchObject({
process: { memory: { heap: {} }, pid: 8675309, uptime_in_millis: 5000000 },
os: {
load: {},
memory: { free_in_bytes: 12, total_in_bytes: 24 },
},
response_times: { max_in_millis: 4 },
requests: { total: 22, disconnects: 0 },
});
});
});
describe('captureCGroups', () => {
afterEach(() => {
setMockFiles();
});
it('returns undefined if cgroups do not exist', async () => {
setMockFiles();
const stats = await metrics.captureCGroups();
expect(stats).toBe(undefined);
});
it('returns cgroups', async () => {
const fsStub = cGroupsFsStub();
setMockFiles(fsStub.files);
const capturedMetrics = await metrics.captureCGroups();
expect(capturedMetrics).toMatchObject({
os: {
cgroup: {
cpuacct: {
control_group: `/${fsStub.hierarchy}`,
usage_nanos: 357753491408,
},
cpu: {
control_group: `/${fsStub.hierarchy}`,
cfs_period_micros: 100000,
cfs_quota_micros: 5000,
stat: {
number_of_elapsed_periods: 0,
number_of_times_throttled: 10,
time_throttled_nanos: 20,
},
},
},
},
});
});
});
describe('captureCGroupsIfAvailable', () => {
afterEach(() => {
setMockFiles();
});
it('marks cgroups as unavailable and prevents subsequent calls', async () => {
setMockFiles();
sinon.spy(metrics, 'captureCGroups');
expect(metrics.checkCGroupStats).toBe(true);
await metrics.captureCGroupsIfAvailable();
expect(metrics.checkCGroupStats).toBe(false);
await metrics.captureCGroupsIfAvailable();
sinon.assert.calledOnce(metrics.captureCGroups);
});
it('allows subsequent calls if cgroups are available', async () => {
const fsStub = cGroupsFsStub();
setMockFiles(fsStub.files);
sinon.spy(metrics, 'captureCGroups');
expect(metrics.checkCGroupStats).toBe(true);
await metrics.captureCGroupsIfAvailable();
expect(metrics.checkCGroupStats).toBe(true);
await metrics.captureCGroupsIfAvailable();
sinon.assert.calledTwice(metrics.captureCGroups);
});
});
});

View file

@ -1,164 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Joi from 'joi';
import boom from 'boom';
import { defaultsDeep } from 'lodash';
import { i18n } from '@kbn/i18n';
import { wrapAuthConfig } from '../../wrap_auth_config';
import { getKibanaInfoForStats } from '../../lib';
const STATS_NOT_READY_MESSAGE = i18n.translate('server.stats.notReadyMessage', {
defaultMessage: 'Stats are not ready yet. Please try again later.',
});
/*
* API for Kibana meta info and accumulated operations stats
* Including ?extended in the query string fetches Elasticsearch cluster_uuid and usageCollection data
* - Requests to set isExtended = true
* GET /api/stats?extended=true
* GET /api/stats?extended
* - No value or 'false' is isExtended = false
* - Any other value causes a statusCode 400 response (Bad Request)
* Including ?exclude_usage in the query string excludes the usage stats from the response. Same value semantics as ?extended
*/
export function registerStatsApi(usageCollection, server, config, kbnServer) {
const wrapAuth = wrapAuthConfig(config.get('status.allowAnonymous'));
const getClusterUuid = async (callCluster) => {
const { cluster_uuid: uuid } = await callCluster('info', { filterPath: 'cluster_uuid' });
return uuid;
};
const getUsage = async (callCluster) => {
const usage = await usageCollection.bulkFetchUsage(callCluster);
return usageCollection.toObject(usage);
};
let lastMetrics = null;
/* kibana_stats gets singled out from the collector set as it is used
* for health-checking Kibana and fetch does not rely on fetching data
* from ES */
server.newPlatform.start.core.metrics.getOpsMetrics$().subscribe((metrics) => {
lastMetrics = {
...metrics,
timestamp: new Date().toISOString(),
};
});
server.route(
wrapAuth({
method: 'GET',
path: '/api/stats',
config: {
validate: {
query: Joi.object({
extended: Joi.string().valid('', 'true', 'false'),
legacy: Joi.string().valid('', 'true', 'false'),
exclude_usage: Joi.string().valid('', 'true', 'false'),
}),
},
tags: ['api'],
},
async handler(req) {
const isExtended = req.query.extended !== undefined && req.query.extended !== 'false';
const isLegacy = req.query.legacy !== undefined && req.query.legacy !== 'false';
const shouldGetUsage =
req.query.exclude_usage === undefined || req.query.exclude_usage === 'false';
let extended;
if (isExtended) {
const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin');
const callCluster = (...args) => callWithRequest(req, ...args);
const collectorsReady = await usageCollection.areAllCollectorsReady();
if (shouldGetUsage && !collectorsReady) {
return boom.serverUnavailable(STATS_NOT_READY_MESSAGE);
}
const usagePromise = shouldGetUsage ? getUsage(callCluster) : Promise.resolve({});
try {
const [usage, clusterUuid] = await Promise.all([
usagePromise,
getClusterUuid(callCluster),
]);
let modifiedUsage = usage;
if (isLegacy) {
// In an effort to make telemetry more easily augmented, we need to ensure
// we can passthrough the data without every part of the process needing
// to know about the change; however, to support legacy use cases where this
// wasn't true, we need to be backwards compatible with how the legacy data
// looked and support those use cases here.
modifiedUsage = Object.keys(usage).reduce((accum, usageKey) => {
if (usageKey === 'kibana') {
accum = {
...accum,
...usage[usageKey],
};
} else if (usageKey === 'reporting') {
accum = {
...accum,
xpack: {
...accum.xpack,
reporting: usage[usageKey],
},
};
} else {
// I don't think we need to it this for the above conditions, but do it for most as it will
// match the behavior done in monitoring/bulk_uploader
defaultsDeep(accum, { [usageKey]: usage[usageKey] });
}
return accum;
}, {});
extended = {
usage: modifiedUsage,
clusterUuid,
};
} else {
extended = usageCollection.toApiFieldNames({
usage: modifiedUsage,
clusterUuid,
});
}
} catch (e) {
throw boom.boomify(e);
}
}
if (!lastMetrics) {
return boom.serverUnavailable(STATS_NOT_READY_MESSAGE);
}
const kibanaStats = usageCollection.toApiFieldNames({
...lastMetrics,
kibana: getKibanaInfoForStats(server, kbnServer),
last_updated: new Date().toISOString(),
collection_interval_in_millis: config.get('ops.interval'),
});
return {
...kibanaStats,
...extended,
};
},
})
);
}

View file

@ -1,50 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { wrapAuthConfig } from '../../wrap_auth_config';
const matchSnapshot = /-SNAPSHOT$/;
export function registerStatusApi(kbnServer, server, config) {
const wrapAuth = wrapAuthConfig(config.get('status.allowAnonymous'));
server.route(
wrapAuth({
method: 'GET',
path: '/api/status',
config: {
tags: ['api'],
},
async handler() {
return {
name: config.get('server.name'),
uuid: config.get('server.uuid'),
version: {
number: config.get('pkg.version').replace(matchSnapshot, ''),
build_hash: config.get('pkg.buildSha'),
build_number: config.get('pkg.buildNum'),
build_snapshot: matchSnapshot.test(config.get('pkg.version')),
},
status: kbnServer.status.toJSON(),
metrics: kbnServer.metrics,
};
},
})
);
}

View file

@ -1,21 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { registerStatusApi } from './api/register_status';
export { registerStatsApi } from './api/register_stats';

View file

@ -1,45 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
function Samples(max) {
this.vals = {};
this.max = max || Infinity;
this.length = 0;
}
Samples.prototype.add = function (sample) {
const vals = this.vals;
const length = (this.length = Math.min(this.length + 1, this.max));
_.forOwn(sample, function (val, name) {
if (val == null) val = null;
if (!vals[name]) vals[name] = new Array(length);
vals[name].unshift([Date.now(), val]);
vals[name].length = length;
});
};
Samples.prototype.toJSON = function () {
return this.vals;
};
export default Samples;

View file

@ -1,116 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
import * as states from './states';
import Status from './status';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { pkg } from '../../../core/server/utils';
export default class ServerStatus {
constructor(server) {
this.server = server;
this._created = {};
}
create(id) {
const status = new Status(id, this.server);
this._created[status.id] = status;
return status;
}
createForPlugin(plugin) {
if (plugin.version === 'kibana') plugin.version = pkg.version;
const status = this.create(`plugin:${plugin.id}@${plugin.version}`);
status.plugin = plugin;
return status;
}
each(fn) {
const self = this;
_.forOwn(self._created, function (status, i, list) {
if (status.state !== 'disabled') {
fn.call(self, status, i, list);
}
});
}
get(id) {
return this._created[id];
}
getForPluginId(pluginId) {
return _.find(this._created, (s) => s.plugin && s.plugin.id === pluginId);
}
getState(id) {
const status = this.get(id);
if (!status) return undefined;
return status.state || 'uninitialized';
}
getStateForPluginId(pluginId) {
const status = this.getForPluginId(pluginId);
if (!status) return undefined;
return status.state || 'uninitialized';
}
overall() {
const state = Object
// take all created status objects
.values(this._created)
// get the state descriptor for each status
.map((status) => states.get(status.state))
// reduce to the state with the highest severity, defaulting to green
.reduce((a, b) => (a.severity > b.severity ? a : b), states.get('green'));
const statuses = _.filter(this._created, { state: state.id });
const since = _.get(_.sortBy(statuses, 'since'), [0, 'since']);
return {
state: state.id,
title: state.title,
nickname: _.sample(state.nicknames),
icon: state.icon,
uiColor: states.get(state.id).uiColor,
since: since,
};
}
isGreen() {
return this.overall().state === 'green';
}
notGreen() {
return !this.isGreen();
}
toString() {
const overall = this.overall();
return `${overall.title} ${overall.nickname}`;
}
toJSON() {
return {
overall: this.overall(),
statuses: _.values(this._created),
};
}
}

View file

@ -1,145 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { find } from 'lodash';
import sinon from 'sinon';
import * as states from './states';
import Status from './status';
import ServerStatus from './server_status';
describe('ServerStatus class', function () {
const plugin = { id: 'name', version: '1.2.3' };
let server;
let serverStatus;
beforeEach(function () {
server = { expose: sinon.stub(), logWithMetadata: sinon.stub() };
serverStatus = new ServerStatus(server);
});
describe('#create(id)', () => {
it('should create a new plugin with an id', () => {
const status = serverStatus.create('someid');
expect(status).toBeInstanceOf(Status);
});
});
describe('#createForPlugin(plugin)', function () {
it('should create a new status by plugin', function () {
const status = serverStatus.createForPlugin(plugin);
expect(status).toBeInstanceOf(Status);
});
});
describe('#get(id)', () => {
it('exposes statuses by their id', () => {
const status = serverStatus.create('statusid');
expect(serverStatus.get('statusid')).toBe(status);
});
it('does not get the status for a plugin', () => {
serverStatus.createForPlugin(plugin);
expect(serverStatus.get(plugin)).toBe(undefined);
});
});
describe('#getForPluginId(plugin)', function () {
it('exposes plugin status for the plugin', function () {
const status = serverStatus.createForPlugin(plugin);
expect(serverStatus.getForPluginId(plugin.id)).toBe(status);
});
it('does not get plain statuses by their id', function () {
serverStatus.create('someid');
expect(serverStatus.getForPluginId('someid')).toBe(undefined);
});
});
describe('#getState(id)', function () {
it('should expose the state of a status by id', function () {
const status = serverStatus.create('someid');
status.green();
expect(serverStatus.getState('someid')).toBe('green');
});
});
describe('#getStateForPluginId(plugin)', function () {
it('should expose the state of a plugin by id', function () {
const status = serverStatus.createForPlugin(plugin);
status.green();
expect(serverStatus.getStateForPluginId(plugin.id)).toBe('green');
});
});
describe('#overall()', function () {
it('considers each status to produce a summary', function () {
const status = serverStatus.createForPlugin(plugin);
expect(serverStatus.overall().state).toBe('uninitialized');
const match = function (overall, state) {
expect(overall).toHaveProperty('state', state.id);
expect(overall).toHaveProperty('title', state.title);
expect(overall).toHaveProperty('icon', state.icon);
expect(overall).toHaveProperty('uiColor', state.uiColor);
expect(state.nicknames).toContain(overall.nickname);
};
status.green();
match(serverStatus.overall(), states.get('green'));
status.yellow();
match(serverStatus.overall(), states.get('yellow'));
status.red();
match(serverStatus.overall(), states.get('red'));
});
});
describe('#toJSON()', function () {
it('serializes to overall status and individuals', function () {
const pluginOne = { id: 'one', version: '1.0.0' };
const pluginTwo = { id: 'two', version: '2.0.0' };
const pluginThree = { id: 'three', version: 'kibana' };
const service = serverStatus.create('some service');
const p1 = serverStatus.createForPlugin(pluginOne);
const p2 = serverStatus.createForPlugin(pluginTwo);
const p3 = serverStatus.createForPlugin(pluginThree);
service.green();
p1.yellow();
p2.red();
const json = JSON.parse(JSON.stringify(serverStatus));
expect(json).toHaveProperty('overall');
expect(json.overall.state).toEqual(serverStatus.overall().state);
expect(json.statuses).toHaveLength(4);
const out = (status) => find(json.statuses, { id: status.id });
expect(out(service)).toHaveProperty('state', 'green');
expect(out(p1)).toHaveProperty('state', 'yellow');
expect(out(p2)).toHaveProperty('state', 'red');
expect(out(p3)).toHaveProperty('id');
expect(out(p3).id).not.toContain('undefined');
});
});
});

View file

@ -1,85 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
export const getAll = () => [
{
id: 'red',
title: i18n.translate('server.status.redTitle', {
defaultMessage: 'Red',
}),
icon: 'danger',
uiColor: 'danger',
severity: 1000,
nicknames: ['Danger Will Robinson! Danger!'],
},
{
id: 'uninitialized',
title: i18n.translate('server.status.uninitializedTitle', {
defaultMessage: 'Uninitialized',
}),
icon: 'spinner',
uiColor: 'default',
severity: 900,
nicknames: ['Initializing'],
},
{
id: 'yellow',
title: i18n.translate('server.status.yellowTitle', {
defaultMessage: 'Yellow',
}),
icon: 'warning',
uiColor: 'warning',
severity: 800,
nicknames: ['S.N.A.F.U', "I'll be back", 'brb'],
},
{
id: 'green',
title: i18n.translate('server.status.greenTitle', {
defaultMessage: 'Green',
}),
icon: 'success',
uiColor: 'secondary',
severity: 0,
nicknames: ['Looking good'],
},
{
id: 'disabled',
title: i18n.translate('server.status.disabledTitle', {
defaultMessage: 'Disabled',
}),
severity: -1,
icon: 'toggle-off',
uiColor: 'default',
nicknames: ['Am I even a thing?'],
},
];
export const getAllById = () => _.keyBy(exports.getAll(), 'id');
export const defaults = {
icon: 'question',
severity: Infinity,
};
export function get(id) {
return exports.getAllById()[id] || _.defaults({ id: id }, exports.defaults);
}

View file

@ -1,108 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as states from './states';
import { EventEmitter } from 'events';
export default class Status extends EventEmitter {
constructor(id, server) {
super();
if (!id || typeof id !== 'string') {
throw new TypeError('Status constructor requires an `id` string');
}
this.id = id;
this.since = new Date();
this.state = 'uninitialized';
this.message = 'uninitialized';
this.on('change', function (previous, previousMsg) {
this.since = new Date();
const tags = ['status', this.id, this.state === 'red' ? 'error' : 'info'];
server.logWithMetadata(
tags,
`Status changed from ${previous} to ${this.state}${
this.message ? ' - ' + this.message : ''
}`,
{
state: this.state,
message: this.message,
prevState: previous,
prevMsg: previousMsg,
}
);
});
}
toJSON() {
return {
id: this.id,
state: this.state,
icon: states.get(this.state).icon,
message: this.message,
uiColor: states.get(this.state).uiColor,
since: this.since,
};
}
on(eventName, handler) {
super.on(eventName, handler);
if (eventName === this.state) {
setImmediate(() => handler(this.state, this.message));
}
}
once(eventName, handler) {
if (eventName === this.state) {
setImmediate(() => handler(this.state, this.message));
} else {
super.once(eventName, handler);
}
}
}
states.getAll().forEach(function (state) {
Status.prototype[state.id] = function (message) {
if (this.state === 'disabled') return;
const previous = this.state;
const previousMsg = this.message;
this.error = null;
this.message = message || state.title;
this.state = state.id;
if (message instanceof Error) {
this.error = message;
this.message = message.message;
}
if (previous === this.state && previousMsg === this.message) {
// noop
return;
}
this.emit(state.id, previous, previousMsg, this.state, this.message);
this.emit('change', previous, previousMsg, this.state, this.message);
};
});

View file

@ -1,147 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import sinon from 'sinon';
import ServerStatus from './server_status';
describe('Status class', function () {
const plugin = { id: 'test', version: '1.2.3' };
let server;
let serverStatus;
beforeEach(function () {
server = { expose: sinon.stub(), logWithMetadata: sinon.stub() };
serverStatus = new ServerStatus(server);
});
it('should have an "uninitialized" state initially', () => {
expect(serverStatus.createForPlugin(plugin)).toHaveProperty('state', 'uninitialized');
});
it('emits change when the status is set', function (done) {
const status = serverStatus.createForPlugin(plugin);
status.once('change', function (prevState, prevMsg, newState, newMsg) {
expect(newState).toBe('green');
expect(newMsg).toBe('GREEN');
expect(prevState).toBe('uninitialized');
status.once('change', function (prevState, prevMsg, newState, newMsg) {
expect(newState).toBe('red');
expect(newMsg).toBe('RED');
expect(prevState).toBe('green');
expect(prevMsg).toBe('GREEN');
done();
});
status.red('RED');
});
status.green('GREEN');
});
it('should only trigger the change listener when something changes', function () {
const status = serverStatus.createForPlugin(plugin);
const stub = sinon.stub();
status.on('change', stub);
status.green('Ready');
status.green('Ready');
status.red('Not Ready');
sinon.assert.calledTwice(stub);
});
it('should create a JSON representation of the status', function () {
const status = serverStatus.createForPlugin(plugin);
status.green('Ready');
const json = status.toJSON();
expect(json.id).toEqual(status.id);
expect(json.state).toEqual('green');
expect(json.message).toEqual('Ready');
});
it('should call on handler if status is already matched', function (done) {
const status = serverStatus.createForPlugin(plugin);
const msg = 'Test Ready';
status.green(msg);
status.on('green', function (prev, prevMsg) {
expect(arguments.length).toBe(2);
expect(prev).toBe('green');
expect(prevMsg).toBe(msg);
expect(status.message).toBe(msg);
done();
});
});
it('should call once handler if status is already matched', function (done) {
const status = serverStatus.createForPlugin(plugin);
const msg = 'Test Ready';
status.green(msg);
status.once('green', function (prev, prevMsg) {
expect(arguments.length).toBe(2);
expect(prev).toBe('green');
expect(prevMsg).toBe(msg);
expect(status.message).toBe(msg);
done();
});
});
function testState(color) {
it(`should change the state to ${color} when #${color}() is called`, function () {
const status = serverStatus.createForPlugin(plugin);
const message = 'testing ' + color;
status[color](message);
expect(status).toHaveProperty('state', color);
expect(status).toHaveProperty('message', message);
});
it(`should trigger the "change" listener when #${color}() is called`, function (done) {
const status = serverStatus.createForPlugin(plugin);
const message = 'testing ' + color;
status.on('change', function (prev, prevMsg) {
expect(status.state).toBe(color);
expect(status.message).toBe(message);
expect(prev).toBe('uninitialized');
expect(prevMsg).toBe('uninitialized');
done();
});
status[color](message);
});
it(`should trigger the "${color}" listener when #${color}() is called`, function (done) {
const status = serverStatus.createForPlugin(plugin);
const message = 'testing ' + color;
status.on(color, function () {
expect(status.state).toBe(color);
expect(status.message).toBe(message);
done();
});
status[color](message);
});
}
testState('green');
testState('yellow');
testState('red');
});

View file

@ -1,27 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { assign, identity } from 'lodash';
export const wrapAuthConfig = (allowAnonymous) => {
if (allowAnonymous) {
return (options) => assign(options, { config: { auth: false } });
}
return identity;
};

View file

@ -1,60 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { wrapAuthConfig } from './wrap_auth_config';
describe('Status wrapAuthConfig', () => {
let options;
beforeEach(() => {
options = {
method: 'GET',
path: '/status',
handler: function (request, h) {
return h.response();
},
};
});
it('should return a function', () => {
expect(typeof wrapAuthConfig()).toBe('function');
expect(typeof wrapAuthConfig(true)).toBe('function');
expect(typeof wrapAuthConfig(false)).toBe('function');
});
it('should not add auth config by default', () => {
const wrapAuth = wrapAuthConfig();
const wrapped = wrapAuth(options);
expect(wrapped).not.toHaveProperty('config');
});
it('should not add auth config if allowAnonymous is false', () => {
const wrapAuth = wrapAuthConfig(false);
const wrapped = wrapAuth(options);
expect(wrapped).not.toHaveProperty('config');
});
it('should add auth config if allowAnonymous is true', () => {
const wrapAuth = wrapAuthConfig(true);
const wrapped = wrapAuth(options);
expect(wrapped).toHaveProperty('config');
expect(wrapped.config).toHaveProperty('auth');
expect(wrapped.config.auth).toBe(false);
});
});

View file

@ -49,8 +49,25 @@ export class UsageCollectionPlugin implements Plugin<CollectorSet> {
maximumWaitTimeForAllCollectorsInS: config.maximumWaitTimeForAllCollectorsInS,
});
const globalConfig = await this.initializerContext.config.legacy.globalConfig$
.pipe(first())
.toPromise();
const router = core.http.createRouter();
setupRoutes(router, () => this.savedObjects);
setupRoutes({
router,
getSavedObjects: () => this.savedObjects,
collectorSet,
config: {
allowAnonymous: core.status.isStatusPageAnonymous(),
kibanaIndex: globalConfig.kibana.index,
kibanaVersion: this.initializerContext.env.packageInfo.version,
server: core.http.getServerInfo(),
uuid: this.initializerContext.env.instanceUuid,
},
metrics: core.metrics,
overallStatus$: core.status.overall$,
});
return collectorSet;
}

View file

@ -17,12 +17,39 @@
* under the License.
*/
import { IRouter, ISavedObjectsRepository } from 'kibana/server';
import {
IRouter,
ISavedObjectsRepository,
MetricsServiceSetup,
ServiceStatus,
} from 'kibana/server';
import { Observable } from 'rxjs';
import { CollectorSet } from '../collector';
import { registerUiMetricRoute } from './report_metrics';
import { registerStatsRoute } from './stats';
export function setupRoutes(
router: IRouter,
getSavedObjects: () => ISavedObjectsRepository | undefined
) {
export function setupRoutes({
router,
getSavedObjects,
...rest
}: {
router: IRouter;
getSavedObjects: () => ISavedObjectsRepository | undefined;
config: {
allowAnonymous: boolean;
kibanaIndex: string;
kibanaVersion: string;
uuid: string;
server: {
name: string;
hostname: string;
port: number;
};
};
collectorSet: CollectorSet;
metrics: MetricsServiceSetup;
overallStatus$: Observable<ServiceStatus>;
}) {
registerUiMetricRoute(router, getSavedObjects);
registerStatsRoute({ router, ...rest });
}

View file

@ -0,0 +1,104 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { BehaviorSubject } from 'rxjs';
import { UnwrapPromise } from '@kbn/utility-types';
import {
MetricsServiceSetup,
ServiceStatus,
ServiceStatusLevels,
} from '../../../../../core/server';
import {
contextServiceMock,
loggingSystemMock,
metricsServiceMock,
} from '../../../../../core/server/mocks';
import { createHttpServer } from '../../../../../core/server/test_utils';
import { registerStatsRoute } from '../stats';
import supertest from 'supertest';
import { CollectorSet } from '../../collector';
type HttpService = ReturnType<typeof createHttpServer>;
type HttpSetup = UnwrapPromise<ReturnType<HttpService['setup']>>;
describe('/api/stats', () => {
let server: HttpService;
let httpSetup: HttpSetup;
let overallStatus$: BehaviorSubject<ServiceStatus>;
let metrics: MetricsServiceSetup;
beforeEach(async () => {
server = createHttpServer();
httpSetup = await server.setup({
context: contextServiceMock.createSetupContract(),
});
overallStatus$ = new BehaviorSubject<ServiceStatus>({
level: ServiceStatusLevels.available,
summary: 'everything is working',
});
metrics = metricsServiceMock.createSetupContract();
const router = httpSetup.createRouter('');
registerStatsRoute({
router,
collectorSet: new CollectorSet({
logger: loggingSystemMock.create().asLoggerFactory().get(),
}),
config: {
allowAnonymous: true,
kibanaIndex: '.kibana-test',
kibanaVersion: '8.8.8-SNAPSHOT',
server: {
name: 'mykibana',
hostname: 'mykibana.com',
port: 1234,
},
uuid: 'xxx-xxxxx',
},
metrics,
overallStatus$,
});
await server.start();
});
afterEach(async () => {
await server.stop();
});
it('successfully returns data', async () => {
const response = await supertest(httpSetup.server.listener).get('/api/stats').expect(200);
expect(response.body).toMatchObject({
kibana: {
uuid: 'xxx-xxxxx',
name: 'mykibana',
index: '.kibana-test',
host: 'mykibana.com',
locale: 'en',
transport_address: `mykibana.com:1234`,
version: '8.8.8',
snapshot: true,
status: 'green',
},
last_updated: expect.any(String),
collection_interval_ms: expect.any(Number),
});
});
});

View file

@ -0,0 +1,190 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { schema } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import defaultsDeep from 'lodash/defaultsDeep';
import { Observable } from 'rxjs';
import { first } from 'rxjs/operators';
import {
IRouter,
LegacyAPICaller,
MetricsServiceSetup,
ServiceStatus,
ServiceStatusLevels,
} from '../../../../core/server';
import { CollectorSet } from '../collector';
const STATS_NOT_READY_MESSAGE = i18n.translate('usageCollection.stats.notReadyMessage', {
defaultMessage: 'Stats are not ready yet. Please try again later.',
});
const SNAPSHOT_REGEX = /-snapshot/i;
export function registerStatsRoute({
router,
config,
collectorSet,
metrics,
overallStatus$,
}: {
router: IRouter;
config: {
allowAnonymous: boolean;
kibanaIndex: string;
kibanaVersion: string;
uuid: string;
server: {
name: string;
hostname: string;
port: number;
};
};
collectorSet: CollectorSet;
metrics: MetricsServiceSetup;
overallStatus$: Observable<ServiceStatus>;
}) {
const getUsage = async (callCluster: LegacyAPICaller): Promise<any> => {
const usage = await collectorSet.bulkFetchUsage(callCluster);
return collectorSet.toObject(usage);
};
const getClusterUuid = async (callCluster: LegacyAPICaller): Promise<string> => {
const { cluster_uuid: uuid } = await callCluster('info', { filterPath: 'cluster_uuid' });
return uuid;
};
router.get(
{
path: '/api/stats',
options: {
authRequired: !config.allowAnonymous,
tags: ['api'], // ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page
},
validate: {
query: schema.object({
extended: schema.oneOf([schema.literal(''), schema.boolean()], { defaultValue: false }),
legacy: schema.oneOf([schema.literal(''), schema.boolean()], { defaultValue: false }),
exclude_usage: schema.oneOf([schema.literal(''), schema.boolean()], {
defaultValue: false,
}),
}),
},
},
async (context, req, res) => {
const isExtended = req.query.extended === '' || req.query.extended;
const isLegacy = req.query.legacy === '' || req.query.legacy;
const shouldGetUsage = req.query.exclude_usage === false;
let extended;
if (isExtended) {
const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser;
const collectorsReady = await collectorSet.areAllCollectorsReady();
if (shouldGetUsage && !collectorsReady) {
return res.customError({ statusCode: 503, body: { message: STATS_NOT_READY_MESSAGE } });
}
const usagePromise = shouldGetUsage ? getUsage(callCluster) : Promise.resolve({});
const [usage, clusterUuid] = await Promise.all([usagePromise, getClusterUuid(callCluster)]);
let modifiedUsage = usage;
if (isLegacy) {
// In an effort to make telemetry more easily augmented, we need to ensure
// we can passthrough the data without every part of the process needing
// to know about the change; however, to support legacy use cases where this
// wasn't true, we need to be backwards compatible with how the legacy data
// looked and support those use cases here.
modifiedUsage = Object.keys(usage).reduce((accum, usageKey) => {
if (usageKey === 'kibana') {
accum = {
...accum,
...usage[usageKey],
};
} else if (usageKey === 'reporting') {
accum = {
...accum,
xpack: {
...accum.xpack,
reporting: usage[usageKey],
},
};
} else {
// I don't think we need to it this for the above conditions, but do it for most as it will
// match the behavior done in monitoring/bulk_uploader
defaultsDeep(accum, { [usageKey]: usage[usageKey] });
}
return accum;
}, {} as any);
extended = {
usage: modifiedUsage,
clusterUuid,
};
} else {
extended = collectorSet.toApiFieldNames({
usage: modifiedUsage,
clusterUuid,
});
}
}
// Guranteed to resolve immediately due to replay effect on getOpsMetrics$
// eslint-disable-next-line @typescript-eslint/naming-convention
const { collected_at, ...lastMetrics } = await metrics
.getOpsMetrics$()
.pipe(first())
.toPromise();
const overallStatus = await overallStatus$.pipe(first()).toPromise();
const kibanaStats = collectorSet.toApiFieldNames({
...lastMetrics,
kibana: {
uuid: config.uuid,
name: config.server.name,
index: config.kibanaIndex,
host: config.server.hostname,
locale: i18n.getLocale(),
transport_address: `${config.server.hostname}:${config.server.port}`,
version: config.kibanaVersion.replace(SNAPSHOT_REGEX, ''),
snapshot: SNAPSHOT_REGEX.test(config.kibanaVersion),
status: ServiceStatusToLegacyState[overallStatus.level.toString()],
},
last_updated: collected_at.toISOString(),
collection_interval_in_millis: metrics.collectionInterval,
});
return res.ok({
body: {
...kibanaStats,
...extended,
},
});
}
);
}
const ServiceStatusToLegacyState: Record<string, string> = {
[ServiceStatusLevels.critical.toString()]: 'red',
[ServiceStatusLevels.unavailable.toString()]: 'red',
[ServiceStatusLevels.degraded.toString()]: 'yellow',
[ServiceStatusLevels.available.toString()]: 'green',
};

View file

@ -5,7 +5,6 @@
*/
import { resolve } from 'path';
import { mirrorPluginStatus } from '../../server/lib/mirror_plugin_status';
import { setupXPackMain } from './server/lib/setup_xpack_main';
import { xpackInfoRoute } from './server/routes/api/v1';
@ -28,8 +27,6 @@ export const xpackMain = (kibana) => {
},
init(server) {
mirrorPluginStatus(server.plugins.elasticsearch, this, 'yellow', 'red');
setupXPackMain(server);
// register routes

View file

@ -13,33 +13,35 @@ describe('setupXPackMain()', () => {
const sandbox = sinon.createSandbox();
let mockServer;
let mockStatusObservable;
let mockElasticsearchPlugin;
let mockXPackMainPlugin;
beforeEach(() => {
sandbox.useFakeTimers();
mockElasticsearchPlugin = {
getCluster: sinon.stub(),
status: sinon.stub({
on() {},
}),
};
mockXPackMainPlugin = {
status: sinon.stub({
green() {},
red() {},
}),
};
mockStatusObservable = sinon.stub({ subscribe() {} });
mockServer = sinon.stub({
plugins: {
elasticsearch: mockElasticsearchPlugin,
xpack_main: mockXPackMainPlugin,
},
newPlatform: {
setup: { plugins: { features: {}, licensing: { license$: new BehaviorSubject() } } },
setup: {
core: {
status: {
core$: {
pipe() {
return mockStatusObservable;
},
},
},
},
plugins: { features: {}, licensing: { license$: new BehaviorSubject() } },
},
},
events: { on() {} },
log() {},
@ -61,55 +63,6 @@ describe('setupXPackMain()', () => {
setupXPackMain(mockServer);
sinon.assert.calledWithExactly(mockServer.expose, 'info', sinon.match.instanceOf(XPackInfo));
sinon.assert.calledWithExactly(mockElasticsearchPlugin.status.on, 'change', sinon.match.func);
});
describe('Elasticsearch plugin state changes cause XPackMain plugin state change.', () => {
let xPackInfo;
let onElasticsearchPluginStatusChange;
beforeEach(() => {
setupXPackMain(mockServer);
onElasticsearchPluginStatusChange = mockElasticsearchPlugin.status.on.withArgs('change')
.firstCall.args[1];
xPackInfo = mockServer.expose.firstCall.args[1];
});
it('if `XPackInfo` is available status will become `green`.', async () => {
sinon.stub(xPackInfo, 'isAvailable').returns(false);
// We need this to make sure the code waits for `refreshNow` to complete before it tries
// to access its properties.
sinon.stub(xPackInfo, 'refreshNow').callsFake(() => {
return new Promise((resolve) => {
xPackInfo.isAvailable.returns(true);
resolve();
});
});
await onElasticsearchPluginStatusChange();
sinon.assert.calledWithExactly(mockXPackMainPlugin.status.green, 'Ready');
sinon.assert.notCalled(mockXPackMainPlugin.status.red);
});
it('if `XPackInfo` is not available status will become `red`.', async () => {
sinon.stub(xPackInfo, 'isAvailable').returns(true);
sinon.stub(xPackInfo, 'unavailableReason').returns('');
// We need this to make sure the code waits for `refreshNow` to complete before it tries
// to access its properties.
sinon.stub(xPackInfo, 'refreshNow').callsFake(() => {
return new Promise((resolve) => {
xPackInfo.isAvailable.returns(false);
xPackInfo.unavailableReason.returns('Some weird error.');
resolve();
});
});
await onElasticsearchPluginStatusChange();
sinon.assert.calledWithExactly(mockXPackMainPlugin.status.red, 'Some weird error.');
sinon.assert.notCalled(mockXPackMainPlugin.status.green);
});
sinon.assert.calledWithExactly(mockStatusObservable.subscribe, sinon.match.func);
});
});

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { pairwise } from 'rxjs/operators';
import { XPackInfo } from './xpack_info';
/**
@ -19,23 +20,14 @@ export function setupXPackMain(server) {
server.expose('info', info);
const setPluginStatus = () => {
if (info.isAvailable()) {
server.plugins.xpack_main.status.green('Ready');
} else {
server.plugins.xpack_main.status.red(info.unavailableReason());
}
};
// trigger an xpack info refresh whenever the elasticsearch plugin status changes
server.plugins.elasticsearch.status.on('change', async () => {
await info.refreshNow();
setPluginStatus();
});
// whenever the license info is updated, regardless of the elasticsearch plugin status
// changes, reflect the change in our plugin status. See https://github.com/elastic/kibana/issues/20017
info.onLicenseInfoChange(setPluginStatus);
server.newPlatform.setup.core.status.core$
.pipe(pairwise())
.subscribe(async ([coreLast, coreCurrent]) => {
if (coreLast.elasticsearch.level !== coreCurrent.elasticsearch.level) {
await info.refreshNow();
}
});
return info;
}

View file

@ -1,108 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import EventEmitter from 'events';
import expect from '@kbn/expect';
import { mirrorPluginStatus } from '../mirror_plugin_status';
describe('mirror_plugin_status', () => {
class MockPluginStatus extends EventEmitter {
constructor() {
super();
this.state = 'uninitialized';
}
_changeState(newState, newMessage) {
if (this.state === newState) {
return;
}
const prevState = this.state;
const prevMessage = this.message;
this.state = newState;
this.message = newMessage;
this.emit(newState, prevState, prevMessage, this.state, this.message);
this.emit('change', prevState, prevMessage, this.state, this.message);
}
red(message) {
this._changeState('red', message);
}
yellow(message) {
this._changeState('yellow', message);
}
green(message) {
this._changeState('green', message);
}
uninitialized(message) {
this._changeState('uninitialized', message);
}
}
class MockPlugin {
constructor() {
this.status = new MockPluginStatus();
}
}
let upstreamPlugin;
let downstreamPlugin;
let eventNotEmittedTimeout;
beforeEach(() => {
upstreamPlugin = new MockPlugin();
downstreamPlugin = new MockPlugin();
eventNotEmittedTimeout = setTimeout(() => {
throw new Error('Event should have been emitted');
}, 100);
});
it('should mirror all downstream plugin statuses to upstream plugin statuses', (done) => {
mirrorPluginStatus(upstreamPlugin, downstreamPlugin);
downstreamPlugin.status.on('change', () => {
clearTimeout(eventNotEmittedTimeout);
expect(downstreamPlugin.status.state).to.be('red');
expect(downstreamPlugin.status.message).to.be('test message');
done();
});
upstreamPlugin.status.red('test message');
});
describe('should only mirror specific downstream plugin statuses to corresponding upstream plugin statuses: ', () => {
beforeEach(() => {
mirrorPluginStatus(upstreamPlugin, downstreamPlugin, 'yellow', 'red');
});
it('yellow', (done) => {
downstreamPlugin.status.on('change', () => {
clearTimeout(eventNotEmittedTimeout);
expect(downstreamPlugin.status.state).to.be('yellow');
expect(downstreamPlugin.status.message).to.be('test yellow message');
done();
});
upstreamPlugin.status.yellow('test yellow message');
});
it('red', (done) => {
downstreamPlugin.status.on('change', () => {
clearTimeout(eventNotEmittedTimeout);
expect(downstreamPlugin.status.state).to.be('red');
expect(downstreamPlugin.status.message).to.be('test red message');
done();
});
upstreamPlugin.status.red('test red message');
});
it('not green', () => {
clearTimeout(eventNotEmittedTimeout); // because event should not be emitted in this test
downstreamPlugin.status.on('change', () => {
throw new Error('Event should NOT have been emitted');
});
upstreamPlugin.status.green('test green message');
});
});
});

View file

@ -1,23 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export function mirrorPluginStatus(upstreamPlugin, downstreamPlugin, ...statesToMirror) {
upstreamPlugin.status.setMaxListeners(21); // We need more than the default, which is 10
function mirror(previousState, previousMsg, newState, newMsg) {
// eslint-disable-line no-unused-vars
if (newState) {
downstreamPlugin.status[newState](newMsg);
}
}
if (statesToMirror.length === 0) {
statesToMirror.push('change');
}
statesToMirror.map((state) => upstreamPlugin.status.on(state, mirror));
mirror(null, null, upstreamPlugin.status.state, upstreamPlugin.status.message); // initial mirroring
}

View file

@ -4,21 +4,31 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { mirrorPluginStatus } from '../mirror_plugin_status';
import { pairwise } from 'rxjs/operators';
import { ServiceStatusLevels } from '../../../../../src/core/server';
import { checkLicense } from '../check_license';
export function registerLicenseChecker(server, pluginId, pluginName, minimumLicenseRequired) {
const xpackMainPlugin = server.plugins.xpack_main;
const thisPlugin = server.plugins[pluginId];
const subscription = server.newPlatform.setup.core.status.core$
.pipe(pairwise())
.subscribe(([coreLast, coreCurrent]) => {
if (
!subscription.closed &&
coreLast.elasticsearch.level !== ServiceStatusLevels.available &&
coreCurrent.elasticsearch.level === ServiceStatusLevels.available
) {
// Unsubscribe as soon as ES becomes available so this function only runs once
subscription.unsubscribe();
mirrorPluginStatus(xpackMainPlugin, thisPlugin);
xpackMainPlugin.status.once('green', () => {
// Register a function that is called whenever the xpack info changes,
// to re-compute the license check results for this plugin
xpackMainPlugin.info
.feature(pluginId)
.registerLicenseCheckResultsGenerator((xpackLicenseInfo) => {
return checkLicense(pluginName, minimumLicenseRequired, xpackLicenseInfo);
});
});
// Register a function that is called whenever the xpack info changes,
// to re-compute the license check results for this plugin
xpackMainPlugin.info
.feature(pluginId)
.registerLicenseCheckResultsGenerator((xpackLicenseInfo) => {
return checkLicense(pluginName, minimumLicenseRequired, xpackLicenseInfo);
});
}
});
}

View file

@ -2930,12 +2930,10 @@
"savedObjectsManagement.view.savedSearchDoesNotExistErrorMessage": "このオブジェクトに関連付けられた保存された検索は現在存在しません。",
"savedObjectsManagement.view.viewItemButtonLabel": "{title}を表示",
"savedObjectsManagement.view.viewItemTitle": "{title}を表示",
"server.stats.notReadyMessage": "まだ統計が準備できていません。後程再試行してください",
"server.status.disabledTitle": "無効",
"server.status.greenTitle": "緑",
"server.status.redTitle": "赤",
"server.status.uninitializedTitle": "アンインストールしました",
"server.status.yellowTitle": "黄色",
"usageCollection.stats.notReadyMessage": "まだ統計が準備できていません。後程再試行してください",
"core.status.greenTitle": "緑",
"core.status.redTitle": "赤",
"core.status.yellowTitle": "黄色",
"share.advancedSettings.csv.quoteValuesText": "csvエクスポートに値を引用するかどうかです",
"share.advancedSettings.csv.quoteValuesTitle": "CSVの値を引用",
"share.advancedSettings.csv.separatorText": "エクスポートされた値をこの文字列で区切ります",

View file

@ -2931,12 +2931,10 @@
"savedObjectsManagement.view.savedSearchDoesNotExistErrorMessage": "与此对象关联的已保存搜索已不存在。",
"savedObjectsManagement.view.viewItemButtonLabel": "查看“{title}”",
"savedObjectsManagement.view.viewItemTitle": "查看“{title}”",
"server.stats.notReadyMessage": "统计尚未就绪。请稍后重试",
"server.status.disabledTitle": "已禁用",
"server.status.greenTitle": "绿",
"server.status.redTitle": "红",
"server.status.uninitializedTitle": "未初始化",
"server.status.yellowTitle": "黄",
"usageCollection.stats.notReadyMessage": "统计尚未就绪。请稍后重试",
"core.status.greenTitle": "绿",
"core.status.redTitle": "红",
"core.status.yellowTitle": "黄",
"share.advancedSettings.csv.quoteValuesText": "在 CSV 导出中是否应使用引号引起值?",
"share.advancedSettings.csv.quoteValuesTitle": "使用引号引起 CSV 值",
"share.advancedSettings.csv.separatorText": "使用此字符串分隔导出的值",