Move Cloud Integrations out of the cloud plugin (#141103)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Alejandro Fernández Haro 2022-10-04 12:25:25 +02:00 committed by GitHub
parent 10884e6a5f
commit 74f30dcf8e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
118 changed files with 1914 additions and 1270 deletions

View file

@ -15,7 +15,7 @@ const STORYBOOKS = [
'apm',
'canvas',
'ci_composite',
'cloud',
'cloud_chat',
'coloring',
'chart_icons',
'controls',

View file

@ -424,10 +424,22 @@ The plugin exposes the static DefaultEditorController class to consume.
|The cloud plugin adds Cloud-specific features to Kibana.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_chat/README.md[cloudChat]
|Integrates with DriftChat in order to provide live support to our Elastic Cloud users. This plugin should only run on Elastic Cloud.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx[cloudExperiments]
|The Cloud Experiments Service provides the necessary APIs to implement A/B testing scenarios, fetching the variations in configuration and reporting back metrics to track conversion rates of the experiments.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_full_story/README.md[cloudFullStory]
|Integrates with FullStory in order to provide better product analytics, so we can understand how our users make use of Kibana. This plugin should only run on Elastic Cloud.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_links/README.md[cloudLinks]
|Adds all the links to the Elastic Cloud console.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_security_posture/README.md[cloudSecurityPosture]
|Cloud Posture automates the identification and remediation of risks across cloud infrastructures

View file

@ -10,7 +10,10 @@ pageLoadAssetSize:
cases: 144442
charts: 55000
cloud: 21076
cloudChat: 19894
cloudExperiments: 59358
cloudFullStory: 18493
cloudLinks: 17629
cloudSecurityPosture: 19109
console: 46091
controls: 40000

View file

@ -11,7 +11,7 @@ export const storybookAliases = {
apm: 'x-pack/plugins/apm/.storybook',
canvas: 'x-pack/plugins/canvas/storybook',
ci_composite: '.ci/.storybook',
cloud: 'x-pack/plugins/cloud/.storybook',
cloud_chat: 'x-pack/plugins/cloud_integrations/cloud_chat/.storybook',
coloring: 'packages/kbn-coloring/.storybook',
chart_icons: 'packages/kbn-chart-icons/.storybook',
content_management: 'packages/content-management/.storybook',

View file

@ -8,6 +8,6 @@
"server": true,
"ui": true,
"requiredPlugins": ["dataViews", "share", "urlForwarding"],
"optionalPlugins": ["usageCollection", "customIntegrations"],
"optionalPlugins": ["usageCollection", "customIntegrations", "cloud"],
"requiredBundles": ["kibanaReact"]
}

View file

@ -11,6 +11,7 @@ import { HomePublicPlugin } from './plugin';
import { coreMock } from '@kbn/core/public/mocks';
import { urlForwardingPluginMock } from '@kbn/url-forwarding-plugin/public/mocks';
import { SharePluginSetup } from '@kbn/share-plugin/public';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
const mockInitializerContext = coreMock.createPluginInitializerContext();
const mockShare = {} as SharePluginSetup;
@ -24,14 +25,11 @@ describe('HomePublicPlugin', () => {
});
describe('setup', () => {
test('registers tutorial directory to feature catalogue', async () => {
const setup = await new HomePublicPlugin(mockInitializerContext).setup(
coreMock.createSetup() as any,
{
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
}
);
test('registers tutorial directory to feature catalogue', () => {
const setup = new HomePublicPlugin(mockInitializerContext).setup(coreMock.createSetup(), {
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
});
expect(setup).toHaveProperty('featureCatalogue');
expect(setup.featureCatalogue.register).toHaveBeenCalledTimes(1);
expect(setup.featureCatalogue.register).toHaveBeenCalledWith(
@ -44,53 +42,73 @@ describe('HomePublicPlugin', () => {
);
});
test('wires up and returns registry', async () => {
const setup = await new HomePublicPlugin(mockInitializerContext).setup(
coreMock.createSetup() as any,
{
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
}
);
test('wires up and returns registry', () => {
const setup = new HomePublicPlugin(mockInitializerContext).setup(coreMock.createSetup(), {
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
});
expect(setup).toHaveProperty('featureCatalogue');
expect(setup.featureCatalogue).toHaveProperty('register');
});
test('wires up and returns environment service', async () => {
const setup = await new HomePublicPlugin(mockInitializerContext).setup(
coreMock.createSetup() as any,
{
share: {} as SharePluginSetup,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
}
);
test('wires up and returns environment service', () => {
const setup = new HomePublicPlugin(mockInitializerContext).setup(coreMock.createSetup(), {
share: {} as SharePluginSetup,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
});
expect(setup).toHaveProperty('environment');
expect(setup.environment).toHaveProperty('update');
});
test('wires up and returns tutorial service', async () => {
const setup = await new HomePublicPlugin(mockInitializerContext).setup(
coreMock.createSetup() as any,
{
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
}
);
test('wires up and returns tutorial service', () => {
const setup = new HomePublicPlugin(mockInitializerContext).setup(coreMock.createSetup(), {
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
});
expect(setup).toHaveProperty('tutorials');
expect(setup.tutorials).toHaveProperty('setVariable');
});
test('wires up and returns welcome service', async () => {
const setup = await new HomePublicPlugin(mockInitializerContext).setup(
coreMock.createSetup() as any,
{
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
}
);
test('wires up and returns welcome service', () => {
const setup = new HomePublicPlugin(mockInitializerContext).setup(coreMock.createSetup(), {
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
});
expect(setup).toHaveProperty('welcomeScreen');
expect(setup.welcomeScreen).toHaveProperty('registerOnRendered');
expect(setup.welcomeScreen).toHaveProperty('registerTelemetryNoticeRenderer');
});
test('sets the cloud environment variable when the cloud plugin is present but isCloudEnabled: false', () => {
const cloud = { ...cloudMock.createSetup(), isCloudEnabled: false };
const plugin = new HomePublicPlugin(mockInitializerContext);
const setup = plugin.setup(coreMock.createSetup(), {
cloud,
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
});
expect(setup.environment.update).toHaveBeenCalledTimes(1);
expect(setup.environment.update).toHaveBeenCalledWith({ cloud: false });
expect(setup.tutorials.setVariable).toHaveBeenCalledTimes(0);
});
test('when cloud is enabled, it sets the cloud environment and the tutorials variable "cloud"', () => {
const cloud = { ...cloudMock.createSetup(), isCloudEnabled: true };
const plugin = new HomePublicPlugin(mockInitializerContext);
const setup = plugin.setup(coreMock.createSetup(), {
cloud,
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
});
expect(setup.environment.update).toHaveBeenCalledTimes(1);
expect(setup.environment.update).toHaveBeenCalledWith({ cloud: true });
expect(setup.tutorials.setVariable).toHaveBeenCalledTimes(1);
expect(setup.tutorials.setVariable).toHaveBeenCalledWith('cloud', {
id: 'mock-cloud-id',
baseUrl: 'base-url',
deploymentUrl: 'deployment-url',
profileUrl: 'profile-url',
});
});
});
});

View file

@ -20,6 +20,7 @@ import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { UrlForwardingSetup, UrlForwardingStart } from '@kbn/url-forwarding-plugin/public';
import { AppNavLinkStatus } from '@kbn/core/public';
import { SharePluginSetup } from '@kbn/share-plugin/public';
import type { CloudSetup } from '@kbn/cloud-plugin/public';
import { PLUGIN_ID, HOME_APP_BASE_PATH } from '../common/constants';
import { setServices } from './application/kibana_services';
import { ConfigSchema } from '../config';
@ -42,6 +43,7 @@ export interface HomePluginStartDependencies {
}
export interface HomePluginSetupDependencies {
cloud?: CloudSetup;
share: SharePluginSetup;
usageCollection?: UsageCollectionSetup;
urlForwarding: UrlForwardingSetup;
@ -66,7 +68,7 @@ export class HomePublicPlugin
public setup(
core: CoreSetup<HomePluginStartDependencies>,
{ share, urlForwarding, usageCollection }: HomePluginSetupDependencies
{ cloud, share, urlForwarding, usageCollection }: HomePluginSetupDependencies
): HomePublicPluginSetup {
core.application.register({
id: PLUGIN_ID,
@ -127,10 +129,25 @@ export class HomePublicPlugin
order: 500,
});
const environment = { ...this.environmentService.setup() };
const tutorials = { ...this.tutorialService.setup() };
if (cloud) {
environment.update({ cloud: cloud.isCloudEnabled });
if (cloud.isCloudEnabled) {
tutorials.setVariable('cloud', {
id: cloud.cloudId,
baseUrl: cloud.baseUrl,
// Cloud's API already provides the full URLs
profileUrl: cloud.profileUrl?.replace(cloud.baseUrl ?? '', ''),
deploymentUrl: cloud.deploymentUrl?.replace(cloud.baseUrl ?? '', ''),
});
}
}
return {
featureCatalogue,
environment: { ...this.environmentService.setup() },
tutorials: { ...this.tutorialService.setup() },
environment,
tutorials,
addData: { ...this.addDataService.setup() },
welcomeScreen: { ...this.welcomeService.setup() },
};

View file

@ -18,14 +18,13 @@ const createSetupMock = (): jest.Mocked<EnvironmentServiceSetup> => {
const createMock = (): jest.Mocked<PublicMethodsOf<EnvironmentService>> => {
const service = {
setup: jest.fn(),
setup: jest.fn(createSetupMock),
getEnvironment: jest.fn(() => ({
cloud: false,
apmUi: false,
ml: false,
})),
};
service.setup.mockImplementation(createSetupMock);
return service;
};

View file

@ -15,6 +15,7 @@
{ "path": "../kibana_react/tsconfig.json" },
{ "path": "../share/tsconfig.json" },
{ "path": "../url_forwarding/tsconfig.json" },
{ "path": "../usage_collection/tsconfig.json" }
{ "path": "../usage_collection/tsconfig.json" },
{ "path": "../../../x-pack/plugins/cloud/tsconfig.json" }
]
}

View file

@ -60,6 +60,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
'--corePluginDeprecations.noLongerUsed=still_using',
// for testing set buffer duration to 0 to immediately flush counters into saved objects.
'--usageCollection.usageCounters.bufferDuration=0',
// explicitly enable the cloud integration plugins to validate the rendered config keys
'--xpack.cloud_integrations.chat.enabled=true',
'--xpack.cloud_integrations.chat.chatURL=a_string',
'--xpack.cloud_integrations.experiments.enabled=true',
'--xpack.cloud_integrations.experiments.launch_darkly.sdk_key=a_string',
'--xpack.cloud_integrations.experiments.launch_darkly.client_id=a_string',
'--xpack.cloud_integrations.full_story.enabled=true',
'--xpack.cloud_integrations.full_story.org_id=a_string',
...plugins.map(
(pluginDir) => `--plugin-path=${path.resolve(__dirname, 'plugins', pluginDir)}`
),

View file

@ -171,14 +171,17 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.cases.markdownPlugins.lens (boolean)',
'xpack.ccr.ui.enabled (boolean)',
'xpack.cloud.base_url (string)',
'xpack.cloud.chat.chatURL (string)',
'xpack.cloud.chat.enabled (boolean)',
'xpack.cloud.cname (string)',
'xpack.cloud.deployment_url (string)',
'xpack.cloud.full_story.enabled (boolean)',
'xpack.cloud.full_story.org_id (any)',
'xpack.cloud_integrations.chat.chatURL (string)',
// No PII. This is an escape patch to override LaunchDarkly's flag resolution mechanism for testing or quick fix.
'xpack.cloud_integrations.experiments.flag_overrides (record)',
// Commented because it's inside a schema conditional, and the test is not able to resolve it. But it's shared.
// Added here for documentation purposes.
// 'xpack.cloud_integrations.experiments.launch_darkly.client_id (string)',
'xpack.cloud_integrations.full_story.org_id (any)',
// No PII. Just the list of event types we want to forward to FullStory.
'xpack.cloud.full_story.eventTypesAllowlist (array)',
'xpack.cloud_integrations.full_story.eventTypesAllowlist (array)',
'xpack.cloud.id (string)',
'xpack.cloud.organization_url (string)',
'xpack.cloud.profile_url (string)',

View file

@ -313,8 +313,14 @@
"@kbn/canvas-plugin/*": ["x-pack/plugins/canvas/*"],
"@kbn/cases-plugin": ["x-pack/plugins/cases"],
"@kbn/cases-plugin/*": ["x-pack/plugins/cases/*"],
"@kbn/cloud-chat-plugin": ["x-pack/plugins/cloud_integrations/cloud_chat"],
"@kbn/cloud-chat-plugin/*": ["x-pack/plugins/cloud_integrations/cloud_chat/*"],
"@kbn/cloud-experiments-plugin": ["x-pack/plugins/cloud_integrations/cloud_experiments"],
"@kbn/cloud-experiments-plugin/*": ["x-pack/plugins/cloud_integrations/cloud_experiments/*"],
"@kbn/cloud-full-story-plugin": ["x-pack/plugins/cloud_integrations/cloud_full_story"],
"@kbn/cloud-full-story-plugin/*": ["x-pack/plugins/cloud_integrations/cloud_full_story/*"],
"@kbn/cloud-links-plugin": ["x-pack/plugins/cloud_integrations/cloud_links"],
"@kbn/cloud-links-plugin/*": ["x-pack/plugins/cloud_integrations/cloud_links/*"],
"@kbn/cloud-security-posture-plugin": ["x-pack/plugins/cloud_security_posture"],
"@kbn/cloud-security-posture-plugin/*": ["x-pack/plugins/cloud_security_posture/*"],
"@kbn/cloud-plugin": ["x-pack/plugins/cloud"],

View file

@ -10,6 +10,8 @@
"xpack.canvas": "plugins/canvas",
"xpack.cases": "plugins/cases",
"xpack.cloud": "plugins/cloud",
"xpack.cloudChat": "plugins/cloud_integrations/cloud_chat",
"xpack.cloudLinks": "plugins/cloud_integrations/cloud_links",
"xpack.csp": "plugins/cloud_security_posture",
"xpack.dashboard": "plugins/dashboard_enhanced",
"xpack.discover": "plugins/discover_enhanced",

View file

@ -6,7 +6,6 @@
*/
export const ELASTIC_SUPPORT_LINK = 'https://cloud.elastic.co/support';
export const GET_CHAT_USER_DATA_ROUTE_PATH = '/internal/cloud/chat_user';
/**
* This is the page for managing your snapshots on Cloud.

View file

@ -7,7 +7,7 @@
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "cloud"],
"optionalPlugins": ["cloudExperiments", "usageCollection", "home", "security"],
"optionalPlugins": ["usageCollection"],
"server": true,
"ui": true
}

View file

@ -13,5 +13,3 @@ export type { CloudSetup, CloudConfigType, CloudStart } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudPlugin(initializerContext);
}
export { Chat } from './components';

View file

@ -8,7 +8,6 @@
import React from 'react';
import { CloudStart } from '.';
import { ServicesProvider } from './services';
function createSetupMock() {
return {
@ -19,28 +18,22 @@ function createSetupMock() {
deploymentUrl: 'deployment-url',
profileUrl: 'profile-url',
organizationUrl: 'organization-url',
registerCloudService: jest.fn(),
};
}
const config = {
chat: {
enabled: true,
chatURL: 'chat-url',
user: {
id: 'user-id',
email: 'test-user@elastic.co',
jwt: 'identity-jwt',
},
},
};
const getContextProvider: () => React.FC =
() =>
({ children }) =>
<ServicesProvider {...config}>{children}</ServicesProvider>;
<>{children}</>;
const createStartMock = (): jest.Mocked<CloudStart> => ({
CloudContextProvider: jest.fn(getContextProvider()),
cloudId: 'mock-cloud-id',
isCloudEnabled: true,
deploymentUrl: 'deployment-url',
profileUrl: 'profile-url',
organizationUrl: 'organization-url',
});
export const cloudMock = {

View file

@ -5,308 +5,18 @@
* 2.0.
*/
import { firstValueFrom } from 'rxjs';
import { Sha256 } from '@kbn/crypto-browser';
import { nextTick } from '@kbn/test-jest-helpers';
import { coreMock } from '@kbn/core/public/mocks';
import { homePluginMock } from '@kbn/home-plugin/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { CloudPlugin, type CloudConfigType } from './plugin';
import { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common';
import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks';
import { CloudPlugin } from './plugin';
const baseConfig = {
base_url: 'https://cloud.elastic.co',
deployment_url: '/abc123',
profile_url: '/user/settings/',
organization_url: '/account/',
full_story: {
enabled: false,
},
chat: {
enabled: false,
},
};
describe('Cloud Plugin', () => {
describe('#setup', () => {
describe('setupFullStory', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const setupPlugin = async ({ config = {} }: { config?: Partial<CloudConfigType> }) => {
const initContext = coreMock.createPluginInitializerContext({
...baseConfig,
id: 'cloudId',
...config,
});
const plugin = new CloudPlugin(initContext);
const coreSetup = coreMock.createSetup();
const setup = plugin.setup(coreSetup, {});
// Wait for FullStory dynamic import to resolve
await new Promise((r) => setImmediate(r));
return { initContext, plugin, setup, coreSetup };
};
test('register the shipper FullStory with correct args when enabled and org_id are set', async () => {
const { coreSetup } = await setupPlugin({
config: { full_story: { enabled: true, org_id: 'foo' } },
});
expect(coreSetup.analytics.registerShipper).toHaveBeenCalled();
expect(coreSetup.analytics.registerShipper).toHaveBeenCalledWith(expect.anything(), {
fullStoryOrgId: 'foo',
scriptUrl: '/internal/cloud/100/fullstory.js',
namespace: 'FSKibana',
});
});
it('does not call initializeFullStory when enabled=false', async () => {
const { coreSetup } = await setupPlugin({
config: { full_story: { enabled: false, org_id: 'foo' } },
});
expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled();
});
it('does not call initializeFullStory when org_id is undefined', async () => {
const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true } } });
expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled();
});
});
describe('setupTelemetryContext', () => {
const username = '1234';
const expectedHashedPlainUsername = new Sha256().update(username, 'utf8').digest('hex');
beforeEach(() => {
jest.clearAllMocks();
});
const setupPlugin = async ({
config = {},
securityEnabled = true,
currentUserProps = {},
}: {
config?: Partial<CloudConfigType>;
securityEnabled?: boolean;
currentUserProps?: Record<string, any> | Error;
}) => {
const initContext = coreMock.createPluginInitializerContext({
...baseConfig,
...config,
});
const plugin = new CloudPlugin(initContext);
const coreSetup = coreMock.createSetup();
const securitySetup = securityMock.createSetup();
if (currentUserProps instanceof Error) {
securitySetup.authc.getCurrentUser.mockRejectedValue(currentUserProps);
} else {
securitySetup.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser(currentUserProps)
);
}
const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {});
return { initContext, plugin, setup, coreSetup };
};
test('register the context provider for the cloud user with hashed user ID when security is available', async () => {
const { coreSetup } = await setupPlugin({
config: { id: 'cloudId' },
currentUserProps: { username },
});
expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled();
const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find(
([{ name }]) => name === 'cloud_user_id'
)!;
await expect(firstValueFrom(context$)).resolves.toEqual({
userId: '5ef112cfdae3dea57097bc276e275b2816e73ef2a398dc0ffaf5b6b4e3af2041',
isElasticCloudUser: false,
});
});
it('user hash includes cloud id', async () => {
const { coreSetup: coreSetup1 } = await setupPlugin({
config: { id: 'esOrg1' },
currentUserProps: { username },
});
const [{ context$: context1$ }] =
coreSetup1.analytics.registerContextProvider.mock.calls.find(
([{ name }]) => name === 'cloud_user_id'
)!;
const { userId: hashId1 } = (await firstValueFrom(context1$)) as { userId: string };
expect(hashId1).not.toEqual(expectedHashedPlainUsername);
const { coreSetup: coreSetup2 } = await setupPlugin({
config: { full_story: { enabled: true, org_id: 'foo' }, id: 'esOrg2' },
currentUserProps: { username },
});
const [{ context$: context2$ }] =
coreSetup2.analytics.registerContextProvider.mock.calls.find(
([{ name }]) => name === 'cloud_user_id'
)!;
const { userId: hashId2 } = (await firstValueFrom(context2$)) as { userId: string };
expect(hashId2).not.toEqual(expectedHashedPlainUsername);
expect(hashId1).not.toEqual(hashId2);
});
test('user hash does not include cloudId when user is an Elastic Cloud user', async () => {
const { coreSetup } = await setupPlugin({
config: { id: 'cloudDeploymentId' },
currentUserProps: { username, elastic_cloud_user: true },
});
expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled();
const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find(
([{ name }]) => name === 'cloud_user_id'
)!;
await expect(firstValueFrom(context$)).resolves.toEqual({
userId: expectedHashedPlainUsername,
isElasticCloudUser: true,
});
});
test('user hash does not include cloudId when not provided', async () => {
const { coreSetup } = await setupPlugin({
config: {},
currentUserProps: { username },
});
expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled();
const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find(
([{ name }]) => name === 'cloud_user_id'
)!;
await expect(firstValueFrom(context$)).resolves.toEqual({
userId: expectedHashedPlainUsername,
isElasticCloudUser: false,
});
});
test('user hash is undefined when failed to fetch a user', async () => {
const { coreSetup } = await setupPlugin({
currentUserProps: new Error('failed to fetch a user'),
});
expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled();
const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find(
([{ name }]) => name === 'cloud_user_id'
)!;
await expect(firstValueFrom(context$)).resolves.toEqual({
userId: undefined,
isElasticCloudUser: false,
});
});
});
describe('setupChat', () => {
let consoleMock: jest.SpyInstance<void, [message?: any, ...optionalParams: any[]]>;
beforeEach(() => {
consoleMock = jest.spyOn(console, 'debug').mockImplementation(() => {});
});
afterEach(() => {
consoleMock.mockRestore();
});
const setupPlugin = async ({
config = {},
securityEnabled = true,
currentUserProps = {},
isCloudEnabled = true,
failHttp = false,
}: {
config?: Partial<CloudConfigType>;
securityEnabled?: boolean;
currentUserProps?: Record<string, any>;
isCloudEnabled?: boolean;
failHttp?: boolean;
}) => {
const initContext = coreMock.createPluginInitializerContext({
...baseConfig,
id: isCloudEnabled ? 'cloud-id' : null,
...config,
});
const plugin = new CloudPlugin(initContext);
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
if (failHttp) {
coreSetup.http.get.mockImplementation(() => {
throw new Error('HTTP request failed');
});
}
coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]);
const securitySetup = securityMock.createSetup();
securitySetup.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser(currentUserProps)
);
const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {});
return { initContext, plugin, setup, coreSetup };
};
it('chatConfig is not retrieved if cloud is not enabled', async () => {
const { coreSetup } = await setupPlugin({ isCloudEnabled: false });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if security is not enabled', async () => {
const { coreSetup } = await setupPlugin({ securityEnabled: false });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if chat is enabled but url is not provided', async () => {
// @ts-expect-error 2741
const { coreSetup } = await setupPlugin({ config: { chat: { enabled: true } } });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if internal API fails', async () => {
const { coreSetup } = await setupPlugin({
config: { chat: { enabled: true, chatURL: 'http://chat.elastic.co' } },
failHttp: true,
});
expect(coreSetup.http.get).toHaveBeenCalled();
expect(consoleMock).toHaveBeenCalled();
});
it('chatConfig is retrieved if chat is enabled and url is provided', async () => {
const { coreSetup } = await setupPlugin({
config: { chat: { enabled: true, chatURL: 'http://chat.elastic.co' } },
});
expect(coreSetup.http.get).toHaveBeenCalled();
});
});
describe('interface', () => {
const setupPlugin = () => {
const initContext = coreMock.createPluginInitializerContext({
@ -317,7 +27,7 @@ describe('Cloud Plugin', () => {
const plugin = new CloudPlugin(initContext);
const coreSetup = coreMock.createSetup();
const setup = plugin.setup(coreSetup, {});
const setup = plugin.setup(coreSetup);
return { setup };
};
@ -361,49 +71,10 @@ describe('Cloud Plugin', () => {
const { setup } = setupPlugin();
expect(setup.cname).toBe('cloud.elastic.co');
});
});
describe('Set up cloudExperiments', () => {
describe('when cloud ID is not provided in the config', () => {
let cloudExperiments: jest.Mocked<CloudExperimentsPluginSetup>;
beforeEach(() => {
const plugin = new CloudPlugin(coreMock.createPluginInitializerContext(baseConfig));
cloudExperiments = cloudExperimentsMock.createSetupMock();
plugin.setup(coreMock.createSetup(), { cloudExperiments });
});
test('does not call cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser).not.toHaveBeenCalled();
});
});
describe('when cloud ID is provided in the config', () => {
let cloudExperiments: jest.Mocked<CloudExperimentsPluginSetup>;
beforeEach(() => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext({ ...baseConfig, id: 'cloud test' })
);
cloudExperiments = cloudExperimentsMock.createSetupMock();
plugin.setup(coreMock.createSetup(), { cloudExperiments });
});
test('calls cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser).toHaveBeenCalledTimes(1);
});
test('the cloud ID is hashed when calling cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser.mock.calls[0][0]).toEqual(
'1acb4a1cc1c3d672a8d826055d897c2623ceb1d4fb07e46d97986751a36b06cf'
);
});
test('specifies the Kibana version when calling cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser.mock.calls[0][1]).toEqual(
expect.objectContaining({
kibanaVersion: 'version',
})
);
});
it('exposes registerCloudService', () => {
const { setup } = setupPlugin();
expect(setup.registerCloudService).toBeDefined();
});
});
});
@ -426,9 +97,8 @@ describe('Cloud Plugin', () => {
})
);
const coreSetup = coreMock.createSetup();
const homeSetup = homePluginMock.createSetupContract();
plugin.setup(coreSetup, { home: homeSetup });
plugin.setup(coreSetup);
return { coreSetup, plugin };
};
@ -437,8 +107,7 @@ describe('Cloud Plugin', () => {
const { plugin } = startPlugin();
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
plugin.start(coreStart, { security: securityStart });
plugin.start(coreStart);
expect(coreStart.chrome.setHelpSupportUrl).toHaveBeenCalledTimes(1);
expect(coreStart.chrome.setHelpSupportUrl.mock.calls[0]).toMatchInlineSnapshot(`
@ -447,177 +116,5 @@ describe('Cloud Plugin', () => {
]
`);
});
it('does not register custom nav links on anonymous pages', async () => {
const { plugin } = startPlugin();
const coreStart = coreMock.createStart();
coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true);
const securityStart = securityMock.createStart();
securityStart.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser({
elastic_cloud_user: true,
})
);
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(coreStart.chrome.setCustomNavLink).not.toHaveBeenCalled();
expect(securityStart.authc.getCurrentUser).not.toHaveBeenCalled();
});
it('registers a custom nav link for cloud users', async () => {
const { plugin } = startPlugin();
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
securityStart.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser({
elastic_cloud_user: true,
})
);
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(coreStart.chrome.setCustomNavLink).toHaveBeenCalledTimes(1);
expect(coreStart.chrome.setCustomNavLink.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"euiIconType": "logoCloud",
"href": "https://cloud.elastic.co/abc123",
"title": "Manage this deployment",
},
]
`);
});
it('registers a custom nav link when there is an error retrieving the current user', async () => {
const { plugin } = startPlugin();
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
securityStart.authc.getCurrentUser.mockRejectedValue(new Error('something happened'));
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(coreStart.chrome.setCustomNavLink).toHaveBeenCalledTimes(1);
expect(coreStart.chrome.setCustomNavLink.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"euiIconType": "logoCloud",
"href": "https://cloud.elastic.co/abc123",
"title": "Manage this deployment",
},
]
`);
});
it('does not register a custom nav link for non-cloud users', async () => {
const { plugin } = startPlugin();
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
securityStart.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser({
elastic_cloud_user: false,
})
);
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(coreStart.chrome.setCustomNavLink).not.toHaveBeenCalled();
});
it('registers user profile links for cloud users', async () => {
const { plugin } = startPlugin();
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
securityStart.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser({
elastic_cloud_user: true,
})
);
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(securityStart.navControlService.addUserMenuLinks).toHaveBeenCalledTimes(1);
expect(securityStart.navControlService.addUserMenuLinks.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"href": "https://cloud.elastic.co/profile/alice",
"iconType": "user",
"label": "Edit profile",
"order": 100,
"setAsProfile": true,
},
Object {
"href": "https://cloud.elastic.co/org/myOrg",
"iconType": "gear",
"label": "Account & Billing",
"order": 200,
},
],
]
`);
});
it('registers profile links when there is an error retrieving the current user', async () => {
const { plugin } = startPlugin();
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
securityStart.authc.getCurrentUser.mockRejectedValue(new Error('something happened'));
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(securityStart.navControlService.addUserMenuLinks).toHaveBeenCalledTimes(1);
expect(securityStart.navControlService.addUserMenuLinks.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"href": "https://cloud.elastic.co/profile/alice",
"iconType": "user",
"label": "Edit profile",
"order": 100,
"setAsProfile": true,
},
Object {
"href": "https://cloud.elastic.co/org/myOrg",
"iconType": "gear",
"label": "Account & Billing",
"order": 200,
},
],
]
`);
});
it('does not register profile links for non-cloud users', async () => {
const { plugin } = startPlugin();
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
securityStart.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser({
elastic_cloud_user: false,
})
);
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(securityStart.navControlService.addUserMenuLinks).not.toHaveBeenCalled();
});
});
});

