diff --git a/src/core/server/bootstrap.ts b/src/core/server/bootstrap.ts index 18a5eceb1b2d326..c48f20bbadc0b1a 100644 --- a/src/core/server/bootstrap.ts +++ b/src/core/server/bootstrap.ts @@ -59,9 +59,9 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot reloadConfiguration(); }); - function reloadConfiguration() { + function reloadConfiguration(reason = 'SIGHUP') { const cliLogger = root.logger.get('cli'); - cliLogger.info('Reloading Kibana configuration due to SIGHUP.', { tags: ['config'] }); + cliLogger.info(`Reloading Kibana configuration due to ${reason}.`, { tags: ['config'] }); try { rawConfigService.reloadConfig(); @@ -69,7 +69,7 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot return shutdown(err); } - cliLogger.info('Reloaded Kibana configuration due to SIGHUP.', { tags: ['config'] }); + cliLogger.info(`Reloaded Kibana configuration due to ${reason}.`, { tags: ['config'] }); } process.on('SIGINT', () => shutdown()); @@ -81,11 +81,28 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot } try { + const { preboot } = await root.preboot(); + + // If setup is on hold then preboot server is supposed to serve user requests and we can let + // dev parent process know that we are ready for dev mode. + const isSetupOnHold = preboot.isSetupOnHold(); + if (process.send && isSetupOnHold) { + process.send(['SERVER_LISTENING']); + } + + if (isSetupOnHold) { + root.logger.get().info('Holding setup until preboot phase is completed.'); + const { shouldReloadConfig } = await preboot.waitUntilCanSetup(); + if (shouldReloadConfig) { + await reloadConfiguration('preboot request'); + } + } + await root.setup(); await root.start(); - // notify parent process know when we are ready for dev mode. - if (process.send) { + // Notify parent process if we haven't that yet during preboot phase. + if (process.send && !isSetupOnHold) { process.send(['SERVER_LISTENING']); } } catch (err) { diff --git a/src/core/server/capabilities/capabilities_service.ts b/src/core/server/capabilities/capabilities_service.ts index f76a7d2738afbe4..1166c8f6b48c4d2 100644 --- a/src/core/server/capabilities/capabilities_service.ts +++ b/src/core/server/capabilities/capabilities_service.ts @@ -9,7 +9,7 @@ import { Capabilities, CapabilitiesProvider, CapabilitiesSwitcher } from './types'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; -import { InternalHttpServiceSetup, KibanaRequest } from '../http'; +import { InternalHttpServicePreboot, InternalHttpServiceSetup, KibanaRequest } from '../http'; import { mergeCapabilities } from './merge_capabilities'; import { getCapabilitiesResolver, CapabilitiesResolver } from './resolve_capabilities'; import { registerRoutes } from './routes'; @@ -120,6 +120,10 @@ export interface CapabilitiesStart { ): Promise; } +interface PrebootSetupDeps { + http: InternalHttpServicePreboot; +} + interface SetupDeps { http: InternalHttpServiceSetup; } @@ -149,17 +153,21 @@ export class CapabilitiesService { ); } + public preboot(prebootDeps: PrebootSetupDeps) { + this.logger.debug('Prebooting capabilities service'); + + // The preboot server has no need for real capabilities. + // Returning the un-augmented defaults is sufficient. + prebootDeps.http.registerRoutes('', (router) => { + registerRoutes(router, async () => defaultCapabilities); + }); + } + public setup(setupDeps: SetupDeps): CapabilitiesSetup { this.logger.debug('Setting up capabilities service'); registerRoutes(setupDeps.http.createRouter(''), this.resolveCapabilities); - // The not ready server has no need for real capabilities. - // Returning the un-augmented defaults is sufficient. - setupDeps.http.notReadyServer?.registerRoutes('', (notReadyRouter) => { - registerRoutes(notReadyRouter, async () => defaultCapabilities); - }); - return { registerProvider: (provider: CapabilitiesProvider) => { this.capabilitiesProviders.push(provider); diff --git a/src/core/server/core_app/core_app.ts b/src/core/server/core_app/core_app.ts index efdb5ed20959cbb..980eb68c5b2ce61 100644 --- a/src/core/server/core_app/core_app.ts +++ b/src/core/server/core_app/core_app.ts @@ -12,7 +12,8 @@ import { Env } from '@kbn/config'; import { schema } from '@kbn/config-schema'; import { fromRoot } from '@kbn/utils'; -import { InternalCoreSetup } from '../internal_types'; +import { IRouter, KibanaRequest, RequestHandlerContext } from 'kibana/server'; +import { InternalCorePrebootSetup, InternalCoreSetup } from '../internal_types'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { registerBundleRoutes } from './bundle_routes'; @@ -28,65 +29,95 @@ export class CoreApp { this.env = core.env; } - setup(coreSetup: InternalCoreSetup, uiPlugins: UiPlugins, notReadyServerUiPlugins: UiPlugins) { - this.logger.debug('Setting up core app.'); - this.registerDefaultRoutes(coreSetup, uiPlugins, notReadyServerUiPlugins); - this.registerStaticDirs(coreSetup); + preboot(corePreboot: InternalCorePrebootSetup, uiPlugins: UiPlugins) { + this.logger.debug('Prebooting core app.'); + + corePreboot.http.registerRoutes('', (router) => { + this.registerPrebootDefaultRoutes(router, corePreboot, uiPlugins); + this.registerStaticDirs(corePreboot); + }); } - private registerDefaultRoutes( - coreSetup: InternalCoreSetup, - uiPlugins: UiPlugins, - notReadyServerUiPlugins: UiPlugins - ) { - const httpSetup = coreSetup.http; - const router = httpSetup.createRouter(''); - const resources = coreSetup.httpResources.createRegistrar(router); + setup(coreSetup: InternalCoreSetup, uiPlugins: UiPlugins) { + this.logger.debug('Setting up core app.'); - router.get({ path: '/', validate: false }, async (context, req, res) => { + const defaultLocation = async (context: RequestHandlerContext, req: KibanaRequest) => { const defaultRoute = await context.core.uiSettings.client.get('defaultRoute'); - const basePath = httpSetup.basePath.get(req); - const url = `${basePath}${defaultRoute}`; + const basePath = coreSetup.http.basePath.get(req); + return `${basePath}${defaultRoute}`; + }; - return res.redirected({ - headers: { - location: url, + const router = coreSetup.http.createRouter(''); + this.registerDefaultRoutes(router, coreSetup, uiPlugins, defaultLocation); + this.registerStaticDirs(coreSetup); + } + + private registerPrebootDefaultRoutes( + router: IRouter, + core: InternalCorePrebootSetup, + uiPlugins: UiPlugins + ) { + // remove trailing slash catch-all + const resources = core.httpResources.createRegistrar(router); + resources.register( + { + path: '/{path*}', + validate: { + params: schema.object({ + path: schema.maybe(schema.string()), + }), + query: schema.maybe(schema.recordOf(schema.string(), schema.any())), }, - }); - }); + }, + async (context, req, res) => { + const { query, params } = req; + const { path } = params; + if (!path || !path.endsWith('/') || path.startsWith('/')) { + return res.renderAnonymousCoreApp(); + } - httpSetup.notReadyServer?.registerRoutes('', (notReadyRouter) => { - const notReadyResources = coreSetup.httpResources.createRegistrar(notReadyRouter); + const basePath = core.http.basePath.get(req); + let rewrittenPath = path.slice(0, -1); + if (`/${path}`.startsWith(basePath)) { + rewrittenPath = rewrittenPath.substring(basePath.length); + } - // TODO: need better mechanism for this, -OR- make setup mode render at the root instead. - notReadyRouter.get({ path: '/', validate: false }, async (context, req, res) => { - const defaultRoute = '/app/setup'; - const basePath = httpSetup.basePath.get(req); - const url = `${basePath}${defaultRoute}`; + const querystring = query ? stringify(query) : undefined; + const url = `${basePath}/${rewrittenPath}${querystring ? `?${querystring}` : ''}`; return res.redirected({ headers: { location: url, }, }); - }); + } + ); - registerBundleRoutes({ - router: notReadyRouter, - uiPlugins: notReadyServerUiPlugins, - packageInfo: this.env.packageInfo, - serverBasePath: coreSetup.http.basePath.serverBasePath, - }); + router.get({ path: '/core', validate: false }, async (context, req, res) => + res.ok({ body: { version: '0.0.1' } }) + ); - notReadyResources.register( - { - path: '/app/{id}/{any*}', - validate: false, + registerBundleRoutes({ + router, + uiPlugins, + packageInfo: this.env.packageInfo, + serverBasePath: core.http.basePath.serverBasePath, + }); + } + + private registerDefaultRoutes( + router: IRouter, + core: InternalCoreSetup | InternalCorePrebootSetup, + uiPlugins: UiPlugins, + defaultLocation: (context: RequestHandlerContext, req: KibanaRequest) => Promise + ) { + const resources = core.httpResources.createRegistrar(router); + router.get({ path: '/', validate: false }, async (context, req, res) => { + return res.redirected({ + headers: { + location: await defaultLocation(context, req), }, - async (context, request, response) => { - return response.renderAnonymousCoreApp({ renderTarget: 'notReady' }); - } - ); + }); }); // remove trailing slash catch-all @@ -107,7 +138,7 @@ export class CoreApp { return res.notFound(); } - const basePath = httpSetup.basePath.get(req); + const basePath = core.http.basePath.get(req); let rewrittenPath = path.slice(0, -1); if (`/${path}`.startsWith(basePath)) { rewrittenPath = rewrittenPath.substring(basePath.length); @@ -132,7 +163,7 @@ export class CoreApp { router, uiPlugins, packageInfo: this.env.packageInfo, - serverBasePath: coreSetup.http.basePath.serverBasePath, + serverBasePath: core.http.basePath.serverBasePath, }); resources.register( @@ -148,37 +179,31 @@ export class CoreApp { } ); - const anonymousStatusPage = coreSetup.status.isStatusPageAnonymous(); - resources.register( - { - path: '/status', - validate: false, - options: { - authRequired: !anonymousStatusPage, + if ('status' in core) { + const anonymousStatusPage = core.status.isStatusPageAnonymous(); + resources.register( + { + path: '/status', + validate: false, + options: { + authRequired: !anonymousStatusPage, + }, }, - }, - async (context, request, response) => { - if (anonymousStatusPage) { - return response.renderAnonymousCoreApp(); - } else { - return response.renderCoreApp(); + async (context, request, response) => { + if (anonymousStatusPage) { + return response.renderAnonymousCoreApp(); + } else { + return response.renderCoreApp(); + } } - } - ); + ); + } } - private registerStaticDirs(coreSetup: InternalCoreSetup) { - coreSetup.http.notReadyServer?.registerStaticDir( - '/ui/{path*}', - Path.resolve(__dirname, './assets') - ); - coreSetup.http.registerStaticDir('/ui/{path*}', Path.resolve(__dirname, './assets')); + private registerStaticDirs(core: InternalCoreSetup | InternalCorePrebootSetup) { + core.http.registerStaticDir('/ui/{path*}', Path.resolve(__dirname, './assets')); - coreSetup.http.notReadyServer?.registerStaticDir( - '/node_modules/@kbn/ui-framework/dist/{path*}', - fromRoot('node_modules/@kbn/ui-framework/dist') - ); - coreSetup.http.registerStaticDir( + core.http.registerStaticDir( '/node_modules/@kbn/ui-framework/dist/{path*}', fromRoot('node_modules/@kbn/ui-framework/dist') ); diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index 089028fc3297617..ade73b07d7def78 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -19,10 +19,26 @@ import { ElasticsearchClientConfig } from './client'; import { legacyClientMock } from './legacy/mocks'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; -import { InternalElasticsearchServiceSetup, ElasticsearchStatusMeta } from './types'; +import { + InternalElasticsearchServiceSetup, + ElasticsearchStatusMeta, + InternalElasticsearchServicePreboot, + ElasticsearchServicePreboot, +} from './types'; import { NodesVersionCompatibility } from './version_check/ensure_es_version'; import { ServiceStatus, ServiceStatusLevels } from '../status'; +type MockedElasticSearchServicePreboot = jest.Mocked; +const createPrebootContractMock = () => { + const prebootSetupContract: MockedElasticSearchServicePreboot = { + createClient: jest.fn(), + }; + prebootSetupContract.createClient.mockImplementation(() => + elasticsearchClientMock.createCustomClusterClient() + ); + return prebootSetupContract; +}; + export interface MockedElasticSearchServiceSetup { legacy: { config$: BehaviorSubject; @@ -75,6 +91,17 @@ const createStartContractMock = () => { const createInternalStartContractMock = createStartContractMock; +type MockedInternalElasticSearchServicePreboot = jest.Mocked; +const createInternalPrebootContractMock = () => { + const prebootContract: MockedInternalElasticSearchServicePreboot = { + createClient: jest.fn(), + }; + prebootContract.createClient.mockImplementation(() => + elasticsearchClientMock.createCustomClusterClient() + ); + return prebootContract; +}; + type MockedInternalElasticSearchServiceSetup = jest.Mocked< InternalElasticsearchServiceSetup & { legacy: { client: jest.Mocked }; @@ -105,10 +132,12 @@ const createInternalSetupContractMock = () => { type ElasticsearchServiceContract = PublicMethodsOf; const createMock = () => { const mocked: jest.Mocked = { + preboot: jest.fn(), setup: jest.fn(), start: jest.fn(), stop: jest.fn(), }; + mocked.preboot.mockResolvedValue(createInternalPrebootContractMock()); mocked.setup.mockResolvedValue(createInternalSetupContractMock()); mocked.start.mockResolvedValueOnce(createInternalStartContractMock()); mocked.stop.mockResolvedValue(); @@ -117,6 +146,8 @@ const createMock = () => { export const elasticsearchServiceMock = { create: createMock, + createInternalPreboot: createInternalPrebootContractMock, + createPreboot: createPrebootContractMock, createInternalSetup: createInternalSetupContractMock, createSetup: createSetupContractMock, createInternalStart: createInternalStartContractMock, diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index 7da83145ccd425e..0555be113e6ae43 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -18,10 +18,14 @@ import { ILegacyCustomClusterClient, LegacyElasticsearchClientConfig, } from './legacy'; -import { ClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from './client'; +import { ClusterClient, ElasticsearchClientConfig } from './client'; import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config'; -import { InternalHttpServiceSetup, GetAuthHeaders } from '../http/'; -import { InternalElasticsearchServiceSetup, InternalElasticsearchServiceStart } from './types'; +import { InternalHttpServiceSetup, GetAuthHeaders } from '../http'; +import { + InternalElasticsearchServicePreboot, + InternalElasticsearchServiceSetup, + InternalElasticsearchServiceStart, +} from './types'; import { pollEsNodesVersion } from './version_check/ensure_es_version'; import { calculateStatus$ } from './status'; @@ -54,6 +58,15 @@ export class ElasticsearchService .pipe(map((rawConfig) => new ElasticsearchConfig(rawConfig))); } + public async preboot(): Promise { + this.log.debug('Prebooting elasticsearch service'); + + const config = await this.config$.pipe(first()).toPromise(); + return { + createClient: (type, clientConfig) => this.createClusterClient(type, config, clientConfig), + }; + } + public async setup(deps: SetupDeps): Promise { this.log.debug('Setting up elasticsearch service'); @@ -92,18 +105,9 @@ export class ElasticsearchService } const config = await this.config$.pipe(first()).toPromise(); - - const createClient = ( - type: string, - clientConfig: Partial = {} - ): ICustomClusterClient => { - const finalConfig = merge({}, config, clientConfig); - return this.createClusterClient(type, finalConfig); - }; - return { client: this.client!, - createClient, + createClient: (type, clientConfig) => this.createClusterClient(type, config, clientConfig), legacy: { config$: this.config$, client: this.legacyClient, @@ -123,7 +127,12 @@ export class ElasticsearchService } } - private createClusterClient(type: string, config: ElasticsearchClientConfig) { + private createClusterClient( + type: string, + baseConfig: ElasticsearchConfig, + clientConfig?: Partial + ) { + const config = clientConfig ? merge({}, baseConfig, clientConfig) : baseConfig; return new ClusterClient( config, this.coreContext.logger.get('elasticsearch'), diff --git a/src/core/server/elasticsearch/index.ts b/src/core/server/elasticsearch/index.ts index 94dc10ff4e86365..c41bff180cd4a1d 100644 --- a/src/core/server/elasticsearch/index.ts +++ b/src/core/server/elasticsearch/index.ts @@ -11,9 +11,11 @@ export { config, configSchema } from './elasticsearch_config'; export { ElasticsearchConfig } from './elasticsearch_config'; export type { NodesVersionCompatibility } from './version_check/ensure_es_version'; export type { + ElasticsearchServicePreboot, ElasticsearchServiceSetup, ElasticsearchServiceStart, ElasticsearchStatusMeta, + InternalElasticsearchServicePreboot, InternalElasticsearchServiceSetup, InternalElasticsearchServiceStart, FakeRequest, diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 8bbf665cbc0965c..99bbb1951644481 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -19,6 +19,16 @@ import { IClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from import { NodesVersionCompatibility } from './version_check/ensure_es_version'; import { ServiceStatus } from '../status'; +/** + * @public + */ +export interface ElasticsearchServicePreboot { + readonly createClient: ( + type: string, + clientConfig?: Partial + ) => ICustomClusterClient; +} + /** * @public */ @@ -77,6 +87,14 @@ export interface ElasticsearchServiceSetup { }; } +/** @internal */ +export interface InternalElasticsearchServicePreboot { + readonly createClient: ( + type: string, + clientConfig?: Partial + ) => ICustomClusterClient; +} + /** @internal */ export interface InternalElasticsearchServiceSetup extends ElasticsearchServiceSetup { esNodesCompatibility$: Observable; diff --git a/src/core/server/environment/environment_service.mock.ts b/src/core/server/environment/environment_service.mock.ts index 2bc5fa89b8e26d9..076837fed94b38a 100644 --- a/src/core/server/environment/environment_service.mock.ts +++ b/src/core/server/environment/environment_service.mock.ts @@ -7,25 +7,25 @@ */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { EnvironmentService, InternalEnvironmentServiceSetup } from './environment_service'; +import type { EnvironmentService, InternalEnvironmentServicePreboot } from './environment_service'; -const createSetupContractMock = () => { - const setupContract: jest.Mocked = { +const createPrebootContractMock = () => { + const prebootContract: jest.Mocked = { instanceUuid: 'uuid', }; - return setupContract; + return prebootContract; }; type EnvironmentServiceContract = PublicMethodsOf; const createMock = () => { const mocked: jest.Mocked = { - setup: jest.fn(), + preboot: jest.fn(), }; - mocked.setup.mockResolvedValue(createSetupContractMock()); + mocked.preboot.mockResolvedValue(createPrebootContractMock()); return mocked; }; export const environmentServiceMock = { create: createMock, - createSetupContract: createSetupContractMock, + createPrebootContract: createPrebootContractMock, }; diff --git a/src/core/server/environment/environment_service.test.ts b/src/core/server/environment/environment_service.test.ts index fb3ddaa77b41623..2e5b1758202f8dd 100644 --- a/src/core/server/environment/environment_service.test.ts +++ b/src/core/server/environment/environment_service.test.ts @@ -76,9 +76,9 @@ describe('UuidService', () => { jest.clearAllMocks(); }); - describe('#setup()', () => { + describe('#preboot()', () => { it('calls resolveInstanceUuid with correct parameters', async () => { - await service.setup(); + await service.preboot(); expect(resolveInstanceUuid).toHaveBeenCalledTimes(1); expect(resolveInstanceUuid).toHaveBeenCalledWith({ @@ -89,7 +89,7 @@ describe('UuidService', () => { }); it('calls createDataFolder with correct parameters', async () => { - await service.setup(); + await service.preboot(); expect(createDataFolder).toHaveBeenCalledTimes(1); expect(createDataFolder).toHaveBeenCalledWith({ @@ -99,7 +99,7 @@ describe('UuidService', () => { }); it('calls writePidFile with correct parameters', async () => { - await service.setup(); + await service.preboot(); expect(writePidFile).toHaveBeenCalledTimes(1); expect(writePidFile).toHaveBeenCalledWith({ @@ -109,14 +109,14 @@ describe('UuidService', () => { }); it('returns the uuid resolved from resolveInstanceUuid', async () => { - const setup = await service.setup(); + const preboot = await service.preboot(); - expect(setup.instanceUuid).toEqual('SOME_UUID'); + expect(preboot.instanceUuid).toEqual('SOME_UUID'); }); describe('process warnings', () => { it('logs warnings coming from the process', async () => { - await service.setup(); + await service.preboot(); const warning = new Error('something went wrong'); process.emit('warning', warning); @@ -126,7 +126,7 @@ describe('UuidService', () => { }); it('does not log deprecation warnings', async () => { - await service.setup(); + await service.preboot(); const warning = new Error('something went wrong'); warning.name = 'DeprecationWarning'; diff --git a/src/core/server/environment/environment_service.ts b/src/core/server/environment/environment_service.ts index e652622049cfa14..a97a1d012eafeaa 100644 --- a/src/core/server/environment/environment_service.ts +++ b/src/core/server/environment/environment_service.ts @@ -20,7 +20,7 @@ import { writePidFile } from './write_pid_file'; /** * @internal */ -export interface InternalEnvironmentServiceSetup { +export interface InternalEnvironmentServicePreboot { /** * Retrieve the Kibana instance uuid. */ @@ -40,7 +40,9 @@ export class EnvironmentService { this.configService = core.configService; } - public async setup() { + public async preboot() { + // IMPORTANT: This code is based on the assumption that none of the configuration values used + // here is supposed to change during preboot phase and it's safe to read them only once. const [pathConfig, serverConfig, pidConfig] = await Promise.all([ this.configService.atPath(pathConfigDef.path).pipe(take(1)).toPromise(), this.configService.atPath(httpConfigDef.path).pipe(take(1)).toPromise(), diff --git a/src/core/server/environment/index.ts b/src/core/server/environment/index.ts index 01d5097887248cc..d3f49a6c6b6d62b 100644 --- a/src/core/server/environment/index.ts +++ b/src/core/server/environment/index.ts @@ -7,6 +7,6 @@ */ export { EnvironmentService } from './environment_service'; -export type { InternalEnvironmentServiceSetup } from './environment_service'; +export type { InternalEnvironmentServicePreboot } from './environment_service'; export { config } from './pid_config'; export type { PidConfigType } from './pid_config'; diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index a589bc76d21fcdf..8a7e6abd44f04ba 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -12,6 +12,8 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { CspConfig } from '../csp'; import { mockRouter, RouterMock } from './router/router.mock'; import { + InternalHttpServicePreboot, + HttpServicePreboot, InternalHttpServiceSetup, HttpServiceSetup, HttpServiceStart, @@ -31,6 +33,10 @@ import { ExternalUrlConfig } from '../external_url'; type BasePathMocked = jest.Mocked; type AuthMocked = jest.Mocked; +export type HttpServicePrebootMock = jest.Mocked; +export type InternalHttpServicePrebootMock = jest.Mocked< + Omit +> & { basePath: BasePathMocked }; export type HttpServiceSetupMock = jest.Mocked< Omit > & { @@ -72,6 +78,30 @@ const createAuthMock = () => { return mock; }; +const createInternalPrebootContractMock = () => { + const mock: InternalHttpServicePrebootMock = { + registerRoutes: jest.fn(), + // @ts-expect-error tsc cannot infer ContextName and uses never + registerRouteHandlerContext: jest.fn(), + registerStaticDir: jest.fn(), + basePath: createBasePathMock(), + csp: CspConfig.DEFAULT, + externalUrl: ExternalUrlConfig.DEFAULT, + auth: createAuthMock(), + }; + return mock; +}; + +const createPrebootContractMock = () => { + const internalMock = createInternalPrebootContractMock(); + + const mock: HttpServicePrebootMock = { + registerRoutes: internalMock.registerRoutes, + }; + + return mock; +}; + const createInternalSetupContractMock = () => { const mock: InternalHttpServiceSetupMock = { // we can mock other hapi server methods when we need it @@ -165,6 +195,7 @@ type HttpServiceContract = PublicMethodsOf; const createHttpServiceMock = () => { const mocked: jest.Mocked = { + preboot: jest.fn(), setup: jest.fn(), getStartContract: jest.fn(), start: jest.fn(), @@ -204,6 +235,8 @@ export const httpServiceMock = { create: createHttpServiceMock, createBasePath: createBasePathMock, createAuth: createAuthMock, + createInternalPrebootContract: createInternalPrebootContractMock, + createPrebootContract: createPrebootContractMock, createInternalSetupContract: createInternalSetupContractMock, createSetupContract: createSetupContractMock, createInternalStartContract: createInternalStartContractMock, diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 11dcfb54a8e1fb8..73f6234666649fa 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -6,20 +6,20 @@ * Side Public License, v 1. */ -import { Observable, Subscription, combineLatest, of } from 'rxjs'; +import { Observable, Subscription, combineLatest } from 'rxjs'; import { first, map } from 'rxjs/operators'; import { pick } from '@kbn/std'; import type { RequestHandlerContext } from 'src/core/server'; import { CoreService } from '../../types'; -import { Logger, LoggerFactory } from '../logging'; +import { Logger } from '../logging'; import { ContextSetup } from '../context'; import { Env } from '../config'; import { CoreContext } from '../core_context'; import { PluginOpaqueId } from '../plugins'; import { CspConfigType, config as cspConfig } from '../csp'; -import { IRouter, Router } from './router'; +import { Router } from './router'; import { HttpConfig, HttpConfigType, config as httpConfig } from './http_config'; import { HttpServer } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; @@ -27,9 +27,9 @@ import { HttpsRedirectServer } from './https_redirect_server'; import { RequestHandlerContextContainer, RequestHandlerContextProvider, + InternalHttpServicePreboot, InternalHttpServiceSetup, InternalHttpServiceStart, - InternalNotReadyHttpServiceSetup, } from './types'; import { registerCoreHandlers } from './lifecycle_handlers'; @@ -39,6 +39,10 @@ import { ExternalUrlConfig, } from '../external_url'; +interface PrebootDeps { + context: ContextSetup; +} + interface SetupDeps { context: ContextSetup; } @@ -46,23 +50,20 @@ interface SetupDeps { /** @internal */ export class HttpService implements CoreService { + private readonly prebootServer: HttpServer; private readonly httpServer: HttpServer; private readonly httpsRedirectServer: HttpsRedirectServer; private readonly config$: Observable; private configSubscription?: Subscription; - private readonly logger: LoggerFactory; private readonly log: Logger; private readonly env: Env; - private notReadyServer?: HttpServer; private internalSetup?: InternalHttpServiceSetup; - private notReadyServerRequestHandlerContext?: RequestHandlerContextContainer; private requestHandlerContext?: RequestHandlerContextContainer; constructor(private readonly coreContext: CoreContext) { const { logger, configService, env } = coreContext; - this.logger = logger; this.env = env; this.log = logger.get('http'); this.config$ = combineLatest([ @@ -71,12 +72,61 @@ export class HttpService configService.atPath(externalUrlConfig.path), ]).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl))); const shutdownTimeout$ = this.config$.pipe(map(({ shutdownTimeout }) => shutdownTimeout)); + this.prebootServer = new HttpServer(logger, 'Preboot', shutdownTimeout$); this.httpServer = new HttpServer(logger, 'Kibana', shutdownTimeout$); this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); } + public async preboot(deps: PrebootDeps): Promise { + this.log.debug('setting up preboot server'); + const config = await this.config$.pipe(first()).toPromise(); + + const prebootSetup = await this.prebootServer.setup(config); + prebootSetup.server.route({ + path: '/{p*}', + method: '*', + handler: (req, responseToolkit) => { + this.log.debug(`Kibana server is not ready yet ${req.method}:${req.url.href}.`); + + // If server is not ready yet, because plugins or core can perform + // long running tasks (build assets, saved objects migrations etc.) + // we should let client know that and ask to retry after 30 seconds. + return responseToolkit + .response('Kibana server is not ready yet') + .code(503) + .header('Retry-After', '30'); + }, + }); + + if (this.shouldListen(config)) { + this.log.debug('starting preboot server'); + await this.prebootServer.start(); + } + + const prebootServerRequestHandlerContext = deps.context.createContextContainer(); + return { + externalUrl: new ExternalUrlConfig(config.externalUrl), + csp: prebootSetup.csp, + basePath: prebootSetup.basePath, + registerStaticDir: prebootSetup.registerStaticDir.bind(prebootSetup), + auth: prebootSetup.auth, + registerRouteHandlerContext: (pluginOpaqueId, contextName, provider) => + prebootServerRequestHandlerContext.registerContext(pluginOpaqueId, contextName, provider), + registerRoutes: (path, registerCallback) => { + const router = new Router( + path, + this.log, + prebootServerRequestHandlerContext.createHandler.bind(null, this.coreContext.coreId) + ); + + registerCallback(router); + + prebootSetup.registerRouterAfterListening(router); + }, + }; + } + public async setup(deps: SetupDeps) { - this.notReadyServerRequestHandlerContext = deps.context.createContextContainer(); this.requestHandlerContext = deps.context.createContextContainer(); this.configSubscription = this.config$.subscribe(() => { if (this.httpServer.isListening()) { @@ -90,8 +140,6 @@ export class HttpService const config = await this.config$.pipe(first()).toPromise(); - const notReadyServer = await this.setupNotReadyService({ config, context: deps.context }); - const { registerRouter, ...serverContract } = await this.httpServer.setup(config); registerCoreHandlers(serverContract, config, this.env); @@ -99,8 +147,6 @@ export class HttpService this.internalSetup = { ...serverContract, - notReadyServer, - externalUrl: new ExternalUrlConfig(config.externalUrl), createRouter: ( @@ -138,11 +184,9 @@ export class HttpService public async start() { const config = await this.config$.pipe(first()).toPromise(); if (this.shouldListen(config)) { - if (this.notReadyServer) { - this.log.debug('stopping NotReady server'); - await this.notReadyServer.stop(); - this.notReadyServer = undefined; - } + this.log.debug('stopping preboot server'); + await this.prebootServer.stop(); + // If a redirect port is specified, we start an HTTP server at this port and // redirect all requests to the SSL port. if (config.ssl.enabled && config.ssl.redirectHttpFromPort !== undefined) { @@ -173,76 +217,8 @@ export class HttpService this.configSubscription?.unsubscribe(); this.configSubscription = undefined; - if (this.notReadyServer) { - await this.notReadyServer.stop(); - } + await this.prebootServer.stop(); await this.httpServer.stop(); await this.httpsRedirectServer.stop(); } - - private async setupNotReadyService({ - config, - context, - }: { - config: HttpConfig; - context: ContextSetup; - }): Promise { - if (!this.shouldListen(config)) { - return; - } - - const notReadySetup = await this.runNotReadyServer(config); - - return { - registerStaticDir: notReadySetup.registerStaticDir.bind(notReadySetup), - registerRouteHandlerContext: < - Context extends RequestHandlerContext, - ContextName extends keyof Context - >( - pluginOpaqueId: PluginOpaqueId, - contextName: ContextName, - provider: RequestHandlerContextProvider - ) => - this.notReadyServerRequestHandlerContext!.registerContext( - pluginOpaqueId, - contextName, - provider - ), - registerRoutes: (path: string, registerCallback: (router: IRouter) => void) => { - const enhanceHandler = this.notReadyServerRequestHandlerContext!.createHandler.bind( - null, - this.coreContext.coreId - ); - - const router = new Router(path, this.log, enhanceHandler); - - registerCallback(router); - notReadySetup.registerRouterAfterListening(router); - }, - }; - } - - private async runNotReadyServer(config: HttpConfig) { - this.log.debug('starting NotReady server'); - this.notReadyServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout)); - const notReadySetup = await this.notReadyServer.setup(config); - notReadySetup.server.route({ - path: '/{p*}', - method: '*', - handler: (req, responseToolkit) => { - this.log.debug(`Kibana server is not ready yet ${req.method}:${req.url.href}.`); - - // If server is not ready yet, because plugins or core can perform - // long running tasks (build assets, saved objects migrations etc.) - // we should let client know that and ask to retry after 30 seconds. - return responseToolkit - .response('Kibana server is not ready yet') - .code(503) - .header('Retry-After', '30'); - }, - }); - await this.notReadyServer.start(); - - return notReadySetup; - } } diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 84fe5149c89c668..cad5a50dbc50502 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -87,6 +87,8 @@ export type { RequestHandlerContextContainer, RequestHandlerContextProvider, HttpAuth, + HttpServicePreboot, + InternalHttpServicePreboot, HttpServiceSetup, InternalHttpServiceSetup, HttpServiceStart, diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index db2152493206fdd..346a711a89bc88e 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -56,6 +56,29 @@ export interface HttpAuth { isAuthenticated: IsAuthenticated; } +/** + * @public + */ +export interface HttpServicePreboot { + registerRoutes(path: string, callback: (router: IRouter) => void): void; +} + +/** @internal */ +export interface InternalHttpServicePreboot + extends Pick { + externalUrl: ExternalUrlConfig; + registerRoutes(path: string, callback: (router: IRouter) => void): void; + registerStaticDir: (path: string, dirPath: string) => void; + registerRouteHandlerContext: < + Context extends RequestHandlerContext, + ContextName extends keyof Context + >( + pluginOpaqueId: PluginOpaqueId, + contextName: ContextName, + provider: RequestHandlerContextProvider + ) => RequestHandlerContextContainer; +} + /** * Kibana HTTP Service provides own abstraction for work with HTTP stack. * Plugins don't have direct access to `hapi` server and its primitives anymore. Moreover, @@ -275,22 +298,6 @@ export interface HttpServiceSetup { * Provides common {@link HttpServerInfo | information} about the running http server. */ getServerInfo: () => HttpServerInfo; - - notReadyServer?: InternalNotReadyHttpServiceSetup; -} - -/** @internal */ -export interface InternalNotReadyHttpServiceSetup { - registerRoutes(path: string, callback: (router: IRouter) => void): void; - registerStaticDir: (path: string, dirPath: string) => void; - registerRouteHandlerContext: < - Context extends RequestHandlerContext, - ContextName extends keyof Context - >( - pluginOpaqueId: PluginOpaqueId, - contextName: ContextName, - provider: RequestHandlerContextProvider - ) => RequestHandlerContextContainer; } /** @internal */ @@ -314,7 +321,6 @@ export interface InternalHttpServiceSetup contextName: ContextName, provider: RequestHandlerContextProvider ) => RequestHandlerContextContainer; - notReadyServer?: InternalNotReadyHttpServiceSetup; } /** @public */ diff --git a/src/core/server/http_resources/http_resources_service.mock.ts b/src/core/server/http_resources/http_resources_service.mock.ts index 3a94de15d14b957..a2ca0aa2465828b 100644 --- a/src/core/server/http_resources/http_resources_service.mock.ts +++ b/src/core/server/http_resources/http_resources_service.mock.ts @@ -13,12 +13,16 @@ const createHttpResourcesMock = (): jest.Mocked => ({ register: jest.fn(), }); -function createInternalHttpResourcesSetup() { +function createInternalHttpResourcesPreboot() { return { createRegistrar: jest.fn(() => createHttpResourcesMock()), }; } +function createInternalHttpResourcesSetup() { + return createInternalHttpResourcesPreboot(); +} + function createHttpResourcesResponseFactory() { const mocked: jest.Mocked = { renderCoreApp: jest.fn(), @@ -35,6 +39,7 @@ function createHttpResourcesResponseFactory() { export const httpResourcesMock = { createRegistrar: createHttpResourcesMock, + createPrebootContract: createInternalHttpResourcesPreboot, createSetupContract: createInternalHttpResourcesSetup, createResponseFactory: createHttpResourcesResponseFactory, }; diff --git a/src/core/server/http_resources/http_resources_service.ts b/src/core/server/http_resources/http_resources_service.ts index 66466323eb95956..6c295b152af3c23 100644 --- a/src/core/server/http_resources/http_resources_service.ts +++ b/src/core/server/http_resources/http_resources_service.ts @@ -15,10 +15,11 @@ import { InternalHttpServiceSetup, KibanaRequest, KibanaResponseFactory, + InternalHttpServicePreboot, } from '../http'; import { Logger } from '../logging'; -import { InternalRenderingServiceSetup } from '../rendering'; +import { InternalRenderingServicePreboot, InternalRenderingServiceSetup } from '../rendering'; import { CoreService } from '../../types'; import { @@ -31,6 +32,11 @@ import { } from './types'; import { getApmConfig } from './get_apm_config'; +export interface PrebootDeps { + http: InternalHttpServicePreboot; + rendering: InternalRenderingServicePreboot; +} + export interface SetupDeps { http: InternalHttpServiceSetup; rendering: InternalRenderingServiceSetup; @@ -43,6 +49,13 @@ export class HttpResourcesService implements CoreService( route: RouteConfig, @@ -71,7 +84,7 @@ export class HttpResourcesService implements CoreService => { const translationPaths = await Promise.all([ getTranslationPaths({ diff --git a/src/core/server/i18n/i18n_service.ts b/src/core/server/i18n/i18n_service.ts index 8e1d99f46a1f8ae..0c2b7ca8699c608 100644 --- a/src/core/server/i18n/i18n_service.ts +++ b/src/core/server/i18n/i18n_service.ts @@ -10,15 +10,20 @@ import { take } from 'rxjs/operators'; import { Logger } from '../logging'; import { IConfigService } from '../config'; import { CoreContext } from '../core_context'; -import { InternalHttpServiceSetup } from '../http'; +import { InternalHttpServicePreboot, InternalHttpServiceSetup } from '../http'; import { config as i18nConfigDef, I18nConfigType } from './i18n_config'; import { getKibanaTranslationFiles } from './get_kibana_translation_files'; import { initTranslations } from './init_translations'; import { registerRoutes } from './routes'; +interface PrebootDeps { + http: InternalHttpServicePreboot; + pluginPaths: readonly string[]; +} + interface SetupDeps { http: InternalHttpServiceSetup; - pluginPaths: string[]; + pluginPaths: readonly string[]; } /** @@ -45,7 +50,24 @@ export class I18nService { this.configService = coreContext.configService; } + public async preboot({ pluginPaths, http }: PrebootDeps) { + const { locale } = await this.initTranslations(pluginPaths); + http.registerRoutes('', (router) => registerRoutes({ router, locale })); + } + public async setup({ pluginPaths, http }: SetupDeps): Promise { + const { locale, translationFiles } = await this.initTranslations(pluginPaths); + + const router = http.createRouter(''); + registerRoutes({ router, locale }); + + return { + getLocale: () => locale, + getTranslationFiles: () => translationFiles, + }; + } + + private async initTranslations(pluginPaths: readonly string[]) { const i18nConfig = await this.configService .atPath(i18nConfigDef.path) .pipe(take(1)) @@ -59,16 +81,6 @@ export class I18nService { this.log.debug(`Using translation files: [${translationFiles.join(', ')}]`); await initTranslations(locale, translationFiles); - http.notReadyServer?.registerRoutes('', (notReadyRouter) => { - registerRoutes({ router: notReadyRouter, locale }); - }); - - const router = http.createRouter(''); - registerRoutes({ router, locale }); - - return { - getLocale: () => locale, - getTranslationFiles: () => translationFiles, - }; + return { locale, translationFiles }; } } diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 77946e15ef68630..edb722b31af002c 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -35,8 +35,9 @@ import { configSchema as elasticsearchConfigSchema, ElasticsearchServiceStart, IScopedClusterClient, + ElasticsearchServicePreboot, } from './elasticsearch'; -import { HttpServiceSetup, HttpServiceStart } from './http'; +import { HttpServicePreboot, HttpServiceSetup, HttpServiceStart } from './http'; import { HttpResources } from './http_resources'; import { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId } from './plugins'; @@ -68,6 +69,7 @@ import { CoreEnvironmentUsageData, CoreServicesUsageData, } from './core_usage_data'; +import { PrebootServicePreboot } from './preboot'; export type { CoreUsageStats, @@ -117,6 +119,7 @@ export type { LegacyElasticsearchClientConfig, LegacyElasticsearchError, LegacyElasticsearchErrorHelpers, + ElasticsearchServicePreboot, ElasticsearchServiceSetup, ElasticsearchServiceStart, ElasticsearchStatusMeta, @@ -254,6 +257,7 @@ export type { export type { DiscoveredPlugin, + PrebootPlugin, Plugin, AsyncPlugin, PluginConfigDescriptor, @@ -459,6 +463,18 @@ export interface RequestHandlerContext { }; } +/** + * @public + */ +export interface CorePreboot { + /** {@link ElasticsearchServicePreboot} */ + elasticsearch: ElasticsearchServicePreboot; + /** {@link HttpServicePreboot} */ + http: HttpServicePreboot; + /** {@link PrebootServicePreboot} */ + preboot: PrebootServicePreboot; +} + /** * Context passed to the plugins `setup` method. * diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 34193f8d0c35e0d..69cffe29be3ba4d 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -12,16 +12,25 @@ import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { ConfigDeprecationProvider } from './config'; import { ContextSetup } from './context'; import { + InternalElasticsearchServicePreboot, InternalElasticsearchServiceSetup, InternalElasticsearchServiceStart, } from './elasticsearch'; -import { InternalHttpServiceSetup, InternalHttpServiceStart } from './http'; +import { + InternalHttpServicePreboot, + InternalHttpServiceSetup, + InternalHttpServiceStart, +} from './http'; import { InternalSavedObjectsServiceSetup, InternalSavedObjectsServiceStart, } from './saved_objects'; -import { InternalUiSettingsServiceSetup, InternalUiSettingsServiceStart } from './ui_settings'; -import { InternalEnvironmentServiceSetup } from './environment'; +import { + InternalUiSettingsServicePreboot, + InternalUiSettingsServiceSetup, + InternalUiSettingsServiceStart, +} from './ui_settings'; +import { InternalEnvironmentServicePreboot } from './environment'; import { InternalMetricsServiceSetup, InternalMetricsServiceStart } from './metrics'; import { InternalRenderingServiceSetup } from './rendering'; import { InternalHttpResourcesSetup } from './http_resources'; @@ -30,6 +39,18 @@ import { InternalLoggingServiceSetup } from './logging'; import { CoreUsageDataStart } from './core_usage_data'; import { I18nServiceSetup } from './i18n'; import { InternalDeprecationsServiceSetup } from './deprecations'; +import { InternalPrebootServicePreboot } from './preboot'; + +/** @internal */ +export interface InternalCorePrebootSetup { + context: ContextSetup; + http: InternalHttpServicePreboot; + elasticsearch: InternalElasticsearchServicePreboot; + uiSettings: InternalUiSettingsServicePreboot; + httpResources: InternalHttpResourcesSetup; + logging: InternalLoggingServiceSetup; + preboot: InternalPrebootServicePreboot; +} /** @internal */ export interface InternalCoreSetup { @@ -41,7 +62,7 @@ export interface InternalCoreSetup { savedObjects: InternalSavedObjectsServiceSetup; status: InternalStatusServiceSetup; uiSettings: InternalUiSettingsServiceSetup; - environment: InternalEnvironmentServiceSetup; + environment: InternalEnvironmentServicePreboot; rendering: InternalRenderingServiceSetup; httpResources: InternalHttpResourcesSetup; logging: InternalLoggingServiceSetup; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 0d52ff64499c1c1..8c0d921f3eed019 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -10,7 +10,13 @@ import { of } from 'rxjs'; import { duration } from 'moment'; import { ByteSizeValue } from '@kbn/config-schema'; import type { MockedKeys } from '@kbn/utility-types/jest'; -import { PluginInitializerContext, CoreSetup, CoreStart, StartServicesAccessor } from '.'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + StartServicesAccessor, + CorePreboot, +} from '.'; import { loggingSystemMock } from './logging/logging_system.mock'; import { loggingServiceMock } from './logging/logging_service.mock'; import { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; @@ -30,6 +36,7 @@ import { statusServiceMock } from './status/status_service.mock'; import { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; import { i18nServiceMock } from './i18n/i18n_service.mock'; import { deprecationsServiceMock } from './deprecations/deprecations_service.mock'; +import { prebootServiceMock } from './preboot/preboot_service.mock'; export { configServiceMock } from './config/mocks'; export { httpServerMock } from './http/http_server.mocks'; @@ -104,6 +111,7 @@ function pluginInitializerContextMock(config: T = {} as T) { dist: false, }, instanceUuid: 'instance-uuid', + configs: ['/some/path/to/config/kibana.yml'], }, config: pluginInitializerContextConfigMock(config), }; @@ -111,6 +119,20 @@ function pluginInitializerContextMock(config: T = {} as T) { return mock; } +type CorePrebootMockType = MockedKeys & { + elasticsearch: ReturnType; +}; + +function createCorePrebootMock() { + const mock: CorePrebootMockType = { + elasticsearch: elasticsearchServiceMock.createPreboot(), + http: httpServiceMock.createPrebootContract(), + preboot: prebootServiceMock.createPrebootContract(), + }; + + return mock; +} + type CoreSetupMockType = MockedKeys & { elasticsearch: ReturnType; getStartServices: jest.MockedFunction>; @@ -166,6 +188,19 @@ function createCoreStartMock() { return mock; } +function createInternalCorePrebootMock() { + const prebootDeps = { + context: contextServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createInternalPreboot(), + http: httpServiceMock.createInternalPrebootContract(), + httpResources: httpResourcesMock.createPrebootContract(), + uiSettings: uiSettingsServiceMock.createPrebootContract(), + logging: loggingServiceMock.createInternalSetupContract(), + preboot: prebootServiceMock.createInternalPrebootContract(), + }; + return prebootDeps; +} + function createInternalCoreSetupMock() { const setupDeps = { capabilities: capabilitiesServiceMock.createSetupContract(), @@ -174,7 +209,7 @@ function createInternalCoreSetupMock() { http: httpServiceMock.createInternalSetupContract(), savedObjects: savedObjectsServiceMock.createInternalSetupContract(), status: statusServiceMock.createInternalSetupContract(), - environment: environmentServiceMock.createSetupContract(), + environment: environmentServiceMock.createPrebootContract(), i18n: i18nServiceMock.createSetupContract(), httpResources: httpResourcesMock.createSetupContract(), rendering: renderingMock.createSetupContract(), @@ -221,8 +256,10 @@ function createCoreRequestHandlerContextMock() { } export const coreMock = { + createPreboot: createCorePrebootMock, createSetup: createCoreSetupMock, createStart: createCoreStartMock, + createInternalPreboot: createInternalCorePrebootMock, createInternalSetup: createInternalCoreSetupMock, createInternalStart: createInternalCoreStartMock, createPluginInitializerContext: pluginInitializerContextMock, diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts index f3a92c896b0148e..3458b9b65cf9f38 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts @@ -226,6 +226,29 @@ test('return error when manifest contains unrecognized properties', async () => }); }); +test('return error when manifest contains unrecognized `type`', async () => { + mockReadFile.mockImplementation((path, cb) => { + cb( + null, + Buffer.from( + JSON.stringify({ + id: 'someId', + version: '7.0.0', + kibanaVersion: '7.0.0', + type: 'unknown', + server: true, + }) + ) + ); + }); + + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ + message: `The optional "type" in manifest for plugin "someId" is set to "unknown", but it should either be "standard" or "preboot". (invalid-manifest, ${pluginManifestPath})`, + type: PluginDiscoveryErrorType.InvalidManifest, + path: pluginManifestPath, + }); +}); + describe('configPath', () => { test('falls back to plugin id if not specified', async () => { mockReadFile.mockImplementation((path, cb) => { @@ -284,6 +307,7 @@ test('set defaults for all missing optional fields', async () => { configPath: 'some_id', version: '7.0.0', kibanaVersion: '7.0.0', + type: 'standard', optionalPlugins: [], requiredPlugins: [], requiredBundles: [], @@ -302,6 +326,7 @@ test('return all set optional fields as they are in manifest', async () => { configPath: ['some', 'path'], version: 'some-version', kibanaVersion: '7.0.0', + type: 'preboot', requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'], optionalPlugins: ['some-optional-plugin'], ui: true, @@ -315,6 +340,7 @@ test('return all set optional fields as they are in manifest', async () => { configPath: ['some', 'path'], version: 'some-version', kibanaVersion: '7.0.0', + type: 'preboot', optionalPlugins: ['some-optional-plugin'], requiredBundles: [], requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'], @@ -345,6 +371,7 @@ test('return manifest when plugin expected Kibana version matches actual version configPath: 'some-path', version: 'some-version', kibanaVersion: '7.0.0-alpha2', + type: 'standard', optionalPlugins: [], requiredPlugins: ['some-required-plugin'], requiredBundles: [], @@ -375,6 +402,7 @@ test('return manifest when plugin expected Kibana version is `kibana`', async () configPath: 'some_id', version: 'some-version', kibanaVersion: 'kibana', + type: 'standard', optionalPlugins: [], requiredPlugins: ['some-required-plugin'], requiredBundles: [], diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.ts index 7e68962a3e9b215..86937ca4028ea34 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.ts @@ -179,11 +179,21 @@ export async function parseManifest( ); } + const type = manifest.type ?? 'standard'; + if (type !== 'preboot' && type !== 'standard') { + throw PluginDiscoveryError.invalidManifest( + manifestPath, + new Error( + `The optional "type" in manifest for plugin "${manifest.id}" is set to "${type}", but it should either be "standard" or "preboot".` + ) + ); + } + return { id: manifest.id, version: manifest.version, kibanaVersion: expectedKibanaVersion, - type: manifest.type ?? 'primary', + type, configPath: manifest.configPath || snakeCase(manifest.id), requiredPlugins: Array.isArray(manifest.requiredPlugins) ? manifest.requiredPlugins : [], optionalPlugins: Array.isArray(manifest.optionalPlugins) ? manifest.optionalPlugins : [], diff --git a/src/core/server/plugins/index.ts b/src/core/server/plugins/index.ts index a71df00b39c5cf3..1b655ccd8bd98fa 100644 --- a/src/core/server/plugins/index.ts +++ b/src/core/server/plugins/index.ts @@ -7,7 +7,12 @@ */ export { PluginsService } from './plugins_service'; -export type { PluginsServiceSetup, PluginsServiceStart, UiPlugins } from './plugins_service'; +export type { + PluginsServiceSetup, + PluginsServiceStart, + UiPlugins, + DiscoveredPlugins, +} from './plugins_service'; export { config } from './plugins_config'; /** @internal */ export { isNewPlatformPlugin } from './discovery'; diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.ts b/src/core/server/plugins/integration_tests/plugins_service.test.ts index a29fb01fbc00923..d91e6ae4b16d294 100644 --- a/src/core/server/plugins/integration_tests/plugins_service.test.ts +++ b/src/core/server/plugins/integration_tests/plugins_service.test.ts @@ -20,12 +20,12 @@ import { config } from '../plugins_config'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { environmentServiceMock } from '../../environment/environment_service.mock'; import { coreMock } from '../../mocks'; -import { AsyncPlugin } from '../types'; +import { AsyncPlugin, PluginType } from '../types'; import { PluginWrapper } from '../plugin'; describe('PluginsService', () => { const logger = loggingSystemMock.create(); - const environmentSetup = environmentServiceMock.createSetupContract(); + const environmentPreboot = environmentServiceMock.createPrebootContract(); let pluginsService: PluginsService; const createPlugin = ( @@ -38,6 +38,7 @@ describe('PluginsService', () => { requiredBundles = [], optionalPlugins = [], kibanaVersion = '7.0.0', + type = 'standard', configPath = [path], server = true, ui = true, @@ -49,6 +50,7 @@ describe('PluginsService', () => { requiredBundles?: string[]; optionalPlugins?: string[]; kibanaVersion?: string; + type?: PluginType; configPath?: ConfigPath; server?: boolean; ui?: boolean; @@ -61,6 +63,7 @@ describe('PluginsService', () => { version, configPath: `${configPath}${disabled ? '-disabled' : ''}`, kibanaVersion, + type, requiredPlugins, requiredBundles, optionalPlugins, @@ -150,7 +153,7 @@ describe('PluginsService', () => { } ); - await pluginsService.discover({ environment: environmentSetup }); + await pluginsService.discover({ environment: environmentPreboot }); const setupDeps = coreMock.createInternalSetup(); await pluginsService.setup(setupDeps); diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index c90d2e804225c71..0b9f58f20c23e62 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -45,6 +45,7 @@ function createPluginManifest(manifestProps: Partial = {}): Plug version: 'some-version', configPath: 'path', kibanaVersion: '7.0.0', + type: 'standard', requiredPlugins: ['some-required-dep'], optionalPlugins: ['some-optional-dep'], requiredBundles: [], @@ -194,7 +195,7 @@ test('`setup` fails if object returned from initializer does not define `setup` }); test('`setup` initializes plugin and calls appropriate lifecycle hook', async () => { - const manifest = createPluginManifest(); + const manifest = createPluginManifest({ type: pluginType }); const opaqueId = Symbol(); const initializerContext = createPluginInitializerContext( coreContext, @@ -214,7 +215,9 @@ test('`setup` initializes plugin and calls appropriate lifecycle hook', async () const setupContext = createPluginSetupContext(coreContext, setupDeps, plugin); const setupDependencies = { 'some-required-dep': { contract: 'no' } }; - await expect(plugin.setup(setupContext, setupDependencies)).resolves.toEqual({ contract: 'yes' }); + await expect(plugin.setup(setupContext, setupDependencies)).resolves.toEqual({ + contract: 'yes', + }); expect(mockPluginInitializer).toHaveBeenCalledTimes(1); expect(mockPluginInitializer).toHaveBeenCalledWith(initializerContext); diff --git a/src/core/server/plugins/plugin.ts b/src/core/server/plugins/plugin.ts index ca7f11e28de75fb..d3a711bf96471c6 100644 --- a/src/core/server/plugins/plugin.ts +++ b/src/core/server/plugins/plugin.ts @@ -22,8 +22,41 @@ import { PluginInitializer, PluginOpaqueId, PluginConfigDescriptor, + PrebootPlugin, } from './types'; -import { CoreSetup, CoreStart } from '..'; +import { CorePreboot, CoreSetup, CoreStart } from '..'; + +export interface CommonPluginWrapper { + readonly path: string; + readonly manifest: PluginManifest; + readonly opaqueId: PluginOpaqueId; + readonly name: PluginManifest['id']; + readonly configPath: PluginManifest['configPath']; + readonly requiredPlugins: PluginManifest['requiredPlugins']; + readonly optionalPlugins: PluginManifest['optionalPlugins']; + readonly requiredBundles: PluginManifest['requiredBundles']; + readonly includesServerPlugin: PluginManifest['server']; + readonly includesUiPlugin: PluginManifest['ui']; +} + +export interface PrebootPluginWrapper + extends CommonPluginWrapper { + setup(setupContext: CorePreboot, plugins: TPluginsSetup): TSetup; + stop(): Promise; +} + +export interface StandardPluginWrapper< + TSetup = unknown, + TStart = unknown, + TPluginsSetup extends object = object, + TPluginsStart extends object = object +> extends CommonPluginWrapper { + readonly startDependencies: Promise<[CoreStart, TPluginsStart, TStart]>; + + setup(setupContext: CoreSetup, plugins: TPluginsSetup): TSetup | Promise; + start(startContext: CoreStart, plugins: TPluginsStart): TStart | Promise; + stop(): Promise; +} /** * Lightweight wrapper around discovered plugin that is responsible for instantiating @@ -53,6 +86,7 @@ export class PluginWrapper< private instance?: | Plugin + | PrebootPlugin | AsyncPlugin; private readonly startDependencies$ = new Subject<[CoreStart, TPluginsStart, TStart]>(); @@ -88,11 +122,16 @@ export class PluginWrapper< * is the contract returned by the dependency's `setup` function. */ public setup( - setupContext: CoreSetup, + setupContext: CoreSetup | CorePreboot, plugins: TPluginsSetup ): TSetup | Promise { this.instance = this.createPluginInstance(); - return this.instance.setup(setupContext, plugins); + + if (this.isStandardPluginInstance(this.instance)) { + return this.instance.setup(setupContext as CoreSetup, plugins); + } + + return this.instance.setup(setupContext as CorePreboot, plugins); } /** @@ -107,6 +146,10 @@ export class PluginWrapper< throw new Error(`Plugin "${this.name}" can't be started since it isn't set up.`); } + if (!this.isStandardPluginInstance(this.instance)) { + throw new Error(`Plugin "${this.name}" is not a standard plugin and cannot be started.`); + } + const startContract = this.instance.start(startContext, plugins); if (isPromise(startContract)) { return startContract.then((resolvedContract) => { @@ -154,6 +197,25 @@ export class PluginWrapper< return configDescriptor; } + /** + * Indicates whether plugin is a standard plugin (manifest `type` property is equal to `standard`). + */ + public isStandardPlugin(): this is StandardPluginWrapper< + TSetup, + TStart, + TPluginsSetup, + TPluginsStart + > { + return this.isStandardPluginInstance(this.instance); + } + + /** + * Indicates whether plugin is a preboot plugin (manifest `type` property is equal to `preboot`). + */ + public isPrebootPlugin(): this is PrebootPluginWrapper { + return this.isPrebootPluginInstance(this.instance); + } + private createPluginInstance() { this.log.debug('Initializing plugin'); @@ -185,4 +247,18 @@ export class PluginWrapper< return instance; } + + private isStandardPluginInstance( + instance: PluginWrapper['instance'] + ): instance is + | Plugin + | AsyncPlugin { + return this.manifest.type === 'standard'; + } + + private isPrebootPluginInstance( + instance: PluginWrapper['instance'] + ): instance is PrebootPlugin { + return this.manifest.type === 'preboot'; + } } diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index c466eb2b9ee09d5..1f94e18de968104 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -9,12 +9,16 @@ import { shareReplay } from 'rxjs/operators'; import type { RequestHandlerContext } from 'src/core/server'; import { CoreContext } from '../core_context'; -import { PluginWrapper } from './plugin'; -import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; +import { PluginWrapper, PrebootPluginWrapper, StandardPluginWrapper } from './plugin'; +import { + PluginsServicePrebootSetupDeps, + PluginsServiceSetupDeps, + PluginsServiceStartDeps, +} from './plugins_service'; import { PluginInitializerContext, PluginManifest, PluginOpaqueId } from './types'; import { IRouter, RequestHandlerContextProvider } from '../http'; import { getGlobalConfig, getGlobalConfig$ } from './legacy_config'; -import { CoreSetup, CoreStart } from '..'; +import { CorePreboot, CoreSetup, CoreStart } from '..'; export interface InstanceInfo { uuid: string; @@ -49,6 +53,7 @@ export function createPluginInitializerContext( mode: coreContext.env.mode, packageInfo: coreContext.env.packageInfo, instanceUuid: instanceInfo.uuid, + configs: coreContext.env.configs, }, /** @@ -83,6 +88,29 @@ export function createPluginInitializerContext( }; } +/** + * @internal + */ +export function createPluginPrebootSetupContext( + coreContext: CoreContext, + deps: PluginsServicePrebootSetupDeps, + plugin: PrebootPluginWrapper +): CorePreboot { + return { + elasticsearch: { + createClient: deps.elasticsearch.createClient, + }, + http: { + registerRoutes: deps.http.registerRoutes, + }, + preboot: { + isSetupOnHold: deps.preboot.isSetupOnHold, + holdSetupUntilResolved: (reason, promise) => + deps.preboot.holdSetupUntilResolved(plugin.name, reason, promise), + }, + }; +} + /** * This returns a facade for `CoreContext` that will be exposed to the plugin `setup` method. * This facade should be safe to use only within `setup` itself. @@ -100,7 +128,7 @@ export function createPluginInitializerContext( export function createPluginSetupContext( coreContext: CoreContext, deps: PluginsServiceSetupDeps, - plugin: PluginWrapper + plugin: StandardPluginWrapper ): CoreSetup { const router = deps.http.createRouter('', plugin.opaqueId); diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 5c50df07dc69793..786c1c186f1825e 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -34,7 +34,7 @@ let configService: ConfigService; let coreId: symbol; let env: Env; let mockPluginSystem: jest.Mocked; -let environmentSetup: ReturnType; +let environmentPreboot: ReturnType; const setupDeps = coreMock.createInternalSetup(); const logger = loggingSystemMock.create(); @@ -115,7 +115,7 @@ async function testSetup() { mockPluginSystem.uiPlugins.mockReturnValue(new Map()); mockPluginSystem.getPlugins.mockReturnValue([]); - environmentSetup = environmentServiceMock.createSetupContract(); + environmentPreboot = environmentServiceMock.createPrebootContract(); } afterEach(() => { @@ -134,7 +134,7 @@ describe('PluginsService', () => { plugin$: from([]), }); - await expect(pluginsService.discover({ environment: environmentSetup })).rejects + await expect(pluginsService.discover({ environment: environmentPreboot })).rejects .toMatchInlineSnapshot(` [Error: Failed to initialize plugins: Invalid JSON (invalid-manifest, path-1)] @@ -156,7 +156,7 @@ describe('PluginsService', () => { plugin$: from([]), }); - await expect(pluginsService.discover({ environment: environmentSetup })).rejects + await expect(pluginsService.discover({ environment: environmentPreboot })).rejects .toMatchInlineSnapshot(` [Error: Failed to initialize plugins: Incompatible version (incompatible-version, path-3)] @@ -192,7 +192,7 @@ describe('PluginsService', () => { }); await expect( - pluginsService.discover({ environment: environmentSetup }) + pluginsService.discover({ environment: environmentPreboot }) ).rejects.toMatchInlineSnapshot( `[Error: Plugin with id "conflicting-id" is already registered!]` ); @@ -254,7 +254,7 @@ describe('PluginsService', () => { ]), }); - await pluginsService.discover({ environment: environmentSetup }); + await pluginsService.discover({ environment: environmentPreboot }); const setup = await pluginsService.setup(setupDeps); expect(setup.contracts).toBeInstanceOf(Map); @@ -301,7 +301,7 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin]), }); - const { pluginTree } = await pluginsService.discover({ environment: environmentSetup }); + const { pluginTree } = await pluginsService.discover({ environment: environmentPreboot }); expect(pluginTree).toBeUndefined(); expect(mockDiscover).toHaveBeenCalledTimes(1); @@ -337,7 +337,7 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin, thirdPlugin, lastPlugin, missingDepsPlugin]), }); - const { pluginTree } = await pluginsService.discover({ environment: environmentSetup }); + const { pluginTree } = await pluginsService.discover({ environment: environmentPreboot }); expect(pluginTree).toBeUndefined(); expect(mockDiscover).toHaveBeenCalledTimes(1); @@ -370,7 +370,7 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin]), }); - await pluginsService.discover({ environment: environmentSetup }); + await pluginsService.discover({ environment: environmentPreboot }); expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); @@ -418,7 +418,7 @@ describe('PluginsService', () => { }), ]), }); - await pluginsService.discover({ environment: environmentSetup }); + await pluginsService.discover({ environment: environmentPreboot }); expect(configService.setSchema).toBeCalledWith('path', configSchema); }); @@ -449,7 +449,7 @@ describe('PluginsService', () => { }), ]), }); - await pluginsService.discover({ environment: environmentSetup }); + await pluginsService.discover({ environment: environmentPreboot }); expect(configService.addDeprecationProvider).toBeCalledWith( 'config-path', deprecationProvider @@ -467,7 +467,7 @@ describe('PluginsService', () => { mockPluginSystem.getPlugins.mockReturnValue([pluginA, pluginB]); - const { pluginPaths } = await pluginsService.discover({ environment: environmentSetup }); + const { pluginPaths } = await pluginsService.discover({ environment: environmentPreboot }); expect(pluginPaths).toEqual(['/plugin-A-path', '/plugin-B-path']); }); @@ -538,7 +538,7 @@ describe('PluginsService', () => { plugin$: from([pluginA, pluginB, pluginC]), }); - await pluginsService.discover({ environment: environmentSetup }); + await pluginsService.discover({ environment: environmentPreboot }); // eslint-disable-next-line dot-notation expect(pluginsService['pluginConfigUsageDescriptors']).toMatchInlineSnapshot(` @@ -595,7 +595,7 @@ describe('PluginsService', () => { }); mockPluginSystem.uiPlugins.mockReturnValue(new Map([pluginToDiscoveredEntry(plugin)])); - const { uiPlugins } = await pluginsService.discover({ environment: environmentSetup }); + const { uiPlugins } = await pluginsService.discover({ environment: environmentPreboot }); const uiConfig$ = uiPlugins.browserConfigs.get('plugin-with-expose'); expect(uiConfig$).toBeDefined(); @@ -631,7 +631,7 @@ describe('PluginsService', () => { }); mockPluginSystem.uiPlugins.mockReturnValue(new Map([pluginToDiscoveredEntry(plugin)])); - const { uiPlugins } = await pluginsService.discover({ environment: environmentSetup }); + const { uiPlugins } = await pluginsService.discover({ environment: environmentPreboot }); expect([...uiPlugins.browserConfigs.entries()]).toHaveLength(0); }); }); @@ -660,7 +660,7 @@ describe('PluginsService', () => { describe('uiPlugins.internal', () => { it('contains internal properties for plugins', async () => { config$.next({ plugins: { initialize: true }, plugin1: { enabled: false } }); - const { uiPlugins } = await pluginsService.discover({ environment: environmentSetup }); + const { uiPlugins } = await pluginsService.discover({ environment: environmentPreboot }); expect(uiPlugins.internal).toMatchInlineSnapshot(` Map { "plugin-1" => Object { @@ -681,7 +681,7 @@ describe('PluginsService', () => { it('includes disabled plugins', async () => { config$.next({ plugins: { initialize: true }, plugin1: { enabled: false } }); - const { uiPlugins } = await pluginsService.discover({ environment: environmentSetup }); + const { uiPlugins } = await pluginsService.discover({ environment: environmentPreboot }); expect([...uiPlugins.internal.keys()].sort()).toEqual(['plugin-1', 'plugin-2']); }); }); @@ -689,7 +689,7 @@ describe('PluginsService', () => { describe('plugin initialization', () => { it('does initialize if plugins.initialize is true', async () => { config$.next({ plugins: { initialize: true } }); - await pluginsService.discover({ environment: environmentSetup }); + await pluginsService.discover({ environment: environmentPreboot }); const { initialized } = await pluginsService.setup(setupDeps); expect(mockPluginSystem.setupPlugins).toHaveBeenCalled(); expect(initialized).toBe(true); @@ -697,7 +697,7 @@ describe('PluginsService', () => { it('does not initialize if plugins.initialize is false', async () => { config$.next({ plugins: { initialize: false } }); - await pluginsService.discover({ environment: environmentSetup }); + await pluginsService.discover({ environment: environmentPreboot }); const { initialized } = await pluginsService.setup(setupDeps); expect(mockPluginSystem.setupPlugins).not.toHaveBeenCalled(); expect(initialized).toBe(false); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index b2271c0cde4efd1..e7f14a432fee407 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -16,12 +16,28 @@ import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { discover, PluginDiscoveryError, PluginDiscoveryErrorType } from './discovery'; import { PluginWrapper } from './plugin'; -import { DiscoveredPlugin, PluginConfigDescriptor, PluginName, InternalPluginInfo } from './types'; +import { + DiscoveredPlugin, + PluginConfigDescriptor, + PluginName, + InternalPluginInfo, + PluginDependencies, + PluginType, +} from './types'; import { PluginsConfig, PluginsConfigType } from './plugins_config'; import { PluginsSystem } from './plugins_system'; -import { InternalCoreSetup, InternalCoreStart } from '../internal_types'; +import { InternalCorePrebootSetup, InternalCoreSetup, InternalCoreStart } from '../internal_types'; import { IConfigService } from '../config'; -import { InternalEnvironmentServiceSetup } from '../environment'; +import { InternalEnvironmentServicePreboot } from '../environment'; + +/** @internal */ +export type DiscoveredPlugins = { + [key in PluginType]: { + pluginTree: PluginDependencies; + pluginPaths: readonly string[]; + uiPlugins: UiPlugins; + }; +}; /** @internal */ export interface PluginsServiceSetup { @@ -56,6 +72,9 @@ export interface PluginsServiceStart { contracts: Map; } +/** @internal */ +export type PluginsServicePrebootSetupDeps = InternalCorePrebootSetup; + /** @internal */ export type PluginsServiceSetupDeps = InternalCoreSetup; @@ -64,7 +83,7 @@ export type PluginsServiceStartDeps = InternalCoreStart; /** @internal */ export interface PluginsServiceDiscoverDeps { - environment: InternalEnvironmentServiceSetup; + environment: InternalEnvironmentServicePreboot; } /** @internal */ @@ -86,7 +105,7 @@ export class PluginsService implements CoreService new PluginsConfig(rawConfig, coreContext.env))); } - public async discover({ environment }: PluginsServiceDiscoverDeps) { + public async discover({ environment }: PluginsServiceDiscoverDeps): Promise { const config = await this.config$.pipe(first()).toPromise(); const { error$, plugin$ } = discover(config, this.coreContext, { @@ -96,22 +115,27 @@ export class PluginsService implements CoreService plugin.path), - uiPlugins: { - internal: this.uiPluginInternalInfo, - public: uiPlugins, - browserConfigs: this.generateUiPluginsConfigs(uiPlugins), + preboot: { + pluginTree: pluginTree.preboot, + pluginPaths: plugins.preboot.map((plugin) => plugin.path), + uiPlugins: { + internal: this.uiPluginInternalInfo, + public: uiPlugins.preboot, + browserConfigs: this.generateUiPluginsConfigs(uiPlugins.preboot), + }, }, - notReadyServerUiPlugins: { - internal: this.uiPluginInternalInfo, - public: notReadyServerUiPlugins, - browserConfigs: this.generateUiPluginsConfigs(notReadyServerUiPlugins), + standard: { + pluginTree: pluginTree.standard, + pluginPaths: plugins.standard.map((plugin) => plugin.path), + uiPlugins: { + internal: this.uiPluginInternalInfo, + public: uiPlugins.standard, + browserConfigs: this.generateUiPluginsConfigs(uiPlugins.standard), + }, }, }; } @@ -120,6 +144,19 @@ export class PluginsService implements CoreService(); if (config.initialize) { - contracts = await this.pluginsSystem.setupPlugins(deps); + contracts = await this.pluginsSystem.setupStandardPlugins(deps); this.registerPluginStaticDirs(deps); } else { this.log.info('Plugin initialization disabled.'); @@ -141,13 +178,15 @@ export class PluginsService implements CoreService, parents: PluginName[] = [] - ): { enabled: true } | { enabled: false; missingDependencies: string[] } { + ): { enabled: true } | { enabled: false; missingOrIncompatibleDependencies: string[] } { const pluginInfo = pluginEnableStatuses.get(pluginName); - if (pluginInfo === undefined || !pluginInfo.isEnabled) { + if ( + pluginInfo === undefined || + !pluginInfo.isEnabled || + pluginInfo.plugin.manifest.type !== pluginType + ) { return { enabled: false, - missingDependencies: [], + missingOrIncompatibleDependencies: [], }; } - const missingDependencies = pluginInfo.plugin.requiredPlugins + const missingOrIncompatibleDependencies = pluginInfo.plugin.requiredPlugins .filter((dep) => !parents.includes(dep)) .filter( (dependencyName) => - !this.shouldEnablePlugin(dependencyName, pluginEnableStatuses, [...parents, pluginName]) - .enabled + !this.shouldEnablePlugin(dependencyName, pluginType, pluginEnableStatuses, [ + ...parents, + pluginName, + ]).enabled ); - if (missingDependencies.length === 0) { + if (missingOrIncompatibleDependencies.length === 0) { return { enabled: true, }; @@ -314,11 +371,11 @@ export class PluginsService implements CoreService ({ + createPluginPrebootSetupContext: mockCreatePluginPrebootSetupContext, createPluginSetupContext: mockCreatePluginSetupContext, createPluginStartContext: mockCreatePluginStartContext, })); diff --git a/src/core/server/plugins/plugins_system.test.ts b/src/core/server/plugins/plugins_system.test.ts index abcd00f4e2daf75..6ae432aa05776c8 100644 --- a/src/core/server/plugins/plugins_system.test.ts +++ b/src/core/server/plugins/plugins_system.test.ts @@ -20,7 +20,7 @@ import { CoreContext } from '../core_context'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { PluginWrapper } from './plugin'; -import { PluginName } from './types'; +import { PluginName, PluginType } from './types'; import { PluginsSystem } from './plugins_system'; import { coreMock } from '../mocks'; import { Logger } from '../logging'; @@ -32,7 +32,14 @@ function createPlugin( optional = [], server = true, ui = true, - }: { required?: string[]; optional?: string[]; server?: boolean; ui?: boolean } = {} + type = 'standard', + }: { + required?: string[]; + optional?: string[]; + server?: boolean; + ui?: boolean; + type?: PluginType; + } = {} ): PluginWrapper { return new PluginWrapper({ path: 'some-path', @@ -41,6 +48,7 @@ function createPlugin( version: 'some-version', configPath: 'path', kibanaVersion: '7.0.0', + type, requiredPlugins: required, optionalPlugins: optional, requiredBundles: [], @@ -52,6 +60,7 @@ function createPlugin( }); } +const prebootDeps = coreMock.createInternalPreboot(); const setupDeps = coreMock.createInternalSetup(); const startDeps = coreMock.createInternalStart(); @@ -74,89 +83,183 @@ beforeEach(() => { }); test('can be setup even without plugins', async () => { - const pluginsSetup = await pluginsSystem.setupPlugins(setupDeps); - - expect(pluginsSetup).toBeInstanceOf(Map); - expect(pluginsSetup.size).toBe(0); + for (const pluginsSetup of [ + await pluginsSystem.setupPrebootPlugins(prebootDeps), + await pluginsSystem.setupStandardPlugins(setupDeps), + ]) { + expect(pluginsSetup).toBeInstanceOf(Map); + expect(pluginsSetup.size).toBe(0); + } }); test('getPlugins returns the list of plugins', () => { - const pluginA = createPlugin('plugin-a'); - const pluginB = createPlugin('plugin-b'); - pluginsSystem.addPlugin(pluginA); - pluginsSystem.addPlugin(pluginB); + const pluginA = createPlugin('plugin-a', { type: 'preboot' }); + const pluginB = createPlugin('plugin-b', { type: 'preboot' }); + const pluginC = createPlugin('plugin-c'); + const pluginD = createPlugin('plugin-d'); + + for (const plugin of [pluginA, pluginB, pluginC, pluginD]) { + pluginsSystem.addPlugin(plugin); + } - expect(pluginsSystem.getPlugins()).toEqual([pluginA, pluginB]); + expect(pluginsSystem.getPlugins()).toEqual({ + preboot: [pluginA, pluginB], + standard: [pluginC, pluginD], + }); }); test('getPluginDependencies returns dependency tree of symbols', () => { - pluginsSystem.addPlugin(createPlugin('plugin-a', { required: ['no-dep'] })); - pluginsSystem.addPlugin( - createPlugin('plugin-b', { required: ['plugin-a'], optional: ['no-dep', 'other'] }) - ); - pluginsSystem.addPlugin(createPlugin('no-dep')); + for (const type of ['standard', 'preboot'] as PluginType[]) { + pluginsSystem.addPlugin( + createPlugin(`plugin-a-${type}`, { type, required: [`no-dep-${type}`] }) + ); + pluginsSystem.addPlugin( + createPlugin(`plugin-b-${type}`, { + type, + required: [`plugin-a-${type}`], + optional: [`no-dep-${type}`, `other-${type}`], + }) + ); + pluginsSystem.addPlugin(createPlugin(`no-dep-${type}`, { type })); + } expect(pluginsSystem.getPluginDependencies()).toMatchInlineSnapshot(` Object { - "asNames": Map { - "plugin-a" => Array [ - "no-dep", - ], - "plugin-b" => Array [ - "plugin-a", - "no-dep", - ], - "no-dep" => Array [], + "preboot": Object { + "asNames": Map { + "plugin-a-preboot" => Array [ + "no-dep-preboot", + ], + "plugin-b-preboot" => Array [ + "plugin-a-preboot", + "no-dep-preboot", + ], + "no-dep-preboot" => Array [], + }, + "asOpaqueIds": Map { + Symbol(plugin-a-preboot) => Array [ + Symbol(no-dep-preboot), + ], + Symbol(plugin-b-preboot) => Array [ + Symbol(plugin-a-preboot), + Symbol(no-dep-preboot), + ], + Symbol(no-dep-preboot) => Array [], + }, }, - "asOpaqueIds": Map { - Symbol(plugin-a) => Array [ - Symbol(no-dep), - ], - Symbol(plugin-b) => Array [ - Symbol(plugin-a), - Symbol(no-dep), - ], - Symbol(no-dep) => Array [], + "standard": Object { + "asNames": Map { + "plugin-a-standard" => Array [ + "no-dep-standard", + ], + "plugin-b-standard" => Array [ + "plugin-a-standard", + "no-dep-standard", + ], + "no-dep-standard" => Array [], + }, + "asOpaqueIds": Map { + Symbol(plugin-a-standard) => Array [ + Symbol(no-dep-standard), + ], + Symbol(plugin-b-standard) => Array [ + Symbol(plugin-a-standard), + Symbol(no-dep-standard), + ], + Symbol(no-dep-standard) => Array [], + }, }, } `); }); -test('`setupPlugins` throws plugin has missing required dependency', async () => { +test('`setupPrebootPlugins` throws plugin has missing required dependency', async () => { + pluginsSystem.addPlugin(createPlugin('some-id', { type: 'preboot', required: ['missing-dep'] })); + + await expect(pluginsSystem.setupPrebootPlugins(prebootDeps)).rejects.toMatchInlineSnapshot( + `[Error: Topological ordering of plugins did not complete, these plugins have cyclic or missing dependencies: ["some-id"]]` + ); +}); + +test('`setupStandardPlugins` throws plugin has missing required dependency', async () => { pluginsSystem.addPlugin(createPlugin('some-id', { required: ['missing-dep'] })); - await expect(pluginsSystem.setupPlugins(setupDeps)).rejects.toMatchInlineSnapshot( + await expect(pluginsSystem.setupStandardPlugins(setupDeps)).rejects.toMatchInlineSnapshot( `[Error: Topological ordering of plugins did not complete, these plugins have cyclic or missing dependencies: ["some-id"]]` ); }); -test('`setupPlugins` throws if plugins have circular required dependency', async () => { +test('`setupPrebootPlugins` throws if plugins have circular required dependency', async () => { + pluginsSystem.addPlugin(createPlugin('no-dep', { type: 'preboot' })); + pluginsSystem.addPlugin( + createPlugin('depends-on-1', { type: 'preboot', required: ['depends-on-2'] }) + ); + pluginsSystem.addPlugin( + createPlugin('depends-on-2', { type: 'preboot', required: ['depends-on-1'] }) + ); + + await expect(pluginsSystem.setupPrebootPlugins(prebootDeps)).rejects.toMatchInlineSnapshot( + `[Error: Topological ordering of plugins did not complete, these plugins have cyclic or missing dependencies: ["depends-on-1","depends-on-2"]]` + ); +}); + +test('`setupStandardPlugins` throws if plugins have circular required dependency', async () => { pluginsSystem.addPlugin(createPlugin('no-dep')); pluginsSystem.addPlugin(createPlugin('depends-on-1', { required: ['depends-on-2'] })); pluginsSystem.addPlugin(createPlugin('depends-on-2', { required: ['depends-on-1'] })); - await expect(pluginsSystem.setupPlugins(setupDeps)).rejects.toMatchInlineSnapshot( + await expect(pluginsSystem.setupStandardPlugins(setupDeps)).rejects.toMatchInlineSnapshot( `[Error: Topological ordering of plugins did not complete, these plugins have cyclic or missing dependencies: ["depends-on-1","depends-on-2"]]` ); }); -test('`setupPlugins` throws if plugins have circular optional dependency', async () => { +test('`setupPrebootPlugins` throws if plugins have circular optional dependency', async () => { + pluginsSystem.addPlugin(createPlugin('no-dep', { type: 'preboot' })); + pluginsSystem.addPlugin( + createPlugin('depends-on-1', { type: 'preboot', optional: ['depends-on-2'] }) + ); + pluginsSystem.addPlugin( + createPlugin('depends-on-2', { type: 'preboot', optional: ['depends-on-1'] }) + ); + + await expect(pluginsSystem.setupPrebootPlugins(prebootDeps)).rejects.toMatchInlineSnapshot( + `[Error: Topological ordering of plugins did not complete, these plugins have cyclic or missing dependencies: ["depends-on-1","depends-on-2"]]` + ); +}); + +test('`setupStandardPlugins` throws if plugins have circular optional dependency', async () => { pluginsSystem.addPlugin(createPlugin('no-dep')); pluginsSystem.addPlugin(createPlugin('depends-on-1', { optional: ['depends-on-2'] })); pluginsSystem.addPlugin(createPlugin('depends-on-2', { optional: ['depends-on-1'] })); - await expect(pluginsSystem.setupPlugins(setupDeps)).rejects.toMatchInlineSnapshot( + await expect(pluginsSystem.setupStandardPlugins(setupDeps)).rejects.toMatchInlineSnapshot( `[Error: Topological ordering of plugins did not complete, these plugins have cyclic or missing dependencies: ["depends-on-1","depends-on-2"]]` ); }); -test('`setupPlugins` ignores missing optional dependency', async () => { +test('`setupPrebootPlugins` ignores missing optional dependency', async () => { + const plugin = createPlugin('some-id', { type: 'preboot', optional: ['missing-dep'] }); + jest.spyOn(plugin, 'setup').mockResolvedValue('test'); + + pluginsSystem.addPlugin(plugin); + + expect([...(await pluginsSystem.setupPrebootPlugins(prebootDeps))]).toMatchInlineSnapshot(` + Array [ + Array [ + "some-id", + "test", + ], + ] + `); +}); + +test('`setupStandardPlugins` ignores missing optional dependency', async () => { const plugin = createPlugin('some-id', { optional: ['missing-dep'] }); jest.spyOn(plugin, 'setup').mockResolvedValue('test'); pluginsSystem.addPlugin(plugin); - expect([...(await pluginsSystem.setupPlugins(setupDeps))]).toMatchInlineSnapshot(` + expect([...(await pluginsSystem.setupStandardPlugins(setupDeps))]).toMatchInlineSnapshot(` Array [ Array [ "some-id", @@ -230,7 +333,7 @@ test('correctly orders plugins and returns exposed values for "setup" and "start startContextMap.get(plugin.name) ); - expect([...(await pluginsSystem.setupPlugins(setupDeps))]).toMatchInlineSnapshot(` + expect([...(await pluginsSystem.setupStandardPlugins(setupDeps))]).toMatchInlineSnapshot(` Array [ Array [ "order-0", @@ -261,7 +364,7 @@ test('correctly orders plugins and returns exposed values for "setup" and "start expect(plugin.setup).toHaveBeenCalledWith(setupContextMap.get(plugin.name), deps.setup); } - expect([...(await pluginsSystem.startPlugins(startDeps))]).toMatchInlineSnapshot(` + expect([...(await pluginsSystem.startStandardPlugins(startDeps))]).toMatchInlineSnapshot(` Array [ Array [ "order-0", @@ -304,7 +407,7 @@ test('`setupPlugins` only setups plugins that have server side', async () => { pluginsSystem.addPlugin(plugin); }); - expect([...(await pluginsSystem.setupPlugins(setupDeps))]).toMatchInlineSnapshot(` + expect([...(await pluginsSystem.setupStandardPlugins(setupDeps))]).toMatchInlineSnapshot(` Array [ Array [ "order-1", @@ -335,7 +438,7 @@ test('`setupPlugins` only setups plugins that have server side', async () => { }); test('`uiPlugins` returns empty Map before plugins are added', async () => { - expect(pluginsSystem.uiPlugins()).toMatchInlineSnapshot(`Map {}`); + expect(pluginsSystem.uiPlugins().standard).toMatchInlineSnapshot(`Map {}`); }); test('`uiPlugins` returns ordered Maps of all plugin manifests', async () => { @@ -357,7 +460,7 @@ test('`uiPlugins` returns ordered Maps of all plugin manifests', async () => { pluginsSystem.addPlugin(plugin); }); - expect([...pluginsSystem.uiPlugins().keys()]).toMatchInlineSnapshot(` + expect([...pluginsSystem.uiPlugins().standard.keys()]).toMatchInlineSnapshot(` Array [ "order-0", "order-1", @@ -386,14 +489,14 @@ test('`uiPlugins` returns only ui plugin dependencies', async () => { pluginsSystem.addPlugin(plugin); }); - const plugin = pluginsSystem.uiPlugins().get('ui-plugin')!; + const plugin = pluginsSystem.uiPlugins().standard.get('ui-plugin')!; expect(plugin.requiredPlugins).toEqual(['req-ui']); expect(plugin.optionalPlugins).toEqual(['opt-ui']); }); test('can start without plugins', async () => { - await pluginsSystem.setupPlugins(setupDeps); - const pluginsStart = await pluginsSystem.startPlugins(startDeps); + await pluginsSystem.setupStandardPlugins(setupDeps); + const pluginsStart = await pluginsSystem.startStandardPlugins(startDeps); expect(pluginsStart).toBeInstanceOf(Map); expect(pluginsStart.size).toBe(0); @@ -410,8 +513,8 @@ test('`startPlugins` only starts plugins that were setup', async () => { pluginsSystem.addPlugin(plugin); }); - await pluginsSystem.setupPlugins(setupDeps); - const result = await pluginsSystem.startPlugins(startDeps); + await pluginsSystem.setupStandardPlugins(setupDeps); + const result = await pluginsSystem.startStandardPlugins(startDeps); expect([...result]).toMatchInlineSnapshot(` Array [ Array [ @@ -439,7 +542,7 @@ describe('setup', () => { pluginsSystem.addPlugin(plugin); mockCreatePluginSetupContext.mockImplementation(() => ({})); - const promise = pluginsSystem.setupPlugins(setupDeps); + const promise = pluginsSystem.setupStandardPlugins(setupDeps); jest.runAllTimers(); await expect(promise).rejects.toMatchInlineSnapshot( @@ -457,7 +560,7 @@ describe('setup', () => { jest.spyOn(plugin, 'start').mockResolvedValue(`started-as-${index}`); pluginsSystem.addPlugin(plugin); }); - await pluginsSystem.setupPlugins(setupDeps); + await pluginsSystem.setupStandardPlugins(setupDeps); const log = logger.get.mock.results[0].value as jest.Mocked; expect(log.info).toHaveBeenCalledWith(`Setting up [2] plugins: [order-1,order-0]`); }); @@ -479,8 +582,8 @@ describe('start', () => { mockCreatePluginSetupContext.mockImplementation(() => ({})); mockCreatePluginStartContext.mockImplementation(() => ({})); - await pluginsSystem.setupPlugins(setupDeps); - const promise = pluginsSystem.startPlugins(startDeps); + await pluginsSystem.setupStandardPlugins(setupDeps); + const promise = pluginsSystem.startStandardPlugins(startDeps); jest.runAllTimers(); await expect(promise).rejects.toMatchInlineSnapshot( @@ -498,8 +601,8 @@ describe('start', () => { jest.spyOn(plugin, 'start').mockResolvedValue(`started-as-${index}`); pluginsSystem.addPlugin(plugin); }); - await pluginsSystem.setupPlugins(setupDeps); - await pluginsSystem.startPlugins(startDeps); + await pluginsSystem.setupStandardPlugins(setupDeps); + await pluginsSystem.startStandardPlugins(startDeps); const log = logger.get.mock.results[0].value as jest.Mocked; expect(log.info).toHaveBeenCalledWith(`Starting [2] plugins: [order-1,order-0]`); }); @@ -541,8 +644,8 @@ describe('asynchronous plugins', () => { .mockReturnValue(asyncStart ? Promise.resolve('start-async') : 'start-sync'); pluginsSystem.addPlugin(asyncPlugin); - await pluginsSystem.setupPlugins(setupDeps); - await pluginsSystem.startPlugins(startDeps); + await pluginsSystem.setupStandardPlugins(setupDeps); + await pluginsSystem.startStandardPlugins(startDeps); }; it('logs a warning if a plugin returns a promise from its setup contract in dev mode', async () => { @@ -648,8 +751,8 @@ describe('stop', () => { mockCreatePluginSetupContext.mockImplementation(() => ({})); - await pluginsSystem.setupPlugins(setupDeps); - const stopPromise = pluginsSystem.stopPlugins(); + await pluginsSystem.setupStandardPlugins(setupDeps); + const stopPromise = pluginsSystem.stopStandardPlugins(); jest.runAllTimers(); await stopPromise; diff --git a/src/core/server/plugins/plugins_system.ts b/src/core/server/plugins/plugins_system.ts index f7a71aa8057c7eb..74a239ff7ed58d0 100644 --- a/src/core/server/plugins/plugins_system.ts +++ b/src/core/server/plugins/plugins_system.ts @@ -9,10 +9,18 @@ import { withTimeout, isPromise } from '@kbn/std'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; -import { PluginWrapper } from './plugin'; -import { DiscoveredPlugin, PluginName } from './types'; -import { createPluginSetupContext, createPluginStartContext } from './plugin_context'; -import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; +import { PluginWrapper, PrebootPluginWrapper, StandardPluginWrapper } from './plugin'; +import { DiscoveredPlugin, PluginName, PluginType } from './types'; +import { + createPluginPrebootSetupContext, + createPluginSetupContext, + createPluginStartContext, +} from './plugin_context'; +import { + PluginsServicePrebootSetupDeps, + PluginsServiceSetupDeps, + PluginsServiceStartDeps, +} from './plugins_service'; import { PluginDependencies } from '.'; const Sec = 1000; @@ -22,7 +30,8 @@ export class PluginsSystem { private readonly plugins = new Map(); private readonly log: Logger; // `satup`, the past-tense version of the noun `setup`. - private readonly satupPlugins: PluginName[] = []; + private readonly satupPrebootPlugins: PluginName[] = []; + private readonly satupStandardPlugins: PluginName[] = []; constructor(private readonly coreContext: CoreContext) { this.log = coreContext.logger.get('plugins-system'); @@ -33,57 +42,92 @@ export class PluginsSystem { } public getPlugins() { - return [...this.plugins.values()]; + return [...this.plugins.values()].reduce( + (groups, plugin) => { + const pluginsGroup = plugin.isPrebootPlugin() ? groups.preboot : groups.standard; + pluginsGroup.push(plugin); + return groups; + }, + { preboot: [], standard: [] } as { [key in PluginType]: PluginWrapper[] } + ); } /** * @returns a ReadonlyMap of each plugin and an Array of its available dependencies * @internal */ - public getPluginDependencies(): PluginDependencies { - const asNames = new Map( - [...this.plugins].map(([name, plugin]) => [ + public getPluginDependencies(): { [key in PluginType]: PluginDependencies } { + const dependenciesByType = { + preboot: { asNames: new Map(), asOpaqueIds: new Map() }, + standard: { asNames: new Map(), asOpaqueIds: new Map() }, + }; + + for (const plugin of this.plugins.values()) { + const dependencies = [ + ...new Set([ + ...plugin.requiredPlugins, + ...plugin.optionalPlugins.filter((optPlugin) => this.plugins.has(optPlugin)), + ]), + ]; + + const pluginsGroup = plugin.isPrebootPlugin() + ? dependenciesByType.preboot + : dependenciesByType.standard; + + // SAME AS DEPS???? NAME === DEP + pluginsGroup.asNames.set( plugin.name, - [ - ...new Set([ - ...plugin.requiredPlugins, - ...plugin.optionalPlugins.filter((optPlugin) => this.plugins.has(optPlugin)), - ]), - ].map((depId) => this.plugins.get(depId)!.name), - ]) - ); - const asOpaqueIds = new Map( - [...this.plugins].map(([name, plugin]) => [ + dependencies.map((pluginName) => this.plugins.get(pluginName)!.name) + ); + pluginsGroup.asOpaqueIds.set( plugin.opaqueId, - [ - ...new Set([ - ...plugin.requiredPlugins, - ...plugin.optionalPlugins.filter((optPlugin) => this.plugins.has(optPlugin)), - ]), - ].map((depId) => this.plugins.get(depId)!.opaqueId), - ]) + dependencies.map((pluginName) => this.plugins.get(pluginName)!.opaqueId) + ); + } + + return dependenciesByType; + } + + public async setupPrebootPlugins(deps: PluginsServicePrebootSetupDeps) { + const contracts = await this.setupPlugins( + (plugin) => plugin.isPrebootPlugin(), + (plugin: PrebootPluginWrapper, pluginDepContracts) => + plugin.setup( + createPluginPrebootSetupContext(this.coreContext, deps, plugin), + pluginDepContracts + ) + ); + + this.satupPrebootPlugins.push(...contracts.keys()); + + return contracts; + } + + public async setupStandardPlugins(deps: PluginsServiceSetupDeps) { + const contracts = await this.setupPlugins( + (plugin) => plugin.isStandardPlugin(), + (plugin: StandardPluginWrapper, pluginDepContracts) => + plugin.setup(createPluginSetupContext(this.coreContext, deps, plugin), pluginDepContracts) ); - return { asNames, asOpaqueIds }; + this.satupStandardPlugins.push(...contracts.keys()); + + return contracts; } - public async setupPlugins(deps: PluginsServiceSetupDeps) { + public async startStandardPlugins(deps: PluginsServiceStartDeps) { const contracts = new Map(); - if (this.plugins.size === 0) { + if (this.satupStandardPlugins.length === 0) { return contracts; } - const sortedPlugins = new Map( - [...this.getTopologicallySortedPluginNames()] - .map((pluginName) => [pluginName, this.plugins.get(pluginName)!] as [string, PluginWrapper]) - .filter(([pluginName, plugin]) => plugin.includesServerPlugin) - ); this.log.info( - `Setting up [${sortedPlugins.size}] plugins: [${[...sortedPlugins.keys()].join(',')}]` + `Starting [${this.satupStandardPlugins.length}] plugins: [${[...this.satupStandardPlugins]}]` ); - for (const [pluginName, plugin] of sortedPlugins) { - this.log.debug(`Setting up plugin "${pluginName}"...`); + for (const pluginName of this.satupStandardPlugins) { + this.log.debug(`Starting plugin "${pluginName}"...`); + const plugin = this.plugins.get(pluginName)!; const pluginDeps = new Set([...plugin.requiredPlugins, ...plugin.optionalPlugins]); const pluginDepContracts = Array.from(pluginDeps).reduce((depContracts, dependencyName) => { // Only set if present. Could be absent if plugin does not have server-side code or is a @@ -96,24 +140,24 @@ export class PluginsSystem { }, {} as Record); let contract: unknown; - const contractOrPromise = plugin.setup( - createPluginSetupContext(this.coreContext, deps, plugin), + const contractOrPromise = plugin.start( + createPluginStartContext(this.coreContext, deps, plugin), pluginDepContracts ); if (isPromise(contractOrPromise)) { if (this.coreContext.env.mode.dev) { this.log.warn( - `Plugin ${pluginName} is using asynchronous setup lifecycle. Asynchronous plugins support will be removed in a later version.` + `Plugin ${pluginName} is using asynchronous start lifecycle. Asynchronous plugins support will be removed in a later version.` ); } - const contractMaybe = await withTimeout({ + const contractMaybe = await withTimeout({ promise: contractOrPromise, timeoutMs: 10 * Sec, }); if (contractMaybe.timedout) { throw new Error( - `Setup lifecycle of "${pluginName}" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.` + `Start lifecycle of "${pluginName}" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.` ); } else { contract = contractMaybe.value; @@ -123,23 +167,83 @@ export class PluginsSystem { } contracts.set(pluginName, contract); - this.satupPlugins.push(pluginName); } return contracts; } - public async startPlugins(deps: PluginsServiceStartDeps) { + public async stopPrebootPlugins() { + if (this.plugins.size === 0 || this.satupPrebootPlugins.length === 0) { + return; + } + + this.log.info(`Stopping preboot plugins.`); + + await this.stopPlugins(this.satupPrebootPlugins); + } + + public async stopStandardPlugins() { + if (this.plugins.size === 0 || this.satupStandardPlugins.length === 0) { + return; + } + + this.log.info(`Stopping standard plugins.`); + + await this.stopPlugins(this.satupStandardPlugins); + } + + /** + * Get a Map of all discovered UI plugins in topological order. + */ + public uiPlugins() { + const uiPluginNames = [...this.getTopologicallySortedPluginNames().keys()].filter( + (pluginName) => this.plugins.get(pluginName)!.includesUiPlugin + ); + + const publicPluginsByType = { + preboot: new Map(), + standard: new Map(), + }; + + for (const pluginName of uiPluginNames) { + const plugin = this.plugins.get(pluginName)!; + const pluginsGroup = plugin.isPrebootPlugin() + ? publicPluginsByType.preboot + : publicPluginsByType.standard; + + pluginsGroup.set(pluginName, { + id: pluginName, + type: plugin.manifest.type, + configPath: plugin.manifest.configPath, + requiredPlugins: plugin.manifest.requiredPlugins.filter((p) => uiPluginNames.includes(p)), + optionalPlugins: plugin.manifest.optionalPlugins.filter((p) => uiPluginNames.includes(p)), + requiredBundles: plugin.manifest.requiredBundles, + }); + } + + return publicPluginsByType; + } + + private async setupPlugins( + pluginFilter: (plugin: PluginWrapper) => boolean, + pluginInitializer: (plugin: PluginWrapper, depContracts: Record) => unknown + ) { const contracts = new Map(); - if (this.satupPlugins.length === 0) { + if (this.plugins.size === 0) { return contracts; } - this.log.info(`Starting [${this.satupPlugins.length}] plugins: [${[...this.satupPlugins]}]`); + const sortedPlugins = new Map( + [...this.getTopologicallySortedPluginNames()] + .map((pluginName) => [pluginName, this.plugins.get(pluginName)!] as [string, PluginWrapper]) + .filter(([, plugin]) => plugin.includesServerPlugin && pluginFilter(plugin)) + ); + this.log.info( + `Setting up [${sortedPlugins.size}] plugins: [${[...sortedPlugins.keys()].join(',')}]` + ); - for (const pluginName of this.satupPlugins) { - this.log.debug(`Starting plugin "${pluginName}"...`); - const plugin = this.plugins.get(pluginName)!; + for (const [pluginName, plugin] of sortedPlugins) { + this.log.debug(`Setting up plugin "${pluginName}"...`); const pluginDeps = new Set([...plugin.requiredPlugins, ...plugin.optionalPlugins]); const pluginDepContracts = Array.from(pluginDeps).reduce((depContracts, dependencyName) => { // Only set if present. Could be absent if plugin does not have server-side code or is a @@ -152,24 +256,21 @@ export class PluginsSystem { }, {} as Record); let contract: unknown; - const contractOrPromise = plugin.start( - createPluginStartContext(this.coreContext, deps, plugin), - pluginDepContracts - ); + const contractOrPromise = pluginInitializer(plugin, pluginDepContracts); if (isPromise(contractOrPromise)) { if (this.coreContext.env.mode.dev) { this.log.warn( - `Plugin ${pluginName} is using asynchronous start lifecycle. Asynchronous plugins support will be removed in a later version.` + `Plugin ${pluginName} is using asynchronous setup lifecycle. Asynchronous plugins support will be removed in a later version.` ); } - const contractMaybe = await withTimeout({ + const contractMaybe = await withTimeout({ promise: contractOrPromise, timeoutMs: 10 * Sec, }); if (contractMaybe.timedout) { throw new Error( - `Start lifecycle of "${pluginName}" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.` + `Setup lifecycle of "${pluginName}" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.` ); } else { contract = contractMaybe.value; @@ -184,16 +285,10 @@ export class PluginsSystem { return contracts; } - public async stopPlugins() { - if (this.plugins.size === 0 || this.satupPlugins.length === 0) { - return; - } - - this.log.info(`Stopping all plugins.`); - + private async stopPlugins(pluginsToStop: PluginName[]) { // Stop plugins in the reverse order of when they were set up. - while (this.satupPlugins.length > 0) { - const pluginName = this.satupPlugins.pop()!; + while (pluginsToStop.length > 0) { + const pluginName = pluginsToStop.pop()!; this.log.debug(`Stopping plugin "${pluginName}"...`); @@ -208,38 +303,6 @@ export class PluginsSystem { } } - /** - * Get a Map of all discovered UI plugins in topological order. - */ - public uiPlugins() { - return new Map(this.allUiPlugins().filter(([, { type }]) => type === 'primary')); - } - - public notReadyServerUiPlugins() { - return new Map(this.allUiPlugins().filter(([, { type }]) => type === 'notReady')); - } - - private allUiPlugins(): Array<[string, DiscoveredPlugin]> { - const uiPluginNames = [...this.getTopologicallySortedPluginNames().keys()].filter( - (pluginName) => this.plugins.get(pluginName)!.includesUiPlugin - ); - - return uiPluginNames.map((pluginName) => { - const plugin = this.plugins.get(pluginName)!; - return [ - pluginName, - { - id: pluginName, - type: plugin.manifest.type, - configPath: plugin.manifest.configPath, - requiredPlugins: plugin.manifest.requiredPlugins.filter((p) => uiPluginNames.includes(p)), - optionalPlugins: plugin.manifest.optionalPlugins.filter((p) => uiPluginNames.includes(p)), - requiredBundles: plugin.manifest.requiredBundles, - }, - ]; - }); - } - /** * Gets topologically sorted plugin names that are registered with the plugin system. * Ordering is possible if and only if the plugins graph has no directed cycles, diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index d6601ddfe8e3d17..3258991c50975bc 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -16,7 +16,7 @@ import { LoggerFactory } from '../logging'; import { KibanaConfigType } from '../kibana_config'; import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config'; import { SavedObjectsConfigType } from '../saved_objects/saved_objects_config'; -import { CoreSetup, CoreStart } from '..'; +import { CorePreboot, CoreSetup, CoreStart } from '..'; type Maybe = T | undefined; @@ -116,6 +116,9 @@ export type PluginName = string; /** @public */ export type PluginOpaqueId = symbol; +/** @public */ +export type PluginType = 'preboot' | 'standard'; + /** @internal */ export interface PluginDependencies { asNames: ReadonlyMap; @@ -149,7 +152,11 @@ export interface PluginManifest { */ readonly kibanaVersion: string; - readonly type: 'notReady' | 'primary'; + /** + * Type of the plugin, defaults to `standard`. Plugins with the `preboot` type are only initialized + * and function at the preboot stage. + */ + readonly type: PluginType; /** * Root {@link ConfigPath | configuration path} used by the plugin, defaults @@ -249,6 +256,12 @@ export interface DiscoveredPlugin { */ readonly configPath: ConfigPath; + /** + * Type of the plugin, defaults to `standard`. Plugins with the `preboot` type are only initialized + * and function at the preboot stage. + */ + readonly type: PluginType; + /** * An optional list of the other plugins that **must be** installed and enabled * for this plugin to function properly. @@ -273,8 +286,6 @@ export interface DiscoveredPlugin { * duplicated here. */ readonly requiredBundles: readonly PluginName[]; - - readonly type: 'notReady' | 'primary'; } /** @@ -299,6 +310,12 @@ export interface InternalPluginInfo { readonly publicAssetsDir: string; } +export interface PrebootPlugin { + setup(core: CorePreboot, plugins: TPluginsSetup): TSetup; + + stop?(): void; +} + /** * The interface that should be returned by a `PluginInitializer`. * @@ -365,6 +382,7 @@ export interface PluginInitializerContext { mode: EnvironmentMode; packageInfo: Readonly; instanceUuid: string; + configs: readonly string[]; }; /** * {@link LoggerFactory | logger factory} instance already bound to the plugin's logging context @@ -475,4 +493,5 @@ export type PluginInitializer< core: PluginInitializerContext ) => | Plugin + | PrebootPlugin | AsyncPlugin; diff --git a/src/core/server/preboot/index.ts b/src/core/server/preboot/index.ts new file mode 100644 index 000000000000000..2b7f25538dcb1c5 --- /dev/null +++ b/src/core/server/preboot/index.ts @@ -0,0 +1,10 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { PrebootService } from './preboot_service'; +export type { InternalPrebootServicePreboot, PrebootServicePreboot } from './types'; diff --git a/src/core/server/preboot/preboot_service.mock.ts b/src/core/server/preboot/preboot_service.mock.ts new file mode 100644 index 000000000000000..acdd9458a462d15 --- /dev/null +++ b/src/core/server/preboot/preboot_service.mock.ts @@ -0,0 +1,49 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { InternalPrebootServicePreboot, PrebootServicePreboot } from './types'; +import { PrebootService } from './preboot_service'; + +export type InternalPrebootServicePrebootMock = jest.Mocked; +export type PrebootServicePrebootMock = jest.Mocked; + +const createInternalPrebootContractMock = () => { + const mock: InternalPrebootServicePrebootMock = { + isSetupOnHold: jest.fn(), + holdSetupUntilResolved: jest.fn(), + waitUntilCanSetup: jest.fn(), + }; + return mock; +}; + +const createPrebootContractMock = () => { + const mock: PrebootServicePrebootMock = { + isSetupOnHold: jest.fn(), + holdSetupUntilResolved: jest.fn(), + }; + + return mock; +}; + +type PrebootServiceContract = PublicMethodsOf; + +const createPrebootServiceMock = () => { + const mocked: jest.Mocked = { + preboot: jest.fn(), + stop: jest.fn(), + }; + mocked.preboot.mockReturnValue(createInternalPrebootContractMock()); + return mocked; +}; + +export const prebootServiceMock = { + create: createPrebootServiceMock, + createInternalPrebootContract: createInternalPrebootContractMock, + createPrebootContract: createPrebootContractMock, +}; diff --git a/src/core/server/preboot/preboot_service.ts b/src/core/server/preboot/preboot_service.ts new file mode 100644 index 000000000000000..cf9a238bbb76c24 --- /dev/null +++ b/src/core/server/preboot/preboot_service.ts @@ -0,0 +1,53 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreContext } from '../core_context'; +import { InternalPrebootServicePreboot } from './types'; + +/** @internal */ +export class PrebootService { + private readonly promiseList: Array> = []; + private waitUntilCanSetupPromise?: Promise<{ shouldReloadConfig: boolean }>; + private readonly log = this.core.logger.get('preboot'); + + constructor(private readonly core: CoreContext) {} + + public preboot(): InternalPrebootServicePreboot { + let isSetupOnHold = false; + return { + isSetupOnHold: () => isSetupOnHold, + holdSetupUntilResolved: (pluginName, reason, promise) => { + if (this.waitUntilCanSetupPromise) { + throw new Error('Cannot hold boot at this stage.'); + } + + this.log.info(`"${pluginName}" plugin is holding setup: ${reason}`); + + isSetupOnHold = true; + + this.promiseList.push(promise); + }, + waitUntilCanSetup: () => { + if (!this.waitUntilCanSetupPromise) { + this.waitUntilCanSetupPromise = Promise.all(this.promiseList) + .then((results) => ({ + shouldReloadConfig: results.some((result) => result?.shouldReloadConfig), + })) + .finally(() => (isSetupOnHold = false)); + } + + return this.waitUntilCanSetupPromise; + }, + }; + } + + public stop() { + this.promiseList.length = 0; + this.waitUntilCanSetupPromise = undefined; + } +} diff --git a/src/core/server/preboot/types.ts b/src/core/server/preboot/types.ts new file mode 100644 index 000000000000000..fa5f543263d96c7 --- /dev/null +++ b/src/core/server/preboot/types.ts @@ -0,0 +1,31 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginName } from '..'; + +/** @internal */ +export interface InternalPrebootServicePreboot { + readonly isSetupOnHold: () => boolean; + readonly holdSetupUntilResolved: ( + pluginName: PluginName, + reason: string, + promise: Promise<{ shouldReloadConfig: boolean } | undefined> + ) => void; + readonly waitUntilCanSetup: () => Promise<{ shouldReloadConfig: boolean }>; +} + +/** + * @public + */ +export interface PrebootServicePreboot { + readonly isSetupOnHold: () => boolean; + readonly holdSetupUntilResolved: ( + reason: string, + promise: Promise<{ shouldReloadConfig: boolean } | undefined> + ) => void; +} diff --git a/src/core/server/not_ready_core_route_handler_context.ts b/src/core/server/preboot_core_route_handler_context.ts similarity index 57% rename from src/core/server/not_ready_core_route_handler_context.ts rename to src/core/server/preboot_core_route_handler_context.ts index fd72cdfd7dbb631..af3101fc9d7cc78 100644 --- a/src/core/server/not_ready_core_route_handler_context.ts +++ b/src/core/server/preboot_core_route_handler_context.ts @@ -7,12 +7,12 @@ */ // eslint-disable-next-line max-classes-per-file -import { InternalCoreSetup } from './internal_types'; -import { InternalUiSettingsServiceSetup, IUiSettingsClient } from './ui_settings'; +import { InternalCorePrebootSetup } from './internal_types'; +import { InternalUiSettingsServicePreboot, IUiSettingsClient } from './ui_settings'; -class NotReadyCoreUiSettingsRouteHandlerContext { +class PrebootCoreUiSettingsRouteHandlerContext { #client?: IUiSettingsClient; - constructor(private readonly uiSettingsSetup: InternalUiSettingsServiceSetup) {} + constructor(private readonly uiSettingsSetup: InternalUiSettingsServicePreboot) {} public get client() { if (this.#client == null) { @@ -22,10 +22,10 @@ class NotReadyCoreUiSettingsRouteHandlerContext { } } -export class NotReadyCoreRouteHandlerContext { - readonly uiSettings: NotReadyCoreUiSettingsRouteHandlerContext; +export class PrebootCoreRouteHandlerContext { + readonly uiSettings: PrebootCoreUiSettingsRouteHandlerContext; - constructor(private readonly coreSetup: InternalCoreSetup) { - this.uiSettings = new NotReadyCoreUiSettingsRouteHandlerContext(this.coreSetup.uiSettings); + constructor(private readonly corePreboot: InternalCorePrebootSetup) { + this.uiSettings = new PrebootCoreUiSettingsRouteHandlerContext(this.corePreboot.uiSettings); } } diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index c2b6a50763ec0ab..331c0ccc6a76ae5 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -19,113 +19,136 @@ import { RenderingSetupDeps, InternalRenderingServiceSetup, RenderingMetadata, + InternalRenderingServicePreboot, } from './types'; import { registerBootstrapRoute, bootstrapRendererFactory } from './bootstrap'; import { getSettingValue, getStylesheetPaths } from './render_utils'; +import { IBasePath, InternalHttpServicePreboot, KibanaRequest, LegacyRequest } from '../http'; +import { ICspConfig } from '../csp'; +import { IUiSettingsClient } from '../ui_settings'; +import { ExternalUrlConfig } from '../external_url'; +import { InternalStatusServiceSetup } from '../status'; + +interface RenderBaseOptions { + uiPlugins: UiPlugins; + http: { basePath: IBasePath; csp: ICspConfig; externalUrl: ExternalUrlConfig }; + status?: InternalStatusServiceSetup; +} /** @internal */ export class RenderingService { constructor(private readonly coreContext: CoreContext) {} + public async preboot({ + http, + uiPlugins, + }: { + http: InternalHttpServicePreboot; + uiPlugins: UiPlugins; + }): Promise { + http.registerRoutes('', (router) => { + registerBootstrapRoute({ + router, + renderer: bootstrapRendererFactory({ + uiPlugins, + serverBasePath: http.basePath.serverBasePath, + packageInfo: this.coreContext.env.packageInfo, + auth: http.auth, + }), + }); + }); + + return { + render: this.render.bind(this, { http, uiPlugins }), + }; + } + public async setup({ http, status, uiPlugins, - notReadyServerUiPlugins, }: RenderingSetupDeps): Promise { - const router = http.createRouter(''); - - const bootstrapRenderer = bootstrapRendererFactory({ - uiPlugins, - serverBasePath: http.basePath.serverBasePath, - packageInfo: this.coreContext.env.packageInfo, - auth: http.auth, - }); - registerBootstrapRoute({ router, renderer: bootstrapRenderer }); - - if (http.notReadyServer) { - const notReadyBootstrapRenderer = bootstrapRendererFactory({ - uiPlugins: notReadyServerUiPlugins, + registerBootstrapRoute({ + router: http.createRouter(''), + renderer: bootstrapRendererFactory({ + uiPlugins, serverBasePath: http.basePath.serverBasePath, packageInfo: this.coreContext.env.packageInfo, auth: http.auth, - }); - http.notReadyServer.registerRoutes('', (notReadyRouter) => { - registerBootstrapRoute({ router: notReadyRouter, renderer: notReadyBootstrapRenderer }); - }); - } + }), + }); return { - render: async ( - request, - uiSettings, - { includeUserSettings = true, vars, renderTarget }: IRenderOptions = {} - ) => { - const env = { - mode: this.coreContext.env.mode, - packageInfo: this.coreContext.env.packageInfo, - }; - const buildNum = env.packageInfo.buildNum; - const basePath = http.basePath.get(request); - const { serverBasePath, publicBaseUrl } = http.basePath; - const settings = { - defaults: uiSettings?.getRegistered() ?? {}, - user: includeUserSettings && uiSettings ? await uiSettings?.getUserProvided() : {}, - }; - - const darkMode = getSettingValue('theme:darkMode', settings, Boolean); - const themeVersion = getSettingValue('theme:version', settings, String); + render: this.render.bind(this, { http, uiPlugins, status }), + }; + } - const stylesheetPaths = getStylesheetPaths({ - darkMode, - themeVersion, - basePath: serverBasePath, - buildNum, - }); + private async render( + { http, uiPlugins, status }: RenderBaseOptions, + request: KibanaRequest | LegacyRequest, + uiSettings: IUiSettingsClient, + { includeUserSettings = true, vars }: IRenderOptions = {} + ) { + const env = { + mode: this.coreContext.env.mode, + packageInfo: this.coreContext.env.packageInfo, + }; + const buildNum = env.packageInfo.buildNum; + const basePath = http.basePath.get(request); + const { serverBasePath, publicBaseUrl } = http.basePath; + const settings = { + defaults: uiSettings.getRegistered() ?? {}, + user: includeUserSettings ? await uiSettings.getUserProvided() : {}, + }; - const plugins = - renderTarget === 'notReady' ? notReadyServerUiPlugins.public : uiPlugins.public; + const darkMode = getSettingValue('theme:darkMode', settings, Boolean); + const themeVersion = getSettingValue('theme:version', settings, String); - const metadata: RenderingMetadata = { - strictCsp: http.csp.strict, - uiPublicUrl: `${basePath}/ui`, - bootstrapScriptUrl: `${basePath}/bootstrap.js`, - i18n: i18n.translate, - locale: i18n.getLocale(), - darkMode, - stylesheetPaths, - themeVersion, - injectedMetadata: { - version: env.packageInfo.version, - buildNumber: env.packageInfo.buildNum, - branch: env.packageInfo.branch, - basePath, - serverBasePath, - publicBaseUrl, - env, - anonymousStatusPage: status.isStatusPageAnonymous(), - i18n: { - translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, - }, - csp: { warnLegacyBrowsers: http.csp.warnLegacyBrowsers }, - externalUrl: http.externalUrl, - vars: vars ?? {}, - uiPlugins: await Promise.all( - [...plugins].map(async ([id, plugin]) => ({ - id, - plugin, - config: await getUiConfig(uiPlugins, id), - })) - ), - legacyMetadata: { - uiSettings: settings, - }, - }, - }; + const stylesheetPaths = getStylesheetPaths({ + darkMode, + themeVersion, + basePath: serverBasePath, + buildNum, + }); - return `${renderToStaticMarkup(