[Spaces] Passing default solution from cloud onboarding process (#185926)

## Summary

Passing default solution from cloud onboarding process.

1. Renaming. Solution changes are not released yet, would be shipped
with `8.15`, so it's fine to do it.
   - `search` -> `es`
   - `observability` -> `oblt`
   - Adjusted telemetry accordingly
2. Added `cloud` as optional dependency to `spaces` plugin to use
`onboarding.defaultSolution` passed through setup contract.

### How to test
1. Set `xpack.cloud.onboarding.default_solution` to `es | oblt |
security`
2. Check that default space was created with provided solution `GET
kbn:/api/spaces/space/default`

### Checklist

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios


### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

__Fixes: https://github.com/elastic/kibana/issues/184999__

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
elena-shostak 2024-06-18 11:10:52 +02:00 committed by GitHub
parent 3639308d37
commit 3d09eaa6fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 113 additions and 50 deletions

View file

@ -32,6 +32,6 @@ The API returns the following:
"initials": "MK",
"disabledFeatures": [],
"imageUrl": "",
"solution": "search"
"solution": "es"
}
--------------------------------------------------

View file

@ -72,7 +72,7 @@ The API returns the following:
"initials": "MK",
"disabledFeatures": ["discover"],
"imageUrl": "",
"solution": "observability"
"solution": "oblt"
}
]
--------------------------------------------------

View file

@ -37,7 +37,7 @@ experimental[] Create a {kib} space.
For best results, your image should be 64x64. Images will not be optimized by this API call, so care should be taken when using custom images.
`solution`::
(Optional, string) The solution defined for the space. Can be one of `security`, `observability`, `search`, `classic`
(Optional, string) The solution defined for the space. Can be one of `security`, `oblt`, `es`, `classic`
[[spaces-api-post-response-codes]]
==== Response codes

View file

@ -37,7 +37,7 @@ experimental[] Update an existing {kib} space.
For best results, your image should be 64x64. Images will not be optimized by this API call, so care should be taken when using custom images.
`solution`::
(Optional, string) The solution defined for the space. Can be one of `security`, `observability`, `search`, `classic`.
(Optional, string) The solution defined for the space. Can be one of `security`, `oblt`, `es`, `classic`.
[[spaces-api-put-response-codes]]
==== Response codes

View file