View file

@ -6,34 +6,12 @@
*/
import React, { FC } from 'react';
import type {
CoreSetup,
CoreStart,
Plugin,
PluginInitializerContext,
HttpStart,
IBasePath,
AnalyticsServiceSetup,
} from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject, catchError, from, map, of } from 'rxjs';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import { Sha256 } from '@kbn/crypto-browser';
import type { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common';
import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import {
ELASTIC_SUPPORT_LINK,
CLOUD_SNAPSHOTS_PATH,
GET_CHAT_USER_DATA_ROUTE_PATH,
} from '../common/constants';
import type { GetChatUserDataResponseBody } from '../common/types';
import { createUserMenuLinks } from './user_menu_links';
import { ELASTIC_SUPPORT_LINK, CLOUD_SNAPSHOTS_PATH } from '../common/constants';
import { getFullCloudUrl } from './utils';
import { ChatConfig, ServicesProvider } from './services';
export interface CloudConfigType {
id?: string;
@ -47,23 +25,6 @@ export interface CloudConfigType {
org_id?: string;
eventTypesAllowlist?: string[];
};
/** Configuration to enable live chat in Cloud-enabled instances of Kibana. */
chat: {
/** Determines if chat is enabled. */
enabled: boolean;
/** The URL to the remotely-hosted chat application. */
chatURL: string;
};
}
interface CloudSetupDependencies {
home?: HomePublicPluginSetup;
security?: Pick<SecurityPluginSetup, 'authc'>;
cloudExperiments?: CloudExperimentsPluginSetup;
}
interface CloudStartDependencies {
security?: SecurityPluginStart;
}
export interface CloudStart {
@ -71,6 +32,26 @@ export interface CloudStart {
* A React component that provides a pre-wired `React.Context` which connects components to Cloud services.
*/
CloudContextProvider: FC<{}>;
/**
* `true` when Kibana is running on Elastic Cloud.
*/
isCloudEnabled: boolean;
/**
* Cloud ID. Undefined if not running on Cloud.
*/
cloudId?: string;
/**
* The full URL to the deployment management page on Elastic Cloud. Undefined if not running on Cloud.
*/
deploymentUrl?: string;
/**
* The full URL to the user profile page on Elastic Cloud. Undefined if not running on Cloud.
*/
profileUrl?: string;
/**
* The full URL to the organization management page on Elastic Cloud. Undefined if not running on Cloud.
*/
organizationUrl?: string;
}
export interface CloudSetup {
@ -82,268 +63,93 @@ export interface CloudSetup {
organizationUrl?: string;
snapshotsUrl?: string;
isCloudEnabled: boolean;
registerCloudService: (contextProvider: FC) => void;
}
interface SetupFullStoryDeps {
analytics: AnalyticsServiceSetup;
basePath: IBasePath;
}
interface SetupChatDeps extends Pick<CloudSetupDependencies, 'security'> {
http: CoreSetup['http'];
interface CloudUrls {
deploymentUrl?: string;
profileUrl?: string;
organizationUrl?: string;
snapshotsUrl?: string;
}
export class CloudPlugin implements Plugin<CloudSetup> {
private readonly config: CloudConfigType;
private readonly isCloudEnabled: boolean;
private chatConfig$ = new BehaviorSubject<ChatConfig>({ enabled: false });
private readonly contextProviders: FC[] = [];
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<CloudConfigType>();
this.isCloudEnabled = getIsCloudEnabled(this.config.id);
}
public setup(core: CoreSetup, { cloudExperiments, home, security }: CloudSetupDependencies) {
this.setupTelemetryContext(core.analytics, security, this.config.id);
public setup(core: CoreSetup): CloudSetup {
registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id);
this.setupFullStory({ analytics: core.analytics, basePath: core.http.basePath }).catch((e) =>
// eslint-disable-next-line no-console
console.debug(`Error setting up FullStory: ${e.toString()}`)
);
const { id, cname, base_url: baseUrl } = this.config;
const {
id,
return {
cloudId: id,
cname,
baseUrl,
...this.getCloudUrls(),
isCloudEnabled: this.isCloudEnabled,
registerCloudService: (contextProvider) => {
this.contextProviders.push(contextProvider);
},
};
}
public start(coreStart: CoreStart): CloudStart {
coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK);
// Nest all the registered context providers under the Cloud Services Provider.
// This way, plugins only need to require Cloud's context provider to have all the enriched Cloud services.
const CloudContextProvider: FC = ({ children }) => {
return (
<>
{this.contextProviders.reduce(
(acc, ContextProvider) => (
<ContextProvider> {acc} </ContextProvider>
),
children
)}
</>
);
};
const { deploymentUrl, profileUrl, organizationUrl } = this.getCloudUrls();
return {
CloudContextProvider,
isCloudEnabled: this.isCloudEnabled,
cloudId: this.config.id,
deploymentUrl,
profileUrl,
organizationUrl,
};
}
public stop() {}
private getCloudUrls(): CloudUrls {
const {
profile_url: profileUrl,
organization_url: organizationUrl,
deployment_url: deploymentUrl,
base_url: baseUrl,
} = this.config;
if (this.isCloudEnabled && id) {
// We use the Hashed Cloud Deployment ID as the userId in the Cloud Experiments
cloudExperiments?.identifyUser(sha256(id), {
kibanaVersion: this.initializerContext.env.packageInfo.version,
});
}
this.setupChat({ http: core.http, security }).catch((e) =>
// eslint-disable-next-line no-console
console.debug(`Error setting up Chat: ${e.toString()}`)
);
if (home) {
home.environment.update({ cloud: this.isCloudEnabled });
if (this.isCloudEnabled) {
home.tutorials.setVariable('cloud', { id, baseUrl, profileUrl, deploymentUrl });
}
}
const fullCloudDeploymentUrl = getFullCloudUrl(baseUrl, deploymentUrl);
const fullCloudProfileUrl = getFullCloudUrl(baseUrl, profileUrl);
const fullCloudOrganizationUrl = getFullCloudUrl(baseUrl, organizationUrl);
const fullCloudSnapshotsUrl = `${fullCloudDeploymentUrl}/${CLOUD_SNAPSHOTS_PATH}`;
return {
cloudId: id,
cname,
baseUrl,
deploymentUrl: fullCloudDeploymentUrl,
profileUrl: fullCloudProfileUrl,
organizationUrl: fullCloudOrganizationUrl,
snapshotsUrl: fullCloudSnapshotsUrl,
isCloudEnabled: this.isCloudEnabled,
};
}
public start(coreStart: CoreStart, { security }: CloudStartDependencies): CloudStart {
const { deployment_url: deploymentUrl, base_url: baseUrl } = this.config;
coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK);
const setLinks = (authorized: boolean) => {
if (!authorized) return;
if (baseUrl && deploymentUrl) {
coreStart.chrome.setCustomNavLink({
title: i18n.translate('xpack.cloud.deploymentLinkLabel', {
defaultMessage: 'Manage this deployment',
}),
euiIconType: 'logoCloud',
href: getFullCloudUrl(baseUrl, deploymentUrl),
});
}
if (security && this.isCloudEnabled) {
const userMenuLinks = createUserMenuLinks(this.config);
security.navControlService.addUserMenuLinks(userMenuLinks);
}
};
this.checkIfAuthorizedForLinks({ http: coreStart.http, security })
.then(setLinks)
// In the event of an unexpected error, fail *open*.
// Cloud admin console will always perform the actual authorization checks.
.catch(() => setLinks(true));
// There's a risk that the request for chat config will take too much time to complete, and the provider
// will maintain a stale value. To avoid this, we'll use an Observable.
const CloudContextProvider: FC = ({ children }) => {
const chatConfig = useObservable(this.chatConfig$, { enabled: false });
return <ServicesProvider chat={chatConfig}>{children}</ServicesProvider>;
};
return {
CloudContextProvider,
};
}
public stop() {}
/**
* Determines if the current user should see links back to Cloud.
* This isn't a true authorization check, but rather a heuristic to
* see if the current user is *likely* a cloud deployment administrator.
*
* At this point, we do not have enough information to reliably make this determination,
* but we do know that all cloud deployment admins are superusers by default.
*/
private async checkIfAuthorizedForLinks({
http,
security,
}: {
http: HttpStart;
security?: SecurityPluginStart;
}) {
if (http.anonymousPaths.isAnonymous(window.location.pathname)) {
return false;
}
// Security plugin is disabled
if (!security) return true;
// Otherwise check if user is a cloud user.
// If user is not defined due to an unexpected error, then fail *open*.
// Cloud admin console will always perform the actual authorization checks.
const user = await security.authc.getCurrentUser().catch(() => null);
return user?.elastic_cloud_user ?? true;
}
/**
* If the right config is provided, register the FullStory shipper to the analytics client.
* @param analytics Core's Analytics service's setup contract.
* @param basePath Core's http.basePath helper.
* @private
*/
private async setupFullStory({ analytics, basePath }: SetupFullStoryDeps) {
const { enabled, org_id: fullStoryOrgId, eventTypesAllowlist } = this.config.full_story;
if (!enabled || !fullStoryOrgId) {
return; // do not load any FullStory code in the browser if not enabled
}
// Keep this import async so that we do not load any FullStory code into the browser when it is disabled.
const { FullStoryShipper } = await import('@kbn/analytics-shippers-fullstory');
analytics.registerShipper(FullStoryShipper, {
eventTypesAllowlist,
fullStoryOrgId,
// Load an Elastic-internally audited script. Ideally, it should be hosted on a CDN.
scriptUrl: basePath.prepend(
`/internal/cloud/${this.initializerContext.env.packageInfo.buildNum}/fullstory.js`
),
namespace: 'FSKibana',
});
}
/**
* Set up the Analytics context providers.
* @param analytics Core's Analytics service. The Setup contract.
* @param security The security plugin.
* @param cloudId The Cloud Org ID.
* @private
*/
private setupTelemetryContext(
analytics: AnalyticsServiceSetup,
security?: Pick<SecurityPluginSetup, 'authc'>,
cloudId?: string
) {
registerCloudDeploymentIdAnalyticsContext(analytics, cloudId);
if (security) {
analytics.registerContextProvider({
name: 'cloud_user_id',
context$: from(security.authc.getCurrentUser()).pipe(
map((user) => {
if (user.elastic_cloud_user) {
// If the user is managed by ESS, use the plain username as the user ID:
// The username is expected to be unique for these users,
// and it matches how users are identified in the Cloud UI, so it allows us to correlate them.
return { userId: user.username, isElasticCloudUser: true };
}
return {
// For the rest of the authentication providers, we want to add the cloud deployment ID to make it unique.
// Especially in the case of Elasticsearch-backed authentication, where users are commonly repeated
// across multiple deployments (i.e.: `elastic` superuser).
userId: cloudId ? `${cloudId}:${user.username}` : user.username,
isElasticCloudUser: false,
};
}),
// The hashing here is to keep it at clear as possible in our source code that we do not send literal user IDs
map(({ userId, isElasticCloudUser }) => ({ userId: sha256(userId), isElasticCloudUser })),
catchError(() => of({ userId: undefined, isElasticCloudUser: false }))
),
schema: {
userId: {
type: 'keyword',
_meta: { description: 'The user id scoped as seen by Cloud (hashed)' },
},
isElasticCloudUser: {
type: 'boolean',
_meta: {
description: '`true` if the user is managed by ESS.',
},
},
},
});
}
}
private async setupChat({ http, security }: SetupChatDeps) {
if (!this.isCloudEnabled) {
return;
}
const { enabled, chatURL } = this.config.chat;
if (!security || !enabled || !chatURL) {
return;
}
try {
const {
email,
id,
token: jwt,
} = await http.get<GetChatUserDataResponseBody>(GET_CHAT_USER_DATA_ROUTE_PATH);
if (!email || !id || !jwt) {
return;
}
this.chatConfig$.next({
enabled,
chatURL,
user: {
email,
id,
jwt,
},
});
} catch (e) {
// eslint-disable-next-line no-console
console.debug(`[cloud.chat] Could not retrieve chat config: ${e.res.status} ${e.message}`, e);
}
}
}
function sha256(str: string) {
return new Sha256().update(str, 'utf8').digest('hex');
}

View file

@ -18,32 +18,11 @@ const apmConfigSchema = schema.object({
),
});
const fullStoryConfigSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
org_id: schema.conditional(
schema.siblingRef('enabled'),
true,
schema.string({ minLength: 1 }),
schema.maybe(schema.string())
),
eventTypesAllowlist: schema.arrayOf(schema.string(), {
defaultValue: ['Loaded Kibana'],
}),
});
const chatConfigSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
chatURL: schema.maybe(schema.string()),
});
const configSchema = schema.object({
apm: schema.maybe(apmConfigSchema),
base_url: schema.maybe(schema.string()),
chat: chatConfigSchema,
chatIdentitySecret: schema.maybe(schema.string()),
cname: schema.maybe(schema.string()),
deployment_url: schema.maybe(schema.string()),
full_story: fullStoryConfigSchema,
id: schema.maybe(schema.string()),
organization_url: schema.maybe(schema.string()),
profile_url: schema.maybe(schema.string()),
@ -54,10 +33,8 @@ export type CloudConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<CloudConfigType> = {
exposeToBrowser: {
base_url: true,
chat: true,
cname: true,
deployment_url: true,
full_story: true,
id: true,
organization_url: true,
profile_url: true,

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CloudSetup } from '.';
function createSetupMock(): jest.Mocked<CloudSetup> {
return {
cloudId: 'mock-cloud-id',
instanceSizeMb: 1234,
deploymentId: 'deployment-id',
isCloudEnabled: true,
apm: {
url: undefined,
secretToken: undefined,
},
};
}
export const cloudMock = {
createSetup: createSetupMock,
};

View file

@ -7,111 +7,54 @@
import { coreMock } from '@kbn/core/server/mocks';
import { CloudPlugin } from './plugin';
import { config } from './config';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks';
import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks';
import { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common';
const baseConfig = {
base_url: 'https://cloud.elastic.co',
deployment_url: '/abc123',
profile_url: '/user/settings/',
organization_url: '/account/',
};
describe('Cloud Plugin', () => {
describe('#setup', () => {
describe('setupSecurity', () => {
it('properly handles missing optional Security dependency if Cloud ID is NOT set.', async () => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({}))
);
describe('interface', () => {
const setupPlugin = () => {
const initContext = coreMock.createPluginInitializerContext({
...baseConfig,
id: 'cloudId',
cname: 'cloud.elastic.co',
});
const plugin = new CloudPlugin(initContext);
expect(() =>
plugin.setup(coreMock.createSetup(), {
usageCollection: usageCollectionPluginMock.createSetupContract(),
})
).not.toThrow();
const coreSetup = coreMock.createSetup();
const setup = plugin.setup(coreSetup, {});
return { setup };
};
it('exposes isCloudEnabled', () => {
const { setup } = setupPlugin();
expect(setup.isCloudEnabled).toBe(true);
});
it('properly handles missing optional Security dependency if Cloud ID is set.', async () => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({ id: 'my-cloud' }))
);
expect(() =>
plugin.setup(coreMock.createSetup(), {
usageCollection: usageCollectionPluginMock.createSetupContract(),
})
).not.toThrow();
it('exposes cloudId', () => {
const { setup } = setupPlugin();
expect(setup.cloudId).toBe('cloudId');
});
it('does not notify Security plugin about Cloud environment if Cloud ID is NOT set.', async () => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({}))
);
const securityDependencyMock = securityMock.createSetup();
plugin.setup(coreMock.createSetup(), {
security: securityDependencyMock,
usageCollection: usageCollectionPluginMock.createSetupContract(),
});
expect(securityDependencyMock.setIsElasticCloudDeployment).not.toHaveBeenCalled();
it('exposes instanceSizeMb', () => {
const { setup } = setupPlugin();
expect(setup.instanceSizeMb).toBeUndefined();
});
it('properly notifies Security plugin about Cloud environment if Cloud ID is set.', async () => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({ id: 'my-cloud' }))
);
const securityDependencyMock = securityMock.createSetup();
plugin.setup(coreMock.createSetup(), {
security: securityDependencyMock,
usageCollection: usageCollectionPluginMock.createSetupContract(),
});
expect(securityDependencyMock.setIsElasticCloudDeployment).toHaveBeenCalledTimes(1);
});
});
describe('Set up cloudExperiments', () => {
describe('when cloud ID is not provided in the config', () => {
let cloudExperiments: jest.Mocked<CloudExperimentsPluginSetup>;
beforeEach(() => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({}))
);
cloudExperiments = cloudExperimentsMock.createSetupMock();
plugin.setup(coreMock.createSetup(), { cloudExperiments });
});
test('does not call cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser).not.toHaveBeenCalled();
});
it('exposes deploymentId', () => {
const { setup } = setupPlugin();
expect(setup.deploymentId).toBe('abc123');
});
describe('when cloud ID is provided in the config', () => {
let cloudExperiments: jest.Mocked<CloudExperimentsPluginSetup>;
beforeEach(() => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({ id: 'cloud test' }))
);
cloudExperiments = cloudExperimentsMock.createSetupMock();
plugin.setup(coreMock.createSetup(), { cloudExperiments });
});
test('calls cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser).toHaveBeenCalledTimes(1);
});
test('the cloud ID is hashed when calling cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser.mock.calls[0][0]).toEqual(
'1acb4a1cc1c3d672a8d826055d897c2623ceb1d4fb07e46d97986751a36b06cf'
);
});
test('specifies the Kibana version when calling cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser.mock.calls[0][1]).toEqual(
expect.objectContaining({
kibanaVersion: 'version',
})
);
});
it('exposes apm', () => {
const { setup } = setupPlugin();
expect(setup.apm).toStrictEqual({ url: undefined, secretToken: undefined });
});
});
});

