diff --git a/x-pack/legacy/plugins/spaces/index.ts b/x-pack/legacy/plugins/spaces/index.ts index 8d44c17018255..0b83dfdc0439c 100644 --- a/x-pack/legacy/plugins/spaces/index.ts +++ b/x-pack/legacy/plugins/spaces/index.ts @@ -13,9 +13,6 @@ import { SpacesPluginSetup } from '../../../plugins/spaces/server'; // @ts-ignore import { AuditLogger } from '../../server/lib/audit_logger'; import { wrapError } from './server/lib/errors'; -// @ts-ignore -import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; -import { initEnterSpaceView } from './server/routes/views'; export interface LegacySpacesPlugin { getSpaceId: (request: Legacy.Request) => ReturnType; @@ -51,7 +48,7 @@ export const spaces = (kibana: Record) => ) { // NOTICE: use of `activeSpace` is deprecated and will not be made available in the New Platform. // Known usages: - // - x-pack/legacy/plugins/infra/public/utils/use_kibana_space_id.ts + // - x-pack/plugins/infra/public/utils/use_kibana_space_id.ts const spacesPlugin = server.newPlatform.setup.plugins.spaces as SpacesPluginSetup; if (!spacesPlugin) { throw new Error('New Platform XPack Spaces plugin is not available.'); @@ -83,7 +80,7 @@ export const spaces = (kibana: Record) => throw new Error('New Platform XPack Spaces plugin is not available.'); } - const { registerLegacyAPI, createDefaultSpace } = spacesPlugin.__legacyCompat; + const { registerLegacyAPI } = spacesPlugin.__legacyCompat; registerLegacyAPI({ auditLogger: { @@ -92,12 +89,6 @@ export const spaces = (kibana: Record) => }, }); - initEnterSpaceView(server); - - watchStatusAndLicenseToInitialize(server.plugins.xpack_main, this, async () => { - await createDefaultSpace(); - }); - server.expose('getSpaceId', (request: Legacy.Request) => spacesPlugin.spacesService.getSpaceId(request) ); diff --git a/x-pack/legacy/plugins/spaces/server/routes/views/enter_space.ts b/x-pack/legacy/plugins/spaces/server/routes/views/enter_space.ts deleted file mode 100644 index 60ae3a1fa77bb..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/views/enter_space.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Legacy } from 'kibana'; -import { ENTER_SPACE_PATH } from '../../../../../../plugins/spaces/common/constants'; -import { wrapError } from '../../lib/errors'; - -export function initEnterSpaceView(server: Legacy.Server) { - server.route({ - method: 'GET', - path: ENTER_SPACE_PATH, - async handler(request, h) { - try { - const uiSettings = request.getUiSettingsService(); - const defaultRoute = await uiSettings.get('defaultRoute'); - - const basePath = server.newPlatform.setup.core.http.basePath.get(request); - const url = `${basePath}${defaultRoute}`; - - return h.redirect(url); - } catch (e) { - server.log(['spaces', 'error'], `Error navigating to space: ${e}`); - return wrapError(e); - } - }, - }); -} diff --git a/x-pack/plugins/spaces/common/licensing/index.mock.ts b/x-pack/plugins/spaces/common/licensing/index.mock.ts new file mode 100644 index 0000000000000..3e67749821f54 --- /dev/null +++ b/x-pack/plugins/spaces/common/licensing/index.mock.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SpacesLicense } from '.'; + +export const licenseMock = { + create: (): jest.Mocked => ({ + isEnabled: jest.fn().mockReturnValue(true), + }), +}; diff --git a/x-pack/plugins/spaces/common/licensing/index.ts b/x-pack/plugins/spaces/common/licensing/index.ts new file mode 100644 index 0000000000000..467d4c060427f --- /dev/null +++ b/x-pack/plugins/spaces/common/licensing/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SpacesLicenseService, SpacesLicense } from './license_service'; diff --git a/x-pack/plugins/spaces/common/licensing/license_service.test.ts b/x-pack/plugins/spaces/common/licensing/license_service.test.ts new file mode 100644 index 0000000000000..72c5b0ef04d80 --- /dev/null +++ b/x-pack/plugins/spaces/common/licensing/license_service.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { of } from 'rxjs'; +import { licensingMock } from '../../../licensing/public/mocks'; +import { SpacesLicenseService } from './license_service'; +import { LICENSE_TYPE, LicenseType } from '../../../licensing/common/types'; + +describe('license#isEnabled', function() { + it('should indicate that Spaces is disabled when there is no license information', () => { + const serviceSetup = new SpacesLicenseService().setup({ + license$: of(undefined as any), + }); + expect(serviceSetup.license.isEnabled()).toEqual(false); + }); + + it('should indicate that Spaces is disabled when xpack is unavailable', () => { + const rawLicenseMock = licensingMock.createLicenseMock(); + rawLicenseMock.isAvailable = false; + const serviceSetup = new SpacesLicenseService().setup({ + license$: of(rawLicenseMock), + }); + expect(serviceSetup.license.isEnabled()).toEqual(false); + }); + + for (const level in LICENSE_TYPE) { + if (isNaN(level as any)) { + it(`should indicate that Spaces is enabled with a ${level} license`, () => { + const rawLicense = licensingMock.createLicense({ + license: { + status: 'active', + type: level as LicenseType, + }, + }); + + const serviceSetup = new SpacesLicenseService().setup({ + license$: of(rawLicense), + }); + expect(serviceSetup.license.isEnabled()).toEqual(true); + }); + } + } +}); diff --git a/x-pack/plugins/spaces/common/licensing/license_service.ts b/x-pack/plugins/spaces/common/licensing/license_service.ts new file mode 100644 index 0000000000000..246928f8b65b0 --- /dev/null +++ b/x-pack/plugins/spaces/common/licensing/license_service.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, Subscription } from 'rxjs'; +import { ILicense } from '../../../licensing/common/types'; + +export interface SpacesLicense { + isEnabled(): boolean; +} + +interface SetupDeps { + license$: Observable; +} + +export class SpacesLicenseService { + private licenseSubscription?: Subscription; + + public setup({ license$ }: SetupDeps) { + let rawLicense: Readonly | undefined; + + this.licenseSubscription = license$.subscribe(nextRawLicense => { + rawLicense = nextRawLicense; + }); + + return { + license: Object.freeze({ + isEnabled: () => this.isSpacesEnabledFromRawLicense(rawLicense), + }), + }; + } + + public stop() { + if (this.licenseSubscription) { + this.licenseSubscription.unsubscribe(); + this.licenseSubscription = undefined; + } + } + + private isSpacesEnabledFromRawLicense(rawLicense: Readonly | undefined) { + if (!rawLicense || !rawLicense.isAvailable) { + return false; + } + + const licenseCheck = rawLicense.check('spaces', 'basic'); + return licenseCheck.state !== 'unavailable' && licenseCheck.state !== 'invalid'; + } +} diff --git a/x-pack/plugins/spaces/server/lib/create_default_space.test.ts b/x-pack/plugins/spaces/server/default_space/create_default_space.test.ts similarity index 85% rename from x-pack/plugins/spaces/server/lib/create_default_space.test.ts rename to x-pack/plugins/spaces/server/default_space/create_default_space.test.ts index 03e774ce67d2b..80cc7428e28e7 100644 --- a/x-pack/plugins/spaces/server/lib/create_default_space.test.ts +++ b/x-pack/plugins/spaces/server/default_space/create_default_space.test.ts @@ -6,6 +6,7 @@ import { createDefaultSpace } from './create_default_space'; import { SavedObjectsErrorHelpers } from 'src/core/server'; +import { loggingServiceMock } from '../../../../../src/core/server/mocks'; interface MockServerSettings { defaultExists?: boolean; @@ -47,14 +48,16 @@ const createMockDeps = (settings: MockServerSettings = {}) => { }); return { - savedObjects: { - createInternalRepository: jest.fn().mockImplementation(() => { - return { - get: mockGet, - create: mockCreate, - }; + getSavedObjects: () => + Promise.resolve({ + createInternalRepository: jest.fn().mockImplementation(() => { + return { + get: mockGet, + create: mockCreate, + }; + }), }), - }, + logger: loggingServiceMock.createLogger(), }; }; @@ -65,7 +68,7 @@ test(`it creates the default space when one does not exist`, async () => { await createDefaultSpace(deps); - const repository = deps.savedObjects.createInternalRepository(); + const repository = (await deps.getSavedObjects()).createInternalRepository(); expect(repository.get).toHaveBeenCalledTimes(1); expect(repository.create).toHaveBeenCalledTimes(1); @@ -89,7 +92,7 @@ test(`it does not attempt to recreate the default space if it already exists`, a await createDefaultSpace(deps); - const repository = deps.savedObjects.createInternalRepository(); + const repository = (await deps.getSavedObjects()).createInternalRepository(); expect(repository.get).toHaveBeenCalledTimes(1); expect(repository.create).toHaveBeenCalledTimes(0); @@ -114,7 +117,7 @@ test(`it ignores conflict errors if the default space already exists`, async () await createDefaultSpace(deps); - const repository = deps.savedObjects.createInternalRepository(); + const repository = (await deps.getSavedObjects()).createInternalRepository(); expect(repository.get).toHaveBeenCalledTimes(1); expect(repository.create).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/spaces/server/lib/create_default_space.ts b/x-pack/plugins/spaces/server/default_space/create_default_space.ts similarity index 76% rename from x-pack/plugins/spaces/server/lib/create_default_space.ts rename to x-pack/plugins/spaces/server/default_space/create_default_space.ts index e0cb75c54220a..12826faa1e0e1 100644 --- a/x-pack/plugins/spaces/server/lib/create_default_space.ts +++ b/x-pack/plugins/spaces/server/default_space/create_default_space.ts @@ -5,22 +5,26 @@ */ import { i18n } from '@kbn/i18n'; -import { SavedObjectsServiceStart, SavedObjectsRepository } from 'src/core/server'; +import { SavedObjectsServiceStart, SavedObjectsRepository, Logger } from 'src/core/server'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { DEFAULT_SPACE_ID } from '../../common/constants'; interface Deps { - savedObjects: Pick; + getSavedObjects: () => Promise>; + logger: Logger; } -export async function createDefaultSpace({ savedObjects }: Deps) { - const { createInternalRepository } = savedObjects; +export async function createDefaultSpace({ getSavedObjects, logger }: Deps) { + const { createInternalRepository } = await getSavedObjects(); const savedObjectsRepository = createInternalRepository(['space']); + logger.debug('Checking for existing default space'); + const defaultSpaceExists = await doesDefaultSpaceExist(savedObjectsRepository); if (defaultSpaceExists) { + logger.debug('Default space already exists'); return; } @@ -28,6 +32,7 @@ export async function createDefaultSpace({ savedObjects }: Deps) { id: DEFAULT_SPACE_ID, }; + logger.debug('Creating the default space'); try { await savedObjectsRepository.create( 'space', @@ -53,6 +58,8 @@ export async function createDefaultSpace({ savedObjects }: Deps) { } throw error; } + + logger.debug('Default space created'); } async function doesDefaultSpaceExist(savedObjectsRepository: Pick) { diff --git a/x-pack/plugins/spaces/server/default_space/default_space_service.test.ts b/x-pack/plugins/spaces/server/default_space/default_space_service.test.ts new file mode 100644 index 0000000000000..2d677565164a2 --- /dev/null +++ b/x-pack/plugins/spaces/server/default_space/default_space_service.test.ts @@ -0,0 +1,280 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as Rx from 'rxjs'; +import { + DefaultSpaceService, + RETRY_SCALE_DURATION, + RETRY_DURATION_MAX, +} from './default_space_service'; +import { + ServiceStatusLevels, + ServiceStatusLevel, + CoreStatus, + SavedObjectsRepository, + SavedObjectsErrorHelpers, +} from '../../../../../src/core/server'; +import { coreMock, loggingServiceMock } from 'src/core/server/mocks'; +import { licensingMock } from '../../../licensing/server/mocks'; +import { SpacesLicenseService } from '../../common/licensing'; +import { ILicense } from '../../../licensing/server'; +import { nextTick } from 'test_utils/enzyme_helpers'; +import { first } from 'rxjs/operators'; + +const advanceRetry = async (initializeCount: number) => { + await Promise.resolve(); + let duration = initializeCount * RETRY_SCALE_DURATION; + if (duration > RETRY_DURATION_MAX) { + duration = RETRY_DURATION_MAX; + } + jest.advanceTimersByTime(duration); +}; + +interface SetupOpts { + elasticsearchStatus: ServiceStatusLevel; + savedObjectsStatus: ServiceStatusLevel; + license: ILicense; +} +const setup = ({ elasticsearchStatus, savedObjectsStatus, license }: SetupOpts) => { + const core = coreMock.createSetup(); + const { status } = core; + status.core$ = (new Rx.BehaviorSubject({ + elasticsearch: { + level: elasticsearchStatus, + summary: '', + }, + savedObjects: { + level: savedObjectsStatus, + summary: '', + }, + }) as unknown) as Rx.Observable; + + const { savedObjects } = coreMock.createStart(); + const repository = savedObjects.createInternalRepository() as jest.Mocked; + // simulate space not found + repository.get.mockRejectedValue(SavedObjectsErrorHelpers.createGenericNotFoundError()); + repository.create.mockReturnValue(Promise.resolve({} as any)); + + const license$ = new Rx.BehaviorSubject(license); + + const logger = loggingServiceMock.createLogger(); + + const { license: spacesLicense } = new SpacesLicenseService().setup({ license$ }); + + const defaultSpaceService = new DefaultSpaceService(); + const { serviceStatus$ } = defaultSpaceService.setup({ + coreStatus: status, + getSavedObjects: () => Promise.resolve(savedObjects), + license$, + logger, + spacesLicense, + }); + + return { + coreStatus: (status as unknown) as { core$: Rx.BehaviorSubject }, + serviceStatus$, + logger, + license$, + savedObjects, + repository, + }; +}; + +test(`does not initialize if elasticsearch is unavailable`, async () => { + const { repository, serviceStatus$ } = setup({ + elasticsearchStatus: ServiceStatusLevels.unavailable, + savedObjectsStatus: ServiceStatusLevels.available, + license: licensingMock.createLicense({ + license: { + status: 'active', + type: 'gold', + }, + }), + }); + + await nextTick(); + + expect(repository.get).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); + + const status = await serviceStatus$.pipe(first()).toPromise(); + expect(status.level).toEqual(ServiceStatusLevels.unavailable); + expect(status.summary).toMatchInlineSnapshot(`"required core services are not ready"`); +}); + +test(`does not initialize if savedObjects is unavailable`, async () => { + const { serviceStatus$, repository } = setup({ + elasticsearchStatus: ServiceStatusLevels.available, + savedObjectsStatus: ServiceStatusLevels.unavailable, + license: licensingMock.createLicense({ + license: { + status: 'active', + type: 'gold', + }, + }), + }); + + await nextTick(); + + expect(repository.get).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); + const status = await serviceStatus$.pipe(first()).toPromise(); + expect(status.level).toEqual(ServiceStatusLevels.unavailable); + expect(status.summary).toMatchInlineSnapshot(`"required core services are not ready"`); +}); + +test(`does not initialize if the license is unavailable`, async () => { + const license = licensingMock.createLicense({ + license: ({ type: ' ', status: ' ' } as unknown) as ILicense, + }) as Writable; + license.isAvailable = false; + + const { serviceStatus$, repository } = setup({ + elasticsearchStatus: ServiceStatusLevels.available, + savedObjectsStatus: ServiceStatusLevels.available, + license, + }); + + await nextTick(); + + expect(repository.get).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); + const status = await serviceStatus$.pipe(first()).toPromise(); + expect(status.level).toEqual(ServiceStatusLevels.unavailable); + expect(status.summary).toMatchInlineSnapshot(`"missing or invalid license"`); +}); + +test(`initializes once all dependencies are met`, async () => { + const { repository, coreStatus, serviceStatus$ } = setup({ + elasticsearchStatus: ServiceStatusLevels.available, + savedObjectsStatus: ServiceStatusLevels.unavailable, + license: licensingMock.createLicense({ + license: { + type: 'gold', + status: 'active', + }, + }), + }); + + await nextTick(); + + expect(repository.get).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); + + const status = await serviceStatus$.pipe(first()).toPromise(); + expect(status.level).toEqual(ServiceStatusLevels.unavailable); + expect(status.summary).toMatchInlineSnapshot(`"required core services are not ready"`); + + coreStatus.core$.next({ + elasticsearch: { + level: ServiceStatusLevels.available, + summary: '', + }, + savedObjects: { + level: ServiceStatusLevels.available, + summary: '', + }, + }); + + await nextTick(); + + expect(repository.get).toHaveBeenCalled(); + expect(repository.create).toHaveBeenCalled(); + + const nextStatus = await serviceStatus$.pipe(first()).toPromise(); + expect(nextStatus.level).toEqual(ServiceStatusLevels.available); + expect(nextStatus.summary).toMatchInlineSnapshot(`"ready"`); +}); + +test('maintains unavailable status if default space cannot be created', async () => { + const { repository, serviceStatus$ } = setup({ + elasticsearchStatus: ServiceStatusLevels.available, + savedObjectsStatus: ServiceStatusLevels.available, + license: licensingMock.createLicense({ + license: { + type: 'gold', + status: 'active', + }, + }), + }); + + repository.create.mockRejectedValue(new Error('something bad happened')); + + await nextTick(); + + expect(repository.get).toHaveBeenCalled(); + expect(repository.create).toHaveBeenCalled(); + + const status = await serviceStatus$.pipe(first()).toPromise(); + expect(status.level).toEqual(ServiceStatusLevels.unavailable); + expect(status.summary).toMatchInlineSnapshot( + `"Error creating default space: something bad happened"` + ); +}); + +test('retries operation', async () => { + jest.useFakeTimers(); + + const { repository, serviceStatus$ } = setup({ + elasticsearchStatus: ServiceStatusLevels.available, + savedObjectsStatus: ServiceStatusLevels.available, + license: licensingMock.createLicense({ + license: { + type: 'gold', + status: 'active', + }, + }), + }); + + repository.create.mockRejectedValue(new Error('something bad happened')); + + await nextTick(); + + expect(repository.get).toHaveBeenCalledTimes(1); + expect(repository.create).toHaveBeenCalledTimes(1); + + let status = await serviceStatus$.pipe(first()).toPromise(); + expect(status.level).toEqual(ServiceStatusLevels.unavailable); + expect(status.summary).toMatchInlineSnapshot( + `"Error creating default space: something bad happened"` + ); + + await advanceRetry(1); + await nextTick(); + + expect(repository.get).toHaveBeenCalledTimes(2); + expect(repository.create).toHaveBeenCalledTimes(2); + + status = await serviceStatus$.pipe(first()).toPromise(); + expect(status.level).toEqual(ServiceStatusLevels.unavailable); + expect(status.summary).toMatchInlineSnapshot( + `"Error creating default space: something bad happened"` + ); + + repository.create.mockResolvedValue({} as any); + + // retries are scaled back, so this should not cause the repository to be invoked + await advanceRetry(1); + await nextTick(); + + expect(repository.get).toHaveBeenCalledTimes(2); + expect(repository.create).toHaveBeenCalledTimes(2); + + status = await serviceStatus$.pipe(first()).toPromise(); + expect(status.level).toEqual(ServiceStatusLevels.unavailable); + expect(status.summary).toMatchInlineSnapshot( + `"Error creating default space: something bad happened"` + ); + + await advanceRetry(1); + await nextTick(); + + expect(repository.get).toHaveBeenCalledTimes(3); + expect(repository.create).toHaveBeenCalledTimes(3); + + status = await serviceStatus$.pipe(first()).toPromise(); + expect(status.level).toEqual(ServiceStatusLevels.available); + expect(status.summary).toMatchInlineSnapshot(`"ready"`); +}); diff --git a/x-pack/plugins/spaces/server/default_space/default_space_service.ts b/x-pack/plugins/spaces/server/default_space/default_space_service.ts new file mode 100644 index 0000000000000..e8547d43f62c9 --- /dev/null +++ b/x-pack/plugins/spaces/server/default_space/default_space_service.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, SavedObjectsServiceStart, Logger, ServiceStatus } from 'src/core/server'; +import { + concat, + of, + timer, + Observable, + ObservableInput, + combineLatest, + defer, + Subscription, + BehaviorSubject, +} from 'rxjs'; +import { mergeMap, switchMap, catchError, tap } from 'rxjs/operators'; +import { ServiceStatusLevels } from '../../../../../src/core/server'; +import { ILicense } from '../../../licensing/server'; +import { SpacesLicense } from '../../common/licensing'; +import { createDefaultSpace } from './create_default_space'; + +interface Deps { + coreStatus: CoreSetup['status']; + getSavedObjects: () => Promise>; + license$: Observable; + spacesLicense: SpacesLicense; + logger: Logger; +} + +export const RETRY_SCALE_DURATION = 100; +export const RETRY_DURATION_MAX = 10000; + +const calculateDuration = (i: number) => { + const duration = i * RETRY_SCALE_DURATION; + if (duration > RETRY_DURATION_MAX) { + return RETRY_DURATION_MAX; + } + + return duration; +}; + +// we can't use a retryWhen here, because we want to propagate the unavailable status and then retry +const propagateUnavailableStatusAndScaleRetry = () => { + let i = 0; + return (err: Error, caught: ObservableInput) => + concat( + of({ + level: ServiceStatusLevels.unavailable, + summary: `Error creating default space: ${err.message}`, + }), + timer(calculateDuration(++i)).pipe(mergeMap(() => caught)) + ); +}; + +export class DefaultSpaceService { + private initializeSubscription?: Subscription; + + private serviceStatus$?: BehaviorSubject; + + public setup({ coreStatus, getSavedObjects, license$, spacesLicense, logger }: Deps) { + const statusLogger = logger.get('status'); + + this.serviceStatus$ = new BehaviorSubject({ + level: ServiceStatusLevels.unavailable, + summary: 'not initialized', + } as ServiceStatus); + + this.initializeSubscription = combineLatest([coreStatus.core$, license$]) + .pipe( + switchMap(([status]) => { + const isElasticsearchReady = status.elasticsearch.level === ServiceStatusLevels.available; + const isSavedObjectsReady = status.savedObjects.level === ServiceStatusLevels.available; + + if (!isElasticsearchReady || !isSavedObjectsReady) { + return of({ + level: ServiceStatusLevels.unavailable, + summary: 'required core services are not ready', + } as ServiceStatus); + } + + if (!spacesLicense.isEnabled()) { + return of({ + level: ServiceStatusLevels.unavailable, + summary: 'missing or invalid license', + } as ServiceStatus); + } + + return defer(() => + createDefaultSpace({ + getSavedObjects, + logger, + }).then(() => { + return { + level: ServiceStatusLevels.available, + summary: 'ready', + }; + }) + ).pipe(catchError(propagateUnavailableStatusAndScaleRetry())); + }), + tap(spacesStatus => { + // This is temporary for debugging/visibility until we get a proper status service from core. + // See issue #41983 for details. + statusLogger.debug(`${spacesStatus.level.toString()}: ${spacesStatus.summary}`); + this.serviceStatus$!.next(spacesStatus); + }) + ) + .subscribe(); + + return { + serviceStatus$: this.serviceStatus$!.asObservable(), + }; + } + + public stop() { + if (this.initializeSubscription) { + this.initializeSubscription.unsubscribe(); + } + this.initializeSubscription = undefined; + + if (this.serviceStatus$) { + this.serviceStatus$.complete(); + this.serviceStatus$ = undefined; + } + } +} diff --git a/x-pack/legacy/plugins/spaces/server/routes/views/index.ts b/x-pack/plugins/spaces/server/default_space/index.ts similarity index 79% rename from x-pack/legacy/plugins/spaces/server/routes/views/index.ts rename to x-pack/plugins/spaces/server/default_space/index.ts index 645e8bec48148..cb8ce47e86244 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/views/index.ts +++ b/x-pack/plugins/spaces/server/default_space/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { initEnterSpaceView } from './enter_space'; +export { DefaultSpaceService } from './default_space_service'; diff --git a/x-pack/plugins/spaces/server/plugin.test.ts b/x-pack/plugins/spaces/server/plugin.test.ts index 75ddee772b168..7126f96f4f829 100644 --- a/x-pack/plugins/spaces/server/plugin.test.ts +++ b/x-pack/plugins/spaces/server/plugin.test.ts @@ -24,7 +24,6 @@ describe('Spaces Plugin', () => { expect(spacesSetup).toMatchInlineSnapshot(` Object { "__legacyCompat": Object { - "createDefaultSpace": [Function], "registerLegacyAPI": [Function], }, "spacesService": Object { diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index 09b38adb70682..36809bf0e9e7a 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -14,7 +14,6 @@ import { } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; import { LicensingPluginSetup } from '../../licensing/server'; -import { createDefaultSpace } from './lib/create_default_space'; // @ts-ignore import { AuditLogger } from '../../../../server/lib/audit_logger'; import { SpacesAuditLogger } from './lib/audit_logger'; @@ -29,6 +28,8 @@ import { initInternalSpacesApi } from './routes/api/internal'; import { initSpacesViewsRoutes } from './routes/views'; import { setupCapabilities } from './capabilities'; import { SpacesSavedObjectsService } from './saved_objects'; +import { DefaultSpaceService } from './default_space'; +import { SpacesLicenseService } from '../common/licensing'; /** * Describes a set of APIs that is available in the legacy platform only and required by this plugin @@ -56,10 +57,6 @@ export interface SpacesPluginSetup { spacesService: SpacesServiceSetup; __legacyCompat: { registerLegacyAPI: (legacyAPI: LegacyAPI) => void; - // TODO: We currently need the legacy plugin to inform this plugin when it is safe to create the default space. - // The NP does not have the equivilent ES connection/health/comapt checks that the legacy world does. - // See: https://github.com/elastic/kibana/issues/43456 - createDefaultSpace: () => Promise; }; } @@ -72,6 +69,10 @@ export class Plugin { private readonly log: Logger; + private readonly spacesLicenseService = new SpacesLicenseService(); + + private defaultSpaceService?: DefaultSpaceService; + private legacyAPI?: LegacyAPI; private readonly getLegacyAPI = () => { if (!this.legacyAPI) { @@ -115,8 +116,21 @@ export class Plugin { const savedObjectsService = new SpacesSavedObjectsService(); savedObjectsService.setup({ core, spacesService }); + const { license } = this.spacesLicenseService.setup({ license$: plugins.licensing.license$ }); + + this.defaultSpaceService = new DefaultSpaceService(); + this.defaultSpaceService.setup({ + coreStatus: core.status, + getSavedObjects: async () => (await core.getStartServices())[0].savedObjects, + license$: plugins.licensing.license$, + spacesLicense: license, + logger: this.log, + }); + initSpacesViewsRoutes({ httpResources: core.http.resources, + basePath: core.http.basePath, + logger: this.log, }); const externalRouter = core.http.createRouter(); @@ -167,15 +181,13 @@ export class Plugin { registerLegacyAPI: (legacyAPI: LegacyAPI) => { this.legacyAPI = legacyAPI; }, - createDefaultSpace: async () => { - const [coreStart] = await core.getStartServices(); - return await createDefaultSpace({ - savedObjects: coreStart.savedObjects, - }); - }, }, }; } - public stop() {} + public stop() { + if (this.defaultSpaceService) { + this.defaultSpaceService.stop(); + } + } } diff --git a/x-pack/plugins/spaces/server/routes/views/index.ts b/x-pack/plugins/spaces/server/routes/views/index.ts index 57ad8872ce558..5a3cf04370a3f 100644 --- a/x-pack/plugins/spaces/server/routes/views/index.ts +++ b/x-pack/plugins/spaces/server/routes/views/index.ts @@ -4,10 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HttpResources } from 'src/core/server'; +import { HttpResources, IBasePath, Logger } from 'src/core/server'; +import { ENTER_SPACE_PATH } from '../../../common'; +import { wrapError } from '../../lib/errors'; export interface ViewRouteDeps { httpResources: HttpResources; + basePath: IBasePath; + logger: Logger; } export function initSpacesViewsRoutes(deps: ViewRouteDeps) { @@ -15,4 +19,25 @@ export function initSpacesViewsRoutes(deps: ViewRouteDeps) { { path: '/spaces/space_selector', validate: false }, (context, request, response) => response.renderCoreApp() ); + + deps.httpResources.register( + { path: ENTER_SPACE_PATH, validate: false }, + async (context, request, response) => { + try { + const defaultRoute = await context.core.uiSettings.client.get('defaultRoute'); + + const basePath = deps.basePath.get(request); + const url = `${basePath}${defaultRoute}`; + + return response.redirected({ + headers: { + location: url, + }, + }); + } catch (e) { + deps.logger.error(`Error navigating to space: ${e}`); + return response.customError(wrapError(e)); + } + } + ); }