@ -104,7 +104,7 @@ describe('Navigation Plugin', () => {
spaces.getActiveSpace$ = jest
.fn()
.mockReturnValue(of({ solution: 'search' } as Pick<Space, 'solution'>));
.mockReturnValue(of({ solution: 'es' } as Pick<Space, 'solution'>));
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces });
await new Promise((resolve) => setTimeout(resolve));
@ -201,7 +201,7 @@ describe('Navigation Plugin', () => {
featureOn: true,
});
for (const solution of ['search', 'observability', 'security']) {
for (const solution of ['es', 'oblt', 'security']) {
spaces.getActiveSpace$ = jest
.fn()
.mockReturnValue(of({ solution } as Pick<Space, 'solution'>));

View file

@ -168,8 +168,12 @@ export class NavigationPublicPlugin
activeSpace,
}: { isFeatureEnabled: boolean; isServerless: boolean; activeSpace?: Space }
) {
const solutionView = serializeSpaceSolution(activeSpace);
const isProjectNav = isFeatureEnabled && Boolean(solutionView) && solutionView !== 'classic';
const solutionView = activeSpace?.solution;
const isProjectNav =
isFeatureEnabled &&
Boolean(solutionView) &&
isKnownSolutionView(solutionView) &&
solutionView !== 'classic';
// On serverless the chrome style is already set by the serverless plugin
if (!isServerless) {
@ -182,10 +186,6 @@ export class NavigationPublicPlugin
}
}
function serializeSpaceSolution(space?: Space): 'classic' | 'es' | 'oblt' | 'security' | undefined {
if (!space) return undefined;
if (space.solution === 'search') return 'es';
if (space.solution === 'observability') return 'oblt';
if (space.solution === 'security') return 'security';
return undefined;
function isKnownSolutionView(solution?: string) {
return solution && ['oblt', 'es', 'security'].includes(solution);
}

View file

@ -8,7 +8,7 @@
import type { Subscription } from 'rxjs';
import { map } from 'rxjs';
import type { CloudStart } from '@kbn/cloud-plugin/server';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server';
import type { TypeOf } from '@kbn/config-schema';
import type {
CoreSetup,
@ -88,6 +88,7 @@ export interface PluginSetupDependencies {
taskManager: TaskManagerSetupContract;
usageCollection?: UsageCollectionSetup;
spaces?: SpacesPluginSetup;
cloud?: CloudSetup;
}
export interface PluginStartDependencies {

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common';
/**
* A Space.
*/
@ -62,7 +64,7 @@ export interface Space {
/**
* Solution selected for this space.
*/
solution?: 'security' | 'observability' | 'search' | 'classic';
solution?: OnBoardingDefaultSolution | 'classic';
}
/**

View file

@ -18,7 +18,8 @@
"optionalPlugins": [
"home",
"management",
"usageCollection"
"usageCollection",
"cloud"
],
"requiredBundles": [
"esUiShared",

View file

@ -87,6 +87,31 @@ test(`it creates the default space when one does not exist`, async () => {
);
});
test(`it creates the default space when one does not exist with defined solution`, async () => {
const deps = createMockDeps({
defaultExists: false,
});
await createDefaultSpace({ ...deps, solution: 'security' });
const repository = (await deps.getSavedObjects()).createInternalRepository();
expect(repository.get).toHaveBeenCalledTimes(1);
expect(repository.create).toHaveBeenCalledTimes(1);
expect(repository.create).toHaveBeenCalledWith(
'space',
{
_reserved: true,
description: 'This is your default space!',
disabledFeatures: [],
name: 'Default',
color: '#00bfb3',
solution: 'security',
},
{ id: 'default' }
);
});
test(`it does not attempt to recreate the default space if it already exists`, async () => {
const deps = createMockDeps({
defaultExists: true,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common';
import type { Logger, SavedObjectsRepository, SavedObjectsServiceStart } from '@kbn/core/server';
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
@ -14,9 +15,10 @@ import { DEFAULT_SPACE_ID } from '../../common/constants';
interface Deps {
getSavedObjects: () => Promise<Pick<SavedObjectsServiceStart, 'createInternalRepository'>>;
logger: Logger;
solution?: OnBoardingDefaultSolution;
}
export async function createDefaultSpace({ getSavedObjects, logger }: Deps) {
export async function createDefaultSpace({ getSavedObjects, logger, solution }: Deps) {
const { createInternalRepository } = await getSavedObjects();
const savedObjectsRepository = createInternalRepository(['space']);
@ -48,6 +50,7 @@ export async function createDefaultSpace({ getSavedObjects, logger }: Deps) {
color: '#00bfb3',
disabledFeatures: [],
_reserved: true,
...(solution ? { solution } : {}),
},
options
);

View file

@ -19,6 +19,7 @@ import {
timer,
} from 'rxjs';
import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common';
import type { CoreSetup, Logger, SavedObjectsServiceStart, ServiceStatus } from '@kbn/core/server';
import { ServiceStatusLevels } from '@kbn/core/server';
import type { ILicense } from '@kbn/licensing-plugin/server';
@ -32,6 +33,7 @@ interface Deps {
license$: Observable<ILicense>;
spacesLicense: SpacesLicense;
logger: Logger;
solution?: OnBoardingDefaultSolution;
}
export const RETRY_SCALE_DURATION = 100;
@ -64,7 +66,7 @@ export class DefaultSpaceService {
private serviceStatus$?: BehaviorSubject<ServiceStatus>;
public setup({ coreStatus, getSavedObjects, license$, spacesLicense, logger }: Deps) {
public setup({ coreStatus, getSavedObjects, license$, spacesLicense, logger, solution }: Deps) {
const statusLogger = logger.get('status');
this.serviceStatus$ = new BehaviorSubject({
@ -96,6 +98,7 @@ export class DefaultSpaceService {
createDefaultSpace({
getSavedObjects,
logger,
solution,
}).then(() => {
return {
level: ServiceStatusLevels.available,

View file

@ -238,7 +238,7 @@ describe('#imageUrl', () => {
describe('#solution', () => {
it('should throw error if solution is defined in serverless offering', () => {
expect(() =>
spaceServerlessSchema.validate({ ...defaultProperties, solution: 'search' })
spaceServerlessSchema.validate({ ...defaultProperties, solution: 'es' })
).toThrow();
});
@ -253,13 +253,13 @@ describe('#solution', () => {
.toThrowErrorMatchingInlineSnapshot(`
"[solution]: types that failed validation:
- [solution.0]: expected value to equal [security]
- [solution.1]: expected value to equal [observability]
- [solution.2]: expected value to equal [search]
- [solution.1]: expected value to equal [oblt]
- [solution.2]: expected value to equal [es]
- [solution.3]: expected value to equal [classic]"
`);
expect(() =>
spaceBaseSchema.validate({ ...defaultProperties, solution: ' search ' }, {})
spaceBaseSchema.validate({ ...defaultProperties, solution: ' es ' }, {})
).toThrow();
});
});

View file

@ -46,8 +46,8 @@ const spaceSchema = schema.object({
const solutionSchema = schema.oneOf([
schema.literal('security'),
schema.literal('observability'),
schema.literal('search'),
schema.literal('oblt'),
schema.literal('es'),
schema.literal('classic'),
]);

View file

@ -7,15 +7,20 @@
import { lastValueFrom } from 'rxjs';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import type { CoreSetup } from '@kbn/core/server';
import { coreMock } from '@kbn/core/server/mocks';
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks';
import { createDefaultSpace } from './default_space/create_default_space';
import type { PluginsStart } from './plugin';
import { SpacesPlugin } from './plugin';
jest.mock('./default_space/create_default_space');
describe('Spaces plugin', () => {
describe('#setup', () => {
it('can setup with all optional plugins disabled, exposing the expected contract', () => {
@ -76,6 +81,25 @@ describe('Spaces plugin', () => {
expect(usageCollection.getCollectorByType('spaces')).toBeDefined();
});
it('can setup space with default solution', async () => {
const initializerContext = coreMock.createPluginInitializerContext({ maxSpaces: 1000 });
const core = coreMock.createSetup() as CoreSetup<PluginsStart>;
const features = featuresPluginMock.createSetup();
const licensing = licensingMock.createSetup();
const cloud = {
...cloudMock.createSetup(),
apm: {},
onboarding: { defaultSolution: 'security' },
} as CloudSetup;
const plugin = new SpacesPlugin(initializerContext);
plugin.setup(core, { features, licensing, cloud });
expect(createDefaultSpace).toHaveBeenCalledWith(
expect.objectContaining({ solution: 'security' })
);
});
});
describe('#start', () => {

View file

@ -8,6 +8,7 @@
import type { Observable } from 'rxjs';
import { map } from 'rxjs';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import type {
CoreSetup,
CoreStart,
@ -46,6 +47,7 @@ export interface PluginsSetup {
licensing: LicensingPluginSetup;
usageCollection?: UsageCollectionSetup;
home?: HomeServerPluginSetup;
cloud?: CloudSetup;
}
export interface PluginsStart {
@ -161,6 +163,7 @@ export class SpacesPlugin
license$: plugins.licensing.license$,
spacesLicense: license,
logger: this.log,
solution: plugins.cloud?.onboarding?.defaultSolution,
});
initSpacesViewsRoutes({

View file

@ -46,8 +46,8 @@ export class SpacesSavedObjectsService {
solution: schema.maybe(
schema.oneOf([
schema.literal('security'),
schema.literal('observability'),
schema.literal('search'),
schema.literal('oblt'),
schema.literal('es'),
schema.literal('classic'),
])
),

View file

@ -42,7 +42,7 @@ describe('#getAll', () => {
imageUrl: 'go-bots/predates/transformers',
disabledFeatures: [],
_reserved: true,
solution: 'search',
solution: 'es',
bar: 'foo-bar', // an extra attribute that will be ignored during conversion
},
},
@ -81,7 +81,7 @@ describe('#getAll', () => {
initials: 'FB',
imageUrl: 'go-bots/predates/transformers',
disabledFeatures: [],
solution: 'search',
solution: 'es',
_reserved: true,
},
{
@ -224,7 +224,7 @@ describe('#get', () => {
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue({
...savedObject,
attributes: { ...(savedObject.attributes as Record<string, unknown>), solution: 'search' },
attributes: { ...(savedObject.attributes as Record<string, unknown>), solution: 'es' },
});
const mockConfig = createMockConfig();
@ -247,7 +247,7 @@ describe('#get', () => {
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue({
...savedObject,
attributes: { ...(savedObject.attributes as Record<string, unknown>), solution: 'search' },
attributes: { ...(savedObject.attributes as Record<string, unknown>), solution: 'es' },
});
const mockConfig = createMockConfig();
@ -261,7 +261,7 @@ describe('#get', () => {
const id = savedObject.id;
const actualSpace = await client.get(id);
expect(actualSpace).toEqual({ ...expectedSpace, solution: 'search' });
expect(actualSpace).toEqual({ ...expectedSpace, solution: 'es' });
});
});
@ -399,7 +399,7 @@ describe('#create', () => {
);
await expect(
client.create({ ...spaceToCreate, solution: 'search' })
client.create({ ...spaceToCreate, solution: 'es' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Unable to create Space, solution property is forbidden in serverless"`
);
@ -418,7 +418,7 @@ describe('#create', () => {
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.create.mockResolvedValue({
...savedObject,
attributes: { ...(savedObject.attributes as Record<string, unknown>), solution: 'search' },
attributes: { ...(savedObject.attributes as Record<string, unknown>), solution: 'es' },
});
mockCallWithRequestRepository.find.mockResolvedValue({
total: maxSpaces - 1,
@ -438,9 +438,9 @@ describe('#create', () => {
'traditional'
);
const actualSpace = await client.create({ ...spaceToCreate, solution: 'search' });
const actualSpace = await client.create({ ...spaceToCreate, solution: 'es' });
expect(actualSpace).toEqual({ ...expectedReturnedSpace, solution: 'search' });
expect(actualSpace).toEqual({ ...expectedReturnedSpace, solution: 'es' });
expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({
type: 'space',
@ -449,7 +449,7 @@ describe('#create', () => {
});
expect(mockCallWithRequestRepository.create).toHaveBeenCalledWith(
'space',
{ ...attributes, solution: 'search' },
{ ...attributes, solution: 'es' },
{
id,
}
@ -609,7 +609,7 @@ describe('#update', () => {
);
await expect(
client.update(id, { ...spaceToUpdate, solution: 'search' })
client.update(id, { ...spaceToUpdate, solution: 'es' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Unable to update Space, solution property is forbidden in serverless"`
);
@ -659,11 +659,11 @@ describe('#update', () => {
'traditional'
);
const id = savedObject.id;
await client.update(id, { ...spaceToUpdate, solution: 'search' });
await client.update(id, { ...spaceToUpdate, solution: 'es' });
expect(mockCallWithRequestRepository.update).toHaveBeenCalledWith('space', id, {
...attributes,
solution: 'search',
solution: 'es',
});
expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id);
});

View file

@ -46,7 +46,7 @@ async function getSpacesUsage(
}
const knownFeatureIds = features.getKibanaFeatures().map((feature) => feature.id);
const knownSolutions = ['classic', 'search', 'observability', 'security', 'unset'];
const knownSolutions = ['classic', 'es', 'oblt', 'security', 'unset'];
const resp = (await esClient.search({
index: kibanaIndex,
@ -205,13 +205,13 @@ export function getSpacesUsageCollector(
description: 'The number of spaces which have solution set to classic.',
},
},
search: {
es: {
type: 'long',
_meta: {
description: 'The number of spaces which have solution set to search.',
},
},
observability: {
oblt: {
type: 'long',
_meta: {
description: 'The number of spaces which have solution set to observability.',

View file

@ -36,6 +36,7 @@
"@kbn/react-kibana-context-render",
"@kbn/utility-types-jest",
"@kbn/security-plugin-types-public",
"@kbn/cloud-plugin",
],
"exclude": [
"target/**/*",

View file

@ -15121,13 +15121,13 @@
"description": "The number of spaces which have solution set to classic."
}
},
"search": {
"es": {
"type": "long",
"_meta": {
"description": "The number of spaces which have solution set to search."
}
},
"observability": {
"oblt": {
"type": "long",
"_meta": {
"description": "The number of spaces which have solution set to observability."

View file

@ -71,7 +71,7 @@ export function createTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
description: 'a description',
color: '#5c5959',
disabledFeatures: [],
solution: 'search',
solution: 'es',
});
};
@ -151,7 +151,7 @@ export function createTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
id: 'solution',
description: 'a description',
color: '#5c5959',
solution: 'search',
solution: 'es',
disabledFeatures: [],
})
.expect(tests.solutionSpecified.statusCode)

View file

@ -38,7 +38,7 @@ export default function ({ getService }: FtrProviderContext) {
description: 'This is your space-3!',
color: '#00bfb3',
disabledFeatures: [],
solution: 'search',
solution: 'es',
});
});
@ -103,8 +103,8 @@ export default function ({ getService }: FtrProviderContext) {
expect(stats.stack_stats.kibana.plugins.spaces.solutions).to.eql({
security: 1,
search: 1,
observability: 0,
es: 1,
oblt: 0,
classic: 0,
unset: 2,
});