View file

@ -5,24 +5,17 @@
* 2.0.
*/
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server';
import type { SecurityPluginSetup } from '@kbn/security-plugin/server';
import type { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common';
import { createSHA256Hash } from '@kbn/crypto';
import type { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/server';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
import { CloudConfigType } from './config';
import type { CloudConfigType } from './config';
import { registerCloudUsageCollector } from './collectors';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import { parseDeploymentIdFromDeploymentUrl } from './utils';
import { registerFullstoryRoute } from './routes/fullstory';
import { registerChatRoute } from './routes/chat';
import { readInstanceSizeMb } from './env';
interface PluginsSetup {
usageCollection?: UsageCollectionSetup;
security?: SecurityPluginSetup;
cloudExperiments?: CloudExperimentsPluginSetup;
}
export interface CloudSetup {
@ -37,52 +30,17 @@ export interface CloudSetup {
}
export class CloudPlugin implements Plugin<CloudSetup> {
private readonly logger: Logger;
private readonly config: CloudConfigType;
private readonly isDev: boolean;
constructor(private readonly context: PluginInitializerContext) {
this.logger = this.context.logger.get();
this.config = this.context.config.get<CloudConfigType>();
this.isDev = this.context.env.mode.dev;
}
public setup(
core: CoreSetup,
{ cloudExperiments, usageCollection, security }: PluginsSetup
): CloudSetup {
this.logger.debug('Setting up Cloud plugin');
public setup(core: CoreSetup, { usageCollection }: PluginsSetup): CloudSetup {
const isCloudEnabled = getIsCloudEnabled(this.config.id);
registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id);
registerCloudUsageCollector(usageCollection, { isCloudEnabled });
if (isCloudEnabled) {
security?.setIsElasticCloudDeployment();
}
if (isCloudEnabled && this.config.id) {
// We use the Cloud ID as the userId in the Cloud Experiments
cloudExperiments?.identifyUser(createSHA256Hash(this.config.id), {
kibanaVersion: this.context.env.packageInfo.version,
});
}
if (this.config.full_story.enabled) {
registerFullstoryRoute({
httpResources: core.http.resources,
packageInfo: this.context.env.packageInfo,
});
}
if (this.config.chat.enabled && this.config.chatIdentitySecret) {
registerChatRoute({
router: core.http.createRouter(),
chatIdentitySecret: this.config.chatIdentitySecret,
security,
isDev: this.isDev,
});
}
return {
cloudId: this.config.id,
instanceSizeMb: readInstanceSizeMb(),

View file

@ -16,8 +16,5 @@
"references": [
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../../../src/plugins/home/tsconfig.json" },
{ "path": "../cloud_integrations/cloud_experiments/tsconfig.json" },
{ "path": "../security/tsconfig.json" },
]
}

View file

@ -7,12 +7,11 @@
import React from 'react';
import { DecoratorFn } from '@storybook/react';
import { ServicesProvider, CloudServices } from '../public/services';
import { ServicesProvider, CloudChatServices } from '../public/services';
// TODO: move to a storybook implementation of the service using parameters.
const services: CloudServices = {
const services: CloudChatServices = {
chat: {
enabled: true,
chatURL: 'https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html',
user: {
id: 'user-id',

View file

@ -0,0 +1,3 @@
# Cloud Chat
Integrates with DriftChat in order to provide live support to our Elastic Cloud users. This plugin should only run on Elastic Cloud.

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const GET_CHAT_USER_DATA_ROUTE_PATH = '/internal/cloud/chat_user';

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../',
roots: ['<rootDir>/x-pack/plugins/cloud_integrations/cloud_chat'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/cloud_integrations/cloud_chat',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/cloud_integrations/cloud_chat/{common,public,server}/**/*.{ts,tsx}',
],
};

View file

@ -0,0 +1,15 @@
{
"id": "cloudChat",
"version": "1.0.0",
"kibanaVersion": "kibana",
"owner": {
"name": "Kibana Core",
"githubTeam": "kibana-core"
},
"description": "Chat available on Elastic Cloud deployments for quicker assistance.",
"server": true,
"ui": true,
"configPath": ["xpack", "cloud_integrations", "chat"],
"requiredPlugins": ["cloud"],
"optionalPlugins": ["security"]
}

View file

@ -68,7 +68,6 @@ export const Component = ({ id, email, chatURL, jwt }: Params) => {
return (
<ServicesProvider
chat={{
enabled: true,
chatURL,
user: {
jwt,

View file

@ -57,7 +57,7 @@ export const Chat = ({ onHide = () => {}, onReady, onResize }: Props) => {
}}
size="xs"
>
{i18n.translate('xpack.cloud.chat.hideChatButtonLabel', {
{i18n.translate('xpack.cloudChat.hideChatButtonLabel', {
defaultMessage: 'Hide chat',
})}
</EuiButtonEmpty>
@ -80,7 +80,7 @@ export const Chat = ({ onHide = () => {}, onReady, onResize }: Props) => {
{button}
<iframe
data-test-subj="cloud-chat-frame"
title={i18n.translate('xpack.cloud.chat.chatFrameTitle', {
title={i18n.translate('xpack.cloudChat.chatFrameTitle', {
defaultMessage: 'Chat',
})}
{...config}

View file

@ -23,7 +23,7 @@ const REFERRER = 'referrer';
describe('getChatContext', () => {
const url = new URL(HREF);
test('retreive the context', () => {
test('retrieve the context', () => {
Object.defineProperty(window, 'location', { value: url });
Object.defineProperty(window, 'navigator', {
value: {

View file

@ -45,11 +45,7 @@ export const useChatConfig = ({
const handleMessage = (event: MessageEvent): void => {
const { current: chatIframe } = ref;
if (
!chat.enabled ||
!chatIframe?.contentWindow ||
event.source !== chatIframe?.contentWindow
) {
if (!chat || !chatIframe?.contentWindow || event.source !== chatIframe?.contentWindow) {
return;
}
@ -108,7 +104,7 @@ export const useChatConfig = ({
return () => window.removeEventListener('message', handleMessage);
}, [chat, style, onReady, onResize, isReady, isResized]);
if (chat.enabled) {
if (chat) {
return { enabled: true, src: chat.chatURL, ref, style, isReady, isResized };
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializerContext } from '@kbn/core/public';
import { CloudChatPlugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudChatPlugin(initializerContext);
}
export { Chat } from './components';

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { coreMock } from '@kbn/core/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import type { CloudChatConfigType } from '../server/config';
import { CloudChatPlugin } from './plugin';
describe('Cloud Chat Plugin', () => {
describe('#setup', () => {
describe('setupChat', () => {
let consoleMock: jest.SpyInstance<void, [message?: any, ...optionalParams: any[]]>;
beforeEach(() => {
consoleMock = jest.spyOn(console, 'debug').mockImplementation(() => {});
});
afterEach(() => {
consoleMock.mockRestore();
});
const setupPlugin = async ({
config = {},
securityEnabled = true,
currentUserProps = {},
isCloudEnabled = true,
failHttp = false,
}: {
config?: Partial<CloudChatConfigType>;
securityEnabled?: boolean;
currentUserProps?: Record<string, any>;
isCloudEnabled?: boolean;
failHttp?: boolean;
}) => {
const initContext = coreMock.createPluginInitializerContext(config);
const plugin = new CloudChatPlugin(initContext);
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
if (failHttp) {
coreSetup.http.get.mockImplementation(() => {
throw new Error('HTTP request failed');
});
}
coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]);
const securitySetup = securityMock.createSetup();
securitySetup.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser(currentUserProps)
);
const cloud = cloudMock.createSetup();
plugin.setup(coreSetup, {
cloud: { ...cloud, isCloudEnabled },
...(securityEnabled ? { security: securitySetup } : {}),
});
return { initContext, plugin, coreSetup };
};
it('chatConfig is not retrieved if cloud is not enabled', async () => {
const { coreSetup } = await setupPlugin({ isCloudEnabled: false });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if security is not enabled', async () => {
const { coreSetup } = await setupPlugin({ securityEnabled: false });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if chat is enabled but url is not provided', async () => {
// @ts-expect-error 2741
const { coreSetup } = await setupPlugin({ config: { chat: { enabled: true } } });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if internal API fails', async () => {
const { coreSetup } = await setupPlugin({
config: { chatURL: 'http://chat.elastic.co' },
failHttp: true,
});
expect(coreSetup.http.get).toHaveBeenCalled();
expect(consoleMock).toHaveBeenCalled();
});
it('chatConfig is retrieved if chat is enabled and url is provided', async () => {
const { coreSetup } = await setupPlugin({
config: { chatURL: 'http://chat.elastic.co' },
});
expect(coreSetup.http.get).toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { type FC } from 'react';
import useObservable from 'react-use/lib/useObservable';
import type { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { HttpSetup } from '@kbn/core-http-browser';
import type { SecurityPluginSetup } from '@kbn/security-plugin/public';
import type { CloudSetup } from '@kbn/cloud-plugin/public';
import { ReplaySubject } from 'rxjs';
import type { GetChatUserDataResponseBody } from '../common/types';
import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../common/constants';
import { ChatConfig, ServicesProvider } from './services';
interface CloudChatSetupDeps {
cloud: CloudSetup;
security?: SecurityPluginSetup;
}
interface SetupChatDeps extends CloudChatSetupDeps {
http: HttpSetup;
}
interface CloudChatConfig {
chatURL?: string;
}
export class CloudChatPlugin implements Plugin {
private readonly config: CloudChatConfig;
private chatConfig$ = new ReplaySubject<ChatConfig>(1);
constructor(initializerContext: PluginInitializerContext<CloudChatConfig>) {
this.config = initializerContext.config.get();
}
public setup(core: CoreSetup, { cloud, security }: CloudChatSetupDeps) {
this.setupChat({ http: core.http, cloud, security }).catch((e) =>
// eslint-disable-next-line no-console
console.debug(`Error setting up Chat: ${e.toString()}`)
);
const CloudChatContextProvider: FC = ({ children }) => {
// There's a risk that the request for chat config will take too much time to complete, and the provider
// will maintain a stale value. To avoid this, we'll use an Observable.
const chatConfig = useObservable(this.chatConfig$, undefined);
return <ServicesProvider chat={chatConfig}>{children}</ServicesProvider>;
};
cloud.registerCloudService(CloudChatContextProvider);
}
public start() {}
public stop() {}
private async setupChat({ cloud, http, security }: SetupChatDeps) {
if (!cloud.isCloudEnabled || !security || !this.config.chatURL) {
return;
}
try {
const {
email,
id,
token: jwt,
} = await http.get<GetChatUserDataResponseBody>(GET_CHAT_USER_DATA_ROUTE_PATH);
if (!email || !id || !jwt) {
return;
}
this.chatConfig$.next({
chatURL: this.config.chatURL,
user: {
email,
id,
jwt,
},
});
} catch (e) {
// eslint-disable-next-line no-console
console.debug(`[cloud.chat] Could not retrieve chat config: ${e.res.status} ${e.message}`, e);
}
}
}

View file

@ -7,12 +7,7 @@
import React, { FC, createContext, useContext } from 'react';
interface WithoutChat {
enabled: false;
}
interface WithChat {
enabled: true;
export interface ChatConfig {
chatURL: string;
user: {
jwt: string;
@ -21,26 +16,24 @@ interface WithChat {
};
}
export type ChatConfig = WithChat | WithoutChat;
export interface CloudServices {
chat: ChatConfig;
export interface CloudChatServices {
chat?: ChatConfig;
}
const ServicesContext = createContext<CloudServices>({ chat: { enabled: false } });
const ServicesContext = createContext<CloudChatServices>({});
export const ServicesProvider: FC<CloudServices> = ({ children, ...services }) => (
export const ServicesProvider: FC<CloudChatServices> = ({ children, ...services }) => (
<ServicesContext.Provider value={services}>{children}</ServicesContext.Provider>
);
/**
* React hook for accessing the pre-wired `CloudServices`.
* React hook for accessing the pre-wired `CloudChatServices`.
*/
export function useServices() {
return useContext(ServicesContext);
}
export function useChat(): ChatConfig {
export function useChat(): ChatConfig | undefined {
const { chat } = useServices();
return chat;
}

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get, has } from 'lodash';
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core/server';
const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
chatURL: schema.maybe(schema.string()),
chatIdentitySecret: schema.maybe(schema.string()),
});
export type CloudChatConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<CloudChatConfigType> = {
exposeToBrowser: {
chatURL: true,
},
schema: configSchema,
deprecations: () => [
// Silently move the chat configuration from `xpack.cloud` to `xpack.cloud_integrations.chat`.
// No need to emit a deprecation log because it's an internal restructure
(cfg) => {
return {
set: [
...copyIfExists({
cfg,
fromKey: 'xpack.cloud.chat.enabled',
toKey: 'xpack.cloud_integrations.chat.enabled',
}),
...copyIfExists({
cfg,
fromKey: 'xpack.cloud.chat.chatURL',
toKey: 'xpack.cloud_integrations.chat.chatURL',
}),
...copyIfExists({
cfg,
fromKey: 'xpack.cloud.chatIdentitySecret',
toKey: 'xpack.cloud_integrations.chat.chatIdentitySecret',
}),
],
unset: [
{ path: 'xpack.cloud.chat.enabled' },
{ path: 'xpack.cloud.chat.chatURL' },
{ path: 'xpack.cloud.chatIdentitySecret' },
],
};
},
],
};
/**
* Defines the `set` action only if the key exists in the `fromKey` value.
* This is to avoid overwriting actual values with undefined.
* @param cfg The config object
* @param fromKey The key to copy from.
* @param toKey The key where the value should be copied to.
*/
function copyIfExists({
cfg,
fromKey,
toKey,
}: {
cfg: Readonly<{ [p: string]: unknown }>;
fromKey: string;
toKey: string;
}) {
return has(cfg, fromKey) ? [{ path: toKey, value: get(cfg, fromKey) }] : [];
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializerContext } from '@kbn/core/server';
import { CloudChatPlugin } from './plugin';
export { config } from './config';
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudChatPlugin(initializerContext);
}

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server';
import { SecurityPluginSetup } from '@kbn/security-plugin/server';
import { CloudSetup } from '@kbn/cloud-plugin/server';
import { registerChatRoute } from './routes';
import { CloudChatConfigType } from './config';
interface CloudChatSetupDeps {
cloud: CloudSetup;
security?: SecurityPluginSetup;
}
export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps> {
private readonly config: CloudChatConfigType;
private readonly isDev: boolean;
constructor(initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.get();
this.isDev = initializerContext.env.mode.dev;
}
public setup(core: CoreSetup, { cloud, security }: CloudChatSetupDeps) {
if (cloud.isCloudEnabled && this.config.chatIdentitySecret) {
registerChatRoute({
router: core.http.createRouter(),
chatIdentitySecret: this.config.chatIdentitySecret,
security,
isDev: this.isDev,
});
}
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { registerChatRoute } from './chat';

View file

@ -0,0 +1,21 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target/types",
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
},
"include": [
".storybook/**/*",
"common/**/*",
"public/**/*",
"server/**/*",
"../../../typings/**/*"
],
"references": [
{ "path": "../../../../src/core/tsconfig.json" },
{ "path": "../../cloud/tsconfig.json" },
{ "path": "../../security/tsconfig.json" },
]
}

View file

@ -9,6 +9,5 @@ export type {
CloudExperimentsMetric,
CloudExperimentsMetricNames,
CloudExperimentsPluginStart,
CloudExperimentsPluginSetup,
CloudExperimentsFeatureFlagNames,
} from './types';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { CloudExperimentsPluginSetup, CloudExperimentsPluginStart } from './types';
import type { CloudExperimentsPluginStart } from './types';
function createStartMock(): jest.Mocked<CloudExperimentsPluginStart> {
return {
@ -14,13 +14,6 @@ function createStartMock(): jest.Mocked<CloudExperimentsPluginStart> {
};
}
function createSetupMock(): jest.Mocked<CloudExperimentsPluginSetup> {
return {
identifyUser: jest.fn(),
};
}
export const cloudExperimentsMock = {
createSetupMock,
createStartMock,
};

View file

@ -7,27 +7,6 @@
import { FEATURE_FLAG_NAMES, METRIC_NAMES } from './constants';
/**
* The contract of the setup lifecycle method.
*
* @public
*/
export interface CloudExperimentsPluginSetup {
/**
* Identifies the user in the A/B testing service.
* For now, we only rely on the user ID. In the future, we may request further details for more targeted experiments.
* @param userId The unique identifier of the user in the experiment.
* @param userMetadata Additional attributes to the user. Take care to ensure these values do not contain PII.
*
* @deprecated This API will become internal as soon as we reduce the dependency graph of the `cloud` plugin,
* and this plugin depends on it to fetch the data.
*/
identifyUser: (
userId: string,
userMetadata?: Record<string, string | boolean | number | Array<string | boolean | number>>
) => void;
}
/**
* The names of the feature flags declared in Kibana.
* Valid keys are defined in {@link FEATURE_FLAG_NAMES}. When using a new feature flag, add the name to the list.

View file

@ -10,6 +10,6 @@
"server": true,
"ui": true,
"configPath": ["xpack", "cloud_integrations", "experiments"],
"requiredPlugins": [],
"requiredPlugins": ["cloud"],
"optionalPlugins": ["usageCollection"]
}

View file

@ -6,6 +6,7 @@
*/
import { coreMock } from '@kbn/core/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { ldClientMock } from './plugin.test.mock';
import { CloudExperimentsPlugin } from './plugin';
import { FEATURE_FLAG_NAMES } from '../common/constants';
@ -61,13 +62,12 @@ describe('Cloud Experiments public plugin', () => {
plugin = new CloudExperimentsPlugin(initializerContext);
});
test('returns the contract', () => {
const setupContract = plugin.setup(coreMock.createSetup());
expect(setupContract).toStrictEqual(
expect.objectContaining({
identifyUser: expect.any(Function),
test('returns no contract', () => {
expect(
plugin.setup(coreMock.createSetup(), {
cloud: cloudMock.createSetup(),
})
);
).toBeUndefined();
});
describe('identifyUser', () => {
@ -76,44 +76,29 @@ describe('Cloud Experiments public plugin', () => {
flag_overrides: { my_flag: '1234' },
});
const customPlugin = new CloudExperimentsPlugin(initializerContext);
const setupContract = customPlugin.setup(coreMock.createSetup());
expect(customPlugin).toHaveProperty('launchDarklyClient', undefined);
setupContract.identifyUser('user-id', {});
customPlugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
expect(customPlugin).toHaveProperty('launchDarklyClient', undefined);
});
test('it initializes the LaunchDarkly client', () => {
const setupContract = plugin.setup(coreMock.createSetup());
test('it skips creating the client if cloud is not enabled', () => {
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
});
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
setupContract.identifyUser('user-id', {});
expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock);
expect(ldClientMock.identify).not.toHaveBeenCalled();
});
test('it calls identify if the client already exists', () => {
const setupContract = plugin.setup(coreMock.createSetup());
test('it initializes the LaunchDarkly client', async () => {
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
setupContract.identifyUser('user-id', {});
expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock);
expect(ldClientMock.identify).not.toHaveBeenCalled();
ldClientMock.identify.mockResolvedValue({}); // ensure it's a promise
setupContract.identifyUser('user-id', {});
expect(ldClientMock.identify).toHaveBeenCalledTimes(1);
});
test('it handles identify rejections', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
const setupContract = plugin.setup(coreMock.createSetup());
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
setupContract.identifyUser('user-id', {});
expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock);
expect(ldClientMock.identify).not.toHaveBeenCalled();
const error = new Error('Something went terribly wrong');
ldClientMock.identify.mockRejectedValue(error);
setupContract.identifyUser('user-id', {});
expect(ldClientMock.identify).toHaveBeenCalledTimes(1);
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
// await the lazy import
await new Promise((resolve) => process.nextTick(resolve));
expect(consoleWarnSpy).toHaveBeenCalledWith(error);
expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock);
});
});
});
@ -132,7 +117,7 @@ describe('Cloud Experiments public plugin', () => {
});
test('returns the contract', () => {
plugin.setup(coreMock.createSetup());
plugin.setup(coreMock.createSetup(), { cloud: cloudMock.createSetup() });
const startContract = plugin.start(coreMock.createStart());
expect(startContract).toStrictEqual(
expect.objectContaining({
@ -145,8 +130,9 @@ describe('Cloud Experiments public plugin', () => {
describe('getVariation', () => {
describe('with the user identified', () => {
beforeEach(() => {
const setupContract = plugin.setup(coreMock.createSetup());
setupContract.identifyUser('user-id', {});
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
});
test('uses the flag overrides to respond early', async () => {
@ -175,7 +161,9 @@ describe('Cloud Experiments public plugin', () => {
describe('with the user not identified', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup());
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
});
});
test('uses the flag overrides to respond early', async () => {
@ -202,8 +190,9 @@ describe('Cloud Experiments public plugin', () => {
describe('reportMetric', () => {
describe('with the user identified', () => {
beforeEach(() => {
const setupContract = plugin.setup(coreMock.createSetup());
setupContract.identifyUser('user-id', {});
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
});
test('calls the track API', () => {
@ -224,7 +213,9 @@ describe('Cloud Experiments public plugin', () => {
describe('with the user not identified', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup());
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
});
});
test('calls the track API', () => {
@ -250,8 +241,9 @@ describe('Cloud Experiments public plugin', () => {
flag_overrides: { my_flag: '1234' },
});
plugin = new CloudExperimentsPlugin(initializerContext);
const setupContract = plugin.setup(coreMock.createSetup());
setupContract.identifyUser('user-id', {});
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
plugin.start(coreMock.createStart());
});

View file

@ -6,21 +6,26 @@
*/
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import LaunchDarkly, { type LDClient } from 'launchdarkly-js-client-sdk';
import type { LDClient } from 'launchdarkly-js-client-sdk';
import { get, has } from 'lodash';
import { Sha256 } from '@kbn/crypto-browser';
import type { CloudSetup } from '@kbn/cloud-plugin/public';
import type {
CloudExperimentsFeatureFlagNames,
CloudExperimentsMetric,
CloudExperimentsPluginSetup,
CloudExperimentsPluginStart,
} from '../common';
import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants';
interface CloudExperimentsPluginSetupDeps {
cloud: CloudSetup;
}
/**
* Browser-side implementation of the Cloud Experiments plugin
*/
export class CloudExperimentsPlugin
implements Plugin<CloudExperimentsPluginSetup, CloudExperimentsPluginStart>
implements Plugin<void, CloudExperimentsPluginStart, CloudExperimentsPluginSetupDeps>
{
private launchDarklyClient?: LDClient;
private readonly clientId?: string;
@ -53,30 +58,32 @@ export class CloudExperimentsPlugin
}
/**
* Returns the contract {@link CloudExperimentsPluginSetup}
* Sets up the A/B testing client only if cloud is enabled
* @param core {@link CoreSetup}
* @param deps {@link CloudExperimentsPluginSetupDeps}
*/
public setup(core: CoreSetup): CloudExperimentsPluginSetup {
return {
identifyUser: (userId, userMetadata) => {
if (!this.clientId) return; // Only applies in dev mode.
if (!this.launchDarklyClient) {
// If the client has not been initialized, create it with the user data..
public setup(core: CoreSetup, deps: CloudExperimentsPluginSetupDeps) {
if (deps.cloud.isCloudEnabled && deps.cloud.cloudId && this.clientId) {
import('launchdarkly-js-client-sdk').then(
(LaunchDarkly) => {
this.launchDarklyClient = LaunchDarkly.initialize(
this.clientId,
{ key: userId, custom: userMetadata },
this.clientId!,
{
// We use the Hashed Cloud Deployment ID as the userId in the Cloud Experiments
key: sha256(deps.cloud.cloudId!),
custom: {
kibanaVersion: this.kibanaVersion,
},
},
{ application: { id: 'kibana-browser', version: this.kibanaVersion } }
);
} else {
// Otherwise, call the `identify` method.
this.launchDarklyClient
.identify({ key: userId, custom: userMetadata })
// eslint-disable-next-line no-console
.catch((err) => console.warn(err));
},
(err) => {
// eslint-disable-next-line no-console
console.debug(`Error setting up LaunchDarkly: ${err.toString()}`);
}
},
};
);
}
}
/**
@ -125,3 +132,7 @@ export class CloudExperimentsPlugin
}
};
}
function sha256(str: string) {
return new Sha256().update(str, 'utf8').digest('hex');
}

View file

@ -6,9 +6,10 @@
*/
import { coreMock } from '@kbn/core/server/mocks';
import { cloudMock } from '@kbn/cloud-plugin/server/mocks';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks';
import { ldClientMock } from './plugin.test.mock';
import { CloudExperimentsPlugin } from './plugin';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks';
import { FEATURE_FLAG_NAMES } from '../common/constants';
describe('Cloud Experiments server plugin', () => {
@ -16,6 +17,13 @@ describe('Cloud Experiments server plugin', () => {
jest.resetAllMocks();
});
const ldUser = {
key: '1c2412b751f056aef6e340efa5637d137442d489a4b1e3117071e7c87f8523f2',
custom: {
kibanaVersion: coreMock.createPluginInitializerContext().env.packageInfo.version,
},
};
describe('constructor', () => {
test('successfully creates a new plugin if provided an empty configuration', () => {
const initializerContext = coreMock.createPluginInitializerContext();
@ -68,17 +76,19 @@ describe('Cloud Experiments server plugin', () => {
});
test('returns the contract', () => {
const setupContract = plugin.setup(coreMock.createSetup(), {});
expect(setupContract).toStrictEqual(
expect.objectContaining({
identifyUser: expect.any(Function),
expect(
plugin.setup(coreMock.createSetup(), {
cloud: cloudMock.createSetup(),
})
);
).toBeUndefined();
});
test('registers the usage collector when available', () => {
const usageCollection = usageCollectionPluginMock.createSetupContract();
plugin.setup(coreMock.createSetup(), { usageCollection });
plugin.setup(coreMock.createSetup(), {
cloud: cloudMock.createSetup(),
usageCollection,
});
expect(usageCollection.makeUsageCollector).toHaveBeenCalledTimes(1);
expect(usageCollection.registerCollector).toHaveBeenCalledTimes(1);
});
@ -86,9 +96,9 @@ describe('Cloud Experiments server plugin', () => {
describe('identifyUser', () => {
test('sets launchDarklyUser and calls identify', () => {
expect(plugin).toHaveProperty('launchDarklyUser', undefined);
const setupContract = plugin.setup(coreMock.createSetup(), {});
setupContract.identifyUser('user-id', {});
const ldUser = { key: 'user-id', custom: {} };
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
expect(plugin).toHaveProperty('launchDarklyUser', ldUser);
expect(ldClientMock.identify).toHaveBeenCalledWith(ldUser);
});
@ -110,7 +120,7 @@ describe('Cloud Experiments server plugin', () => {
});
test('returns the contract', () => {
plugin.setup(coreMock.createSetup(), {});
plugin.setup(coreMock.createSetup(), { cloud: cloudMock.createSetup() });
const startContract = plugin.start(coreMock.createStart());
expect(startContract).toStrictEqual(
expect.objectContaining({
@ -123,8 +133,9 @@ describe('Cloud Experiments server plugin', () => {
describe('getVariation', () => {
describe('with the user identified', () => {
beforeEach(() => {
const setupContract = plugin.setup(coreMock.createSetup(), {});
setupContract.identifyUser('user-id', {});
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
});
test('uses the flag overrides to respond early', async () => {
@ -146,7 +157,7 @@ describe('Cloud Experiments server plugin', () => {
).resolves.toStrictEqual('12345');
expect(ldClientMock.variation).toHaveBeenCalledWith(
undefined, // it couldn't find it in FEATURE_FLAG_NAMES
{ key: 'user-id', custom: {} },
ldUser,
123
);
});
@ -154,7 +165,9 @@ describe('Cloud Experiments server plugin', () => {
describe('with the user not identified', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup(), {});
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
});
});
test('uses the flag overrides to respond early', async () => {
@ -181,8 +194,9 @@ describe('Cloud Experiments server plugin', () => {
describe('reportMetric', () => {
describe('with the user identified', () => {
beforeEach(() => {
const setupContract = plugin.setup(coreMock.createSetup(), {});
setupContract.identifyUser('user-id', {});
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
});
test('calls the track API', () => {
@ -195,7 +209,7 @@ describe('Cloud Experiments server plugin', () => {
});
expect(ldClientMock.track).toHaveBeenCalledWith(
undefined, // it couldn't find it in METRIC_NAMES
{ key: 'user-id', custom: {} },
ldUser,
{},
1
);
@ -204,7 +218,9 @@ describe('Cloud Experiments server plugin', () => {
describe('with the user not identified', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup(), {});
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
});
});
test('calls the track API', () => {
@ -231,7 +247,9 @@ describe('Cloud Experiments server plugin', () => {
});
ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock);
plugin = new CloudExperimentsPlugin(initializerContext);
plugin.setup(coreMock.createSetup(), {});
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
plugin.start(coreMock.createStart());
});

View file

@ -14,36 +14,33 @@ import type {
} from '@kbn/core/server';
import { get, has } from 'lodash';
import LaunchDarkly, { type LDClient, type LDUser } from 'launchdarkly-node-server-sdk';
import { createSHA256Hash } from '@kbn/crypto';
import type { LogMeta } from '@kbn/logging';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import { registerUsageCollector } from './usage';
import type { CloudExperimentsConfigType } from './config';
import type {
CloudExperimentsFeatureFlagNames,
CloudExperimentsMetric,
CloudExperimentsPluginSetup,
CloudExperimentsPluginStart,
} from '../common';
import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants';
interface CloudExperimentsPluginSetupDeps {
cloud: CloudSetup;
usageCollection?: UsageCollectionSetup;
}
export class CloudExperimentsPlugin
implements
Plugin<
CloudExperimentsPluginSetup,
CloudExperimentsPluginStart,
CloudExperimentsPluginSetupDeps
>
implements Plugin<void, CloudExperimentsPluginStart, CloudExperimentsPluginSetupDeps>
{
private readonly logger: Logger;
private readonly launchDarklyClient?: LDClient;
private readonly flagOverrides?: Record<string, unknown>;
private launchDarklyUser: LDUser | undefined;
constructor(initializerContext: PluginInitializerContext) {
constructor(private readonly initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
const config = initializerContext.config.get<CloudExperimentsConfigType>();
if (config.flag_overrides) {
@ -73,10 +70,7 @@ export class CloudExperimentsPlugin
}
}
public setup(
core: CoreSetup,
deps: CloudExperimentsPluginSetupDeps
): CloudExperimentsPluginSetup {
public setup(core: CoreSetup, deps: CloudExperimentsPluginSetupDeps) {
if (deps.usageCollection) {
registerUsageCollector(deps.usageCollection, () => ({
launchDarklyClient: this.launchDarklyClient,
@ -84,12 +78,17 @@ export class CloudExperimentsPlugin
}));
}
return {
identifyUser: (userId, userMetadata) => {
this.launchDarklyUser = { key: userId, custom: userMetadata };
this.launchDarklyClient?.identify(this.launchDarklyUser!);
},
};
if (deps.cloud.isCloudEnabled && deps.cloud.cloudId) {
this.launchDarklyUser = {
// We use the Cloud ID as the userId in the Cloud Experiments
key: createSHA256Hash(deps.cloud.cloudId),
custom: {
// This list of deployment metadata will likely grow in future versions
kibanaVersion: this.initializerContext.env.packageInfo.version,
},
};
this.launchDarklyClient?.identify(this.launchDarklyUser);
}
}
public start(core: CoreStart) {

View file

@ -16,5 +16,6 @@
"references": [
{ "path": "../../../../src/core/tsconfig.json" },
{ "path": "../../../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../../cloud/tsconfig.json" },
]
}

View file

@ -0,0 +1,7 @@
{
"prefix": "cloudFullStory",
"paths": {
"cloudFullStory": "."
},
"translations": ["translations/ja-JP.json"]
}

View file

@ -0,0 +1,3 @@
# Cloud FullStory
Integrates with FullStory in order to provide better product analytics, so we can understand how our users make use of Kibana. This plugin should only run on Elastic Cloud.

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../',
roots: ['<rootDir>/x-pack/plugins/cloud_integrations/cloud_full_story'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/cloud_integrations/cloud_full_story',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/cloud_integrations/cloud_full_story/{common,public,server}/**/*.{ts,tsx}',
],
};

View file

@ -0,0 +1,15 @@
{
"id": "cloudFullStory",
"version": "1.0.0",
"kibanaVersion": "kibana",
"owner": {
"name": "Kibana Core",
"githubTeam": "kibana-core"
},
"description": "When Kibana runs on Elastic Cloud, this plugin registers FullStory as a shipper for telemetry.",
"server": true,
"ui": true,
"configPath": ["xpack", "cloud_integrations", "full_story"],
"requiredPlugins": ["cloud"],
"optionalPlugins": ["security"]
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializerContext } from '@kbn/core/public';
import { CloudFullStoryPlugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudFullStoryPlugin(initializerContext);
}

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { coreMock } from '@kbn/core/public/mocks';
import type { CloudFullStoryConfigType } from '../server/config';
import { CloudFullStoryPlugin } from './plugin';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
describe('Cloud Plugin', () => {
describe('#setup', () => {
describe('setupFullStory', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const setupPlugin = async ({
config = {},
isCloudEnabled = true,
}: {
config?: Partial<CloudFullStoryConfigType>;
isCloudEnabled?: boolean;
}) => {
const initContext = coreMock.createPluginInitializerContext(config);
const plugin = new CloudFullStoryPlugin(initContext);
const coreSetup = coreMock.createSetup();
const cloud = { ...cloudMock.createSetup(), isCloudEnabled };
plugin.setup(coreSetup, { cloud });
// Wait for FullStory dynamic import to resolve
await new Promise((r) => setImmediate(r));
return { initContext, plugin, coreSetup };
};
test('register the shipper FullStory with correct args when enabled and org_id are set', async () => {
const { coreSetup } = await setupPlugin({
config: { org_id: 'foo' },
});
expect(coreSetup.analytics.registerShipper).toHaveBeenCalled();
expect(coreSetup.analytics.registerShipper).toHaveBeenCalledWith(expect.anything(), {
fullStoryOrgId: 'foo',
scriptUrl: '/internal/cloud/100/fullstory.js',
namespace: 'FSKibana',
});
});
it('does not call initializeFullStory when isCloudEnabled=false', async () => {
const { coreSetup } = await setupPlugin({
config: { org_id: 'foo' },
isCloudEnabled: false,
});
expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled();
});
it('does not call initializeFullStory when org_id is undefined', async () => {
const { coreSetup } = await setupPlugin({ config: {} });
expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
AnalyticsServiceSetup,
IBasePath,
PluginInitializerContext,
CoreSetup,
Plugin,
} from '@kbn/core/public';
import type { CloudSetup } from '@kbn/cloud-plugin/public';
interface SetupFullStoryDeps {
analytics: AnalyticsServiceSetup;
basePath: IBasePath;
}
interface CloudFullStoryConfig {
org_id?: string;
eventTypesAllowlist: string[];
}
interface CloudFullStorySetupDeps {
cloud: CloudSetup;
}
export class CloudFullStoryPlugin implements Plugin {
private readonly config: CloudFullStoryConfig;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<CloudFullStoryConfig>();
}
public setup(core: CoreSetup, { cloud }: CloudFullStorySetupDeps) {
if (cloud.isCloudEnabled) {
this.setupFullStory({ analytics: core.analytics, basePath: core.http.basePath }).catch((e) =>
// eslint-disable-next-line no-console
console.debug(`Error setting up FullStory: ${e.toString()}`)
);
}
}
public start() {}
public stop() {}
/**
* If the right config is provided, register the FullStory shipper to the analytics client.
* @param analytics Core's Analytics service's setup contract.
* @param basePath Core's http.basePath helper.
* @private
*/
private async setupFullStory({ analytics, basePath }: SetupFullStoryDeps) {
const { org_id: fullStoryOrgId, eventTypesAllowlist } = this.config;
if (!fullStoryOrgId) {
return; // do not load any FullStory code in the browser if not enabled
}
// Keep this import async so that we do not load any FullStory code into the browser when it is disabled.
const { FullStoryShipper } = await import('@kbn/analytics-shippers-fullstory');
analytics.registerShipper(FullStoryShipper, {
eventTypesAllowlist,
fullStoryOrgId,
// Load an Elastic-internally audited script. Ideally, it should be hosted on a CDN.
scriptUrl: basePath.prepend(
`/internal/cloud/${this.initializerContext.env.packageInfo.buildNum}/fullstory.js`
),
namespace: 'FSKibana',
});
}
}

View file

@ -10,28 +10,22 @@ import { config } from './config';
describe('xpack.cloud config', () => {
describe('full_story', () => {
it('allows org_id when enabled: false', () => {
expect(() =>
config.schema.validate({ full_story: { enabled: false, org_id: 'asdf' } })
).not.toThrow();
expect(() => config.schema.validate({ enabled: false, org_id: 'asdf' })).not.toThrow();
});
it('rejects undefined or empty org_id when enabled: true', () => {
expect(() =>
config.schema.validate({ full_story: { enabled: true } })
).toThrowErrorMatchingInlineSnapshot(
`"[full_story.org_id]: expected value of type [string] but got [undefined]"`
expect(() => config.schema.validate({ enabled: true })).toThrowErrorMatchingInlineSnapshot(
`"[org_id]: expected value of type [string] but got [undefined]"`
);
expect(() =>
config.schema.validate({ full_story: { enabled: true, org_id: '' } })
config.schema.validate({ enabled: true, org_id: '' })
).toThrowErrorMatchingInlineSnapshot(
`"[full_story.org_id]: value has length [0] but it must have a minimum length of [1]."`
`"[org_id]: value has length [0] but it must have a minimum length of [1]."`
);
});
it('accepts org_id when enabled: true', () => {
expect(() =>
config.schema.validate({ full_story: { enabled: true, org_id: 'asdf' } })
).not.toThrow();
expect(() => config.schema.validate({ enabled: true, org_id: 'asdf' })).not.toThrow();
});
});
});

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core/server';
import { get, has } from 'lodash';
const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
org_id: schema.conditional(
schema.siblingRef('enabled'),
true,
schema.string({ minLength: 1 }),
schema.maybe(schema.string())
),
eventTypesAllowlist: schema.arrayOf(schema.string(), {
defaultValue: ['Loaded Kibana'],
}),
});
export type CloudFullStoryConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<CloudFullStoryConfigType> = {
exposeToBrowser: {
org_id: true,
eventTypesAllowlist: true,
},
schema: configSchema,
deprecations: () => [
// Silently move the chat configuration from `xpack.cloud` to `xpack.cloud_integrations.full_story`.
// No need to emit a deprecation log because it's an internal restructure
(cfg) => {
return {
set: [
...copyIfExists({
cfg,
fromKey: 'xpack.cloud.full_story.enabled',
toKey: 'xpack.cloud_integrations.full_story.enabled',
}),
...copyIfExists({
cfg,
fromKey: 'xpack.cloud.full_story.org_id',
toKey: 'xpack.cloud_integrations.full_story.org_id',
}),
...copyIfExists({
cfg,
fromKey: 'xpack.cloud.full_story.eventTypesAllowlist',
toKey: 'xpack.cloud_integrations.full_story.eventTypesAllowlist',
}),
],
unset: [
{ path: 'xpack.cloud.full_story.enabled' },
{ path: 'xpack.cloud.full_story.org_id' },
{ path: 'xpack.cloud.full_story.eventTypesAllowlist' },
],
};
},
],
};
/**
* Defines the `set` action only if the key exists in the `fromKey` value.
* This is to avoid overwriting actual values with undefined.
* @param cfg The config object
* @param fromKey The key to copy from.
* @param toKey The key where the value should be copied to.
*/
function copyIfExists({
cfg,
fromKey,
toKey,
}: {
cfg: Readonly<{ [p: string]: unknown }>;
fromKey: string;
toKey: string;
}) {
return has(cfg, fromKey) ? [{ path: toKey, value: get(cfg, fromKey) }] : [];
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PluginInitializerContext } from '@kbn/core/server';
import { CloudFullStoryPlugin } from './plugin';
export { config } from './config';
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudFullStoryPlugin(initializerContext);
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const registerFullStoryRouteMock = jest.fn();
jest.doMock('./routes', () => ({
registerFullStoryRoute: registerFullStoryRouteMock,
}));

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { coreMock } from '@kbn/core/server/mocks';
import { cloudMock } from '@kbn/cloud-plugin/server/mocks';
import { registerFullStoryRouteMock } from './plugin.test.mock';
import { CloudFullStoryPlugin } from './plugin';
describe('Cloud FullStory plugin', () => {
let plugin: CloudFullStoryPlugin;
beforeEach(() => {
registerFullStoryRouteMock.mockReset();
plugin = new CloudFullStoryPlugin(coreMock.createPluginInitializerContext());
});
test('registers route when cloud is enabled', () => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
expect(registerFullStoryRouteMock).toHaveBeenCalledTimes(1);
});
test('does not register the route when cloud is disabled', () => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
});
expect(registerFullStoryRouteMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import { registerFullStoryRoute } from './routes';
interface CloudFullStorySetupDeps {
cloud: CloudSetup;
}
export class CloudFullStoryPlugin implements Plugin {
constructor(private readonly initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup, { cloud }: CloudFullStorySetupDeps) {
if (cloud.isCloudEnabled) {
registerFullStoryRoute({
httpResources: core.http.resources,
packageInfo: this.initializerContext.env.packageInfo,
});
}
}
public start() {}
public stop() {}
}

View file

@ -40,7 +40,7 @@ export const renderFullStoryLibraryFactory = (dist = true) =>
}
);
export const registerFullstoryRoute = ({
export const registerFullStoryRoute = ({
httpResources,
packageInfo,
}: {

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { registerFullStoryRoute } from './fullstory';

View file

@ -0,0 +1,20 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target/types",
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
},
"include": [
".storybook/**/*",
"common/**/*",
"public/**/*",
"server/**/*",
"../../../typings/**/*"
],
"references": [
{ "path": "../../../../src/core/tsconfig.json" },
{ "path": "../../cloud/tsconfig.json" },
]
}

View file

@ -0,0 +1,3 @@
# Cloud Links
Adds all the links to the Elastic Cloud console.

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../',
roots: ['<rootDir>/x-pack/plugins/cloud_integrations/cloud_links'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/cloud_integrations/cloud_links',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/cloud_integrations/cloud_links/{common,public,server}/**/*.{ts,tsx}',
],
};

View file

@ -0,0 +1,14 @@
{
"id": "cloudLinks",
"version": "1.0.0",
"kibanaVersion": "kibana",
"owner": {
"name": "Kibana Core",
"githubTeam": "@kibana-core"
},
"description": "Adds the links to the Elastic Cloud console",
"server": false,
"ui": true,
"requiredPlugins": [],
"optionalPlugins": ["cloud", "security"]
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CloudLinksPlugin } from './plugin';
export function plugin() {
return new CloudLinksPlugin();
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { maybeAddCloudLinks } from './maybe_add_cloud_links';

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { coreMock } from '@kbn/core/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { maybeAddCloudLinks } from './maybe_add_cloud_links';
describe('maybeAddCloudLinks', () => {
it('should skip if cloud is disabled', async () => {
const security = securityMock.createStart();
maybeAddCloudLinks({
security,
chrome: coreMock.createStart().chrome,
cloud: { ...cloudMock.createStart(), isCloudEnabled: false },
});
// Since there's a promise, let's wait for the next tick
await new Promise((resolve) => process.nextTick(resolve));
expect(security.authc.getCurrentUser).not.toHaveBeenCalled();
});
it('when cloud enabled and the user is an Elastic Cloud user, it sets the links', async () => {
const security = securityMock.createStart();
security.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser({ elastic_cloud_user: true })
);
const chrome = coreMock.createStart().chrome;
maybeAddCloudLinks({
security,
chrome,
cloud: { ...cloudMock.createStart(), isCloudEnabled: true },
});
// Since there's a promise, let's wait for the next tick
await new Promise((resolve) => process.nextTick(resolve));
expect(security.authc.getCurrentUser).toHaveBeenCalledTimes(1);
expect(chrome.setCustomNavLink).toHaveBeenCalledTimes(1);
expect(chrome.setCustomNavLink.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"euiIconType": "logoCloud",
"href": "deployment-url",
"title": "Manage this deployment",
},
]
`);
expect(security.navControlService.addUserMenuLinks).toHaveBeenCalledTimes(1);
expect(security.navControlService.addUserMenuLinks.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"href": "profile-url",
"iconType": "user",
"label": "Edit profile",
"order": 100,
"setAsProfile": true,
},
Object {
"href": "organization-url",
"iconType": "gear",
"label": "Account & Billing",
"order": 200,
},
],
]
`);
});
it('when cloud enabled and it fails to fetch the user, it sets the links', async () => {
const security = securityMock.createStart();
security.authc.getCurrentUser.mockRejectedValue(new Error('Something went terribly wrong'));
const chrome = coreMock.createStart().chrome;
maybeAddCloudLinks({
security,
chrome,
cloud: { ...cloudMock.createStart(), isCloudEnabled: true },
});
// Since there's a promise, let's wait for the next tick
await new Promise((resolve) => process.nextTick(resolve));
expect(security.authc.getCurrentUser).toHaveBeenCalledTimes(1);
expect(chrome.setCustomNavLink).toHaveBeenCalledTimes(1);
expect(chrome.setCustomNavLink.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"euiIconType": "logoCloud",
"href": "deployment-url",
"title": "Manage this deployment",
},
]
`);
expect(security.navControlService.addUserMenuLinks).toHaveBeenCalledTimes(1);
expect(security.navControlService.addUserMenuLinks.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"href": "profile-url",
"iconType": "user",
"label": "Edit profile",
"order": 100,
"setAsProfile": true,
},
Object {
"href": "organization-url",
"iconType": "gear",
"label": "Account & Billing",
"order": 200,
},
],
]
`);
});
it('when cloud enabled and the user is NOT an Elastic Cloud user, it does not set the links', async () => {
const security = securityMock.createStart();
security.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser({ elastic_cloud_user: false })
);
const chrome = coreMock.createStart().chrome;
maybeAddCloudLinks({
security,
chrome,
cloud: { ...cloudMock.createStart(), isCloudEnabled: true },
});
// Since there's a promise, let's wait for the next tick
await new Promise((resolve) => process.nextTick(resolve));
expect(security.authc.getCurrentUser).toHaveBeenCalledTimes(1);
expect(chrome.setCustomNavLink).not.toHaveBeenCalled();
expect(security.navControlService.addUserMenuLinks).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { catchError, defer, filter, map, of } from 'rxjs';
import { i18n } from '@kbn/i18n';
import type { CloudStart } from '@kbn/cloud-plugin/public';
import type { ChromeStart } from '@kbn/core/public';
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
import { createUserMenuLinks } from './user_menu_links';
export interface MaybeAddCloudLinksDeps {
security: SecurityPluginStart;
chrome: ChromeStart;
cloud: CloudStart;
}
export function maybeAddCloudLinks({ security, chrome, cloud }: MaybeAddCloudLinksDeps): void {
if (cloud.isCloudEnabled) {
defer(() => security.authc.getCurrentUser())
.pipe(
// Check if user is a cloud user.
map((user) => user.elastic_cloud_user),
// If user is not defined due to an unexpected error, then fail *open*.
catchError(() => of(true)),
filter((isElasticCloudUser) => isElasticCloudUser === true),
map(() => {
if (cloud.deploymentUrl) {
chrome.setCustomNavLink({
title: i18n.translate('xpack.cloudLinks.deploymentLinkLabel', {
defaultMessage: 'Manage this deployment',
}),
euiIconType: 'logoCloud',
href: cloud.deploymentUrl,
});
}
const userMenuLinks = createUserMenuLinks(cloud);
security.navControlService.addUserMenuLinks(userMenuLinks);
})
)
.subscribe();
}
}

View file

@ -6,33 +6,32 @@
*/
import { i18n } from '@kbn/i18n';
import { UserMenuLink } from '@kbn/security-plugin/public';
import { CloudConfigType } from '.';
import { getFullCloudUrl } from './utils';
import type { CloudStart } from '@kbn/cloud-plugin/public';
import type { UserMenuLink } from '@kbn/security-plugin/public';
export const createUserMenuLinks = (config: CloudConfigType): UserMenuLink[] => {
const { profile_url: profileUrl, organization_url: organizationUrl, base_url: baseUrl } = config;
export const createUserMenuLinks = (cloud: CloudStart): UserMenuLink[] => {
const { profileUrl, organizationUrl } = cloud;
const userMenuLinks = [] as UserMenuLink[];
if (baseUrl && profileUrl) {
if (profileUrl) {
userMenuLinks.push({
label: i18n.translate('xpack.cloud.userMenuLinks.profileLinkText', {
label: i18n.translate('xpack.cloudLinks.userMenuLinks.profileLinkText', {
defaultMessage: 'Edit profile',
}),
iconType: 'user',
href: getFullCloudUrl(baseUrl, profileUrl),
href: profileUrl,
order: 100,
setAsProfile: true,
});
}
if (baseUrl && organizationUrl) {
if (organizationUrl) {
userMenuLinks.push({
label: i18n.translate('xpack.cloud.userMenuLinks.accountLinkText', {
label: i18n.translate('xpack.cloudLinks.userMenuLinks.accountLinkText', {
defaultMessage: 'Account & Billing',
}),
iconType: 'gear',
href: getFullCloudUrl(baseUrl, organizationUrl),
href: organizationUrl,
order: 200,
});
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const maybeAddCloudLinksMock = jest.fn();
jest.doMock('./maybe_add_cloud_links', () => ({
maybeAddCloudLinks: maybeAddCloudLinksMock,
}));

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { maybeAddCloudLinksMock } from './plugin.test.mocks';
import { CloudLinksPlugin } from './plugin';
import { coreMock } from '@kbn/core/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
describe('Cloud Links Plugin - public', () => {
let plugin: CloudLinksPlugin;
beforeEach(() => {
plugin = new CloudLinksPlugin();
});
afterEach(() => {
maybeAddCloudLinksMock.mockReset();
});
describe('start', () => {
beforeEach(() => {
plugin.setup();
});
afterEach(() => {
plugin.stop();
});
test('calls maybeAddCloudLinks when cloud and security are enabled and it is an authenticated page', () => {
const coreStart = coreMock.createStart();
coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(false);
const cloud = { ...cloudMock.createStart(), isCloudEnabled: true };
const security = securityMock.createStart();
plugin.start(coreStart, { cloud, security });
expect(maybeAddCloudLinksMock).toHaveBeenCalledTimes(1);
});
test('does not call maybeAddCloudLinks when security is disabled', () => {
const coreStart = coreMock.createStart();
coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(false);
const cloud = { ...cloudMock.createStart(), isCloudEnabled: true };
plugin.start(coreStart, { cloud });
expect(maybeAddCloudLinksMock).toHaveBeenCalledTimes(0);
});
test('does not call maybeAddCloudLinks when the page is anonymous', () => {
const coreStart = coreMock.createStart();
coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true);
const cloud = { ...cloudMock.createStart(), isCloudEnabled: true };
const security = securityMock.createStart();
plugin.start(coreStart, { cloud, security });
expect(maybeAddCloudLinksMock).toHaveBeenCalledTimes(0);
});
test('does not call maybeAddCloudLinks when cloud is disabled', () => {
const coreStart = coreMock.createStart();
coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(false);
const security = securityMock.createStart();
plugin.start(coreStart, { security });
expect(maybeAddCloudLinksMock).toHaveBeenCalledTimes(0);
});
test('does not call maybeAddCloudLinks when isCloudEnabled is false', () => {
const coreStart = coreMock.createStart();
coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(false);
const cloud = { ...cloudMock.createStart(), isCloudEnabled: false };
const security = securityMock.createStart();
plugin.start(coreStart, { cloud, security });
expect(maybeAddCloudLinksMock).toHaveBeenCalledTimes(0);
});
});
});

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreStart, Plugin } from '@kbn/core/public';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import { maybeAddCloudLinks } from './maybe_add_cloud_links';
interface CloudLinksDepsSetup {
cloud?: CloudSetup;
security?: SecurityPluginSetup;
}
interface CloudLinksDepsStart {
cloud?: CloudStart;
security?: SecurityPluginStart;
}
export class CloudLinksPlugin
implements Plugin<void, void, CloudLinksDepsSetup, CloudLinksDepsStart>
{
public setup() {}
public start(core: CoreStart, { cloud, security }: CloudLinksDepsStart) {
if (
cloud?.isCloudEnabled &&
security &&
!core.http.anonymousPaths.isAnonymous(window.location.pathname)
) {
maybeAddCloudLinks({ security, chrome: core.chrome, cloud });
}
}
public stop() {}
}

View file

@ -0,0 +1,21 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target/types",
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
},
"include": [
".storybook/**/*",
"common/**/*",
"public/**/*",
"server/**/*",
"../../../typings/**/*"
],
"references": [
{ "path": "../../../../src/core/tsconfig.json" },
{ "path": "../../cloud/tsconfig.json" },
{ "path": "../../security/tsconfig.json" },
]
}

View file

@ -31,7 +31,7 @@
"fieldFormats",
"uiActions",
"lens",
"cloud"
"cloudChat"
],
"owner": {
"name": "Machine Learning UI",

View file

@ -30,7 +30,7 @@ import {
processResults,
} from '../../../common/components/utils';
import { Chat } from '@kbn/cloud-plugin/public';
import { Chat } from '@kbn/cloud-chat-plugin/public';
import { MODE } from './constants';

View file

@ -28,6 +28,7 @@
{ "path": "../../../src/plugins/embeddable/tsconfig.json" },
{ "path": "../../../src/plugins/unified_search/tsconfig.json" },
{ "path": "../cloud/tsconfig.json" },
{ "path": "../cloud_integrations/cloud_chat/tsconfig.json" },
{ "path": "../../../src/plugins/embeddable/tsconfig.json" }
]
}

View file

@ -4,10 +4,10 @@
"kibanaVersion": "kibana",
"requiredPlugins": ["features", "spaces", "security", "licensing", "data", "charts", "infra", "cloud", "esUiShared"],
"configPath": ["enterpriseSearch"],
"optionalPlugins": ["usageCollection", "home", "cloud", "customIntegrations"],
"optionalPlugins": ["usageCollection", "home", "customIntegrations"],
"server": true,
"ui": true,
"requiredBundles": ["kibanaReact"],
"requiredBundles": ["kibanaReact", "cloudChat"],
"owner": {
"name": "Enterprise Search",
"githubTeam": "enterprise-search-frontend"

View file

@ -6,6 +6,7 @@
*/
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
import { Capabilities } from '@kbn/core/public';
@ -18,6 +19,7 @@ export const mockKibanaValues = {
config: { host: 'http://localhost:3002' },
charts: chartPluginMock.createStartContract(),
cloud: {
...cloudMock.createSetup(),
isCloudEnabled: false,
deployment_url: 'https://cloud.elastic.co/deployments/some-id',
},

View file

@ -19,7 +19,7 @@ import {
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { Chat } from '@kbn/cloud-plugin/public';
import { Chat } from '@kbn/cloud-chat-plugin/public';
import { i18n } from '@kbn/i18n';
import {

View file

@ -21,6 +21,7 @@
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../cloud/tsconfig.json" },
{ "path": "../cloud_integrations/cloud_chat/tsconfig.json" },
{ "path": "../infra/tsconfig.json" },
{ "path": "../features/tsconfig.json" },
{ "path": "../licensing/tsconfig.json" },

Some files were not shown because too many files have changed in this diff Show more