diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/runner/run_tests.ts b/src/platform/packages/shared/kbn-scout/src/playwright/runner/run_tests.ts index 01d4cddffb450..e977ed272982b 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/runner/run_tests.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/runner/run_tests.ts @@ -19,6 +19,7 @@ import { silence } from '../../common'; import { preCreateSecurityIndexesViaSamlAuth, runElasticsearch, + startDockerServers, runKibanaServer, } from '../../servers'; import { getConfigRootDir, loadServersConfig } from '../../servers/configs'; @@ -133,6 +134,7 @@ async function runLocalServersAndTests( }; let shutdownEs; + let shutdownDockerServers: (() => Promise) | undefined; try { shutdownEs = await runElasticsearch({ @@ -143,6 +145,8 @@ async function runLocalServersAndTests( logsDir: options.logsDir, }); + shutdownDockerServers = await startDockerServers(config, log); + await runKibanaServer({ procs, onEarlyExit, @@ -162,8 +166,14 @@ async function runLocalServersAndTests( try { await procs.stop('kibana'); } finally { - if (shutdownEs) { - await shutdownEs(); + try { + if (shutdownDockerServers) { + await shutdownDockerServers(); + } + } finally { + if (shutdownEs) { + await shutdownEs(); + } } } } diff --git a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/base_config.test.ts b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/base_config.test.ts new file mode 100644 index 0000000000000..9ce47997f8af5 --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/base_config.test.ts @@ -0,0 +1,107 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { packageRegistryDocker } from '@kbn/test-docker-servers'; + +describe('Scout base configs', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('stateful base config', () => { + it('includes --xpack.fleet.registryUrl when FLEET_PACKAGE_REGISTRY_PORT is set', async () => { + process.env.FLEET_PACKAGE_REGISTRY_PORT = '6104'; + + const { defaultConfig } = await import('./stateful/base.config'); + const serverArgs: string[] = defaultConfig.kbnTestServer.serverArgs; + + expect(serverArgs).toContain('--xpack.fleet.registryUrl=http://localhost:6104'); + }); + + it('omits --xpack.fleet.registryUrl when FLEET_PACKAGE_REGISTRY_PORT is not set', async () => { + delete process.env.FLEET_PACKAGE_REGISTRY_PORT; + + const { defaultConfig } = await import('./stateful/base.config'); + const serverArgs: string[] = defaultConfig.kbnTestServer.serverArgs; + const registryArgs = serverArgs.filter((arg) => arg.includes('fleet.registryUrl')); + + expect(registryArgs).toHaveLength(0); + }); + + it('uses packageRegistryDocker from @kbn/test for dockerServers', async () => { + process.env.FLEET_PACKAGE_REGISTRY_PORT = '6104'; + + const { defaultConfig } = await import('./stateful/base.config'); + const registryConfig = (defaultConfig.dockerServers as Record).registry; + + expect(registryConfig.image).toBe(packageRegistryDocker.image); + expect(registryConfig.portInContainer).toBe(packageRegistryDocker.portInContainer); + expect(registryConfig.waitForLogLine).toBe(packageRegistryDocker.waitForLogLine); + expect(registryConfig.preferCached).toBe(packageRegistryDocker.preferCached); + }); + + it('overrides waitForLogLineTimeoutMs to 6 minutes', async () => { + process.env.FLEET_PACKAGE_REGISTRY_PORT = '6104'; + + const { defaultConfig } = await import('./stateful/base.config'); + const registryConfig = (defaultConfig.dockerServers as Record).registry; + + expect(registryConfig.waitForLogLineTimeoutMs).toBe(60 * 6 * 1000); + }); + }); + + describe('serverless base config', () => { + it('includes --xpack.fleet.registryUrl when FLEET_PACKAGE_REGISTRY_PORT is set', async () => { + process.env.FLEET_PACKAGE_REGISTRY_PORT = '6104'; + + const { defaultConfig } = await import('./serverless/serverless.base.config'); + const serverArgs: string[] = defaultConfig.kbnTestServer.serverArgs; + + expect(serverArgs).toContain('--xpack.fleet.registryUrl=http://localhost:6104'); + }); + + it('omits --xpack.fleet.registryUrl when FLEET_PACKAGE_REGISTRY_PORT is not set', async () => { + delete process.env.FLEET_PACKAGE_REGISTRY_PORT; + + const { defaultConfig } = await import('./serverless/serverless.base.config'); + const serverArgs: string[] = defaultConfig.kbnTestServer.serverArgs; + const registryArgs = serverArgs.filter((arg) => arg.includes('fleet.registryUrl')); + + expect(registryArgs).toHaveLength(0); + }); + + it('uses packageRegistryDocker from @kbn/test for dockerServers', async () => { + process.env.FLEET_PACKAGE_REGISTRY_PORT = '6104'; + + const { defaultConfig } = await import('./serverless/serverless.base.config'); + const registryConfig = (defaultConfig.dockerServers as Record).registry; + + expect(registryConfig.image).toBe(packageRegistryDocker.image); + expect(registryConfig.portInContainer).toBe(packageRegistryDocker.portInContainer); + expect(registryConfig.waitForLogLine).toBe(packageRegistryDocker.waitForLogLine); + expect(registryConfig.preferCached).toBe(packageRegistryDocker.preferCached); + }); + + it('overrides waitForLogLineTimeoutMs to 6 minutes', async () => { + process.env.FLEET_PACKAGE_REGISTRY_PORT = '6104'; + + const { defaultConfig } = await import('./serverless/serverless.base.config'); + const registryConfig = (defaultConfig.dockerServers as Record).registry; + + expect(registryConfig.waitForLogLineTimeoutMs).toBe(60 * 6 * 1000); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/serverless/resources/package_registry_config.yml b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/serverless/resources/package_registry_config.yml deleted file mode 100644 index 1885fa5c2ebe5..0000000000000 --- a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/serverless/resources/package_registry_config.yml +++ /dev/null @@ -1,2 +0,0 @@ -package_paths: - - /packages/package-storage diff --git a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/serverless/serverless.base.config.ts b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/serverless/serverless.base.config.ts index 3fa8c4a63b217..acd234c017c31 100644 --- a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/serverless/serverless.base.config.ts +++ b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/serverless/serverless.base.config.ts @@ -7,14 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { resolve, join } from 'path'; +import { resolve } from 'path'; import { format as formatUrl } from 'url'; import Fs from 'fs'; import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH, kibanaDevServiceAccount } from '@kbn/dev-utils'; import { - fleetPackageRegistryDockerImage, defineDockerServersConfig, + dockerRegistryPort, + packageRegistryDocker, } from '@kbn/test-docker-servers'; import { getDockerFileMountPath } from '@kbn/es'; import { @@ -29,17 +30,6 @@ import { REPO_ROOT } from '@kbn/repo-info'; import type { ScoutServerConfig } from '../../../../../types'; import { SAML_IDP_PLUGIN_PATH, SERVERLESS_IDP_METADATA_PATH, JWKS_PATH } from '../../../constants'; -const packageRegistryConfig = join(__dirname, './package_registry_config.yml'); -const dockerArgs: string[] = ['-v', `${packageRegistryConfig}:/package-registry/config.yml`]; - -/** - * This is used by CI to set the docker registry port - * you can also define this environment variable locally when running tests which - * will spin up a local docker package registry locally for you - * if this is defined it takes precedence over the `packageRegistryOverride` variable - */ -const dockerRegistryPort: string | undefined = process.env.FLEET_PACKAGE_REGISTRY_PORT; - // Indicates whether the config is used on CI or locally. const isRunOnCI = process.env.CI; @@ -66,14 +56,8 @@ export const defaultConfig: ScoutServerConfig = { servers, dockerServers: defineDockerServersConfig({ registry: { - enabled: !!dockerRegistryPort, - image: fleetPackageRegistryDockerImage, - portInContainer: 8080, - port: dockerRegistryPort, - args: dockerArgs, - waitForLogLine: 'package manifests loaded', + ...packageRegistryDocker, waitForLogLineTimeoutMs: 60 * 6 * 1000, // 6 minutes - preferCached: true, }, }), esTestCluster: { @@ -208,6 +192,9 @@ export const defaultConfig: ScoutServerConfig = { `--xpack.security.uiam.ssl.certificate=${KBN_CERT_PATH}`, `--xpack.security.uiam.ssl.key=${KBN_KEY_PATH}`, '--xpack.security.uiam.ssl.verificationMode=none', + ...(dockerRegistryPort + ? [`--xpack.fleet.registryUrl=http://localhost:${dockerRegistryPort}`] + : []), ], }, }; diff --git a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/stateful/base.config.ts b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/stateful/base.config.ts index a141f6dc26033..26f477680c24a 100644 --- a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/stateful/base.config.ts +++ b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/default/stateful/base.config.ts @@ -23,22 +23,12 @@ import { import { REPO_ROOT } from '@kbn/repo-info'; import { defineDockerServersConfig, - fleetPackageRegistryDockerImage, + dockerRegistryPort, + packageRegistryDocker, } from '@kbn/test-docker-servers'; import type { ScoutServerConfig } from '../../../../../types'; import { SAML_IDP_PLUGIN_PATH, STATEFUL_IDP_METADATA_PATH } from '../../../constants'; -const packageRegistryConfig = join(__dirname, './package_registry_config.yml'); -const dockerArgs: string[] = ['-v', `${packageRegistryConfig}:/package-registry/config.yml`]; - -/** - * This is used by CI to set the docker registry port - * you can also define this environment variable locally when running tests which - * will spin up a local docker package registry locally for you - * if this is defined it takes precedence over the `packageRegistryOverride` variable - */ -const dockerRegistryPort: string | undefined = process.env.FLEET_PACKAGE_REGISTRY_PORT; - // if config is executed on CI or locally const isRunOnCI = process.env.CI; @@ -65,14 +55,8 @@ export const defaultConfig: ScoutServerConfig = { servers, dockerServers: defineDockerServersConfig({ registry: { - enabled: !!dockerRegistryPort, - image: fleetPackageRegistryDockerImage, - portInContainer: 8080, - port: dockerRegistryPort, - args: dockerArgs, - waitForLogLine: 'package manifests loaded', - waitForLogLineTimeoutMs: 60 * 6 * 1000, // 6 minutes, - preferCached: true, + ...packageRegistryDocker, + waitForLogLineTimeoutMs: 60 * 6 * 1000, // 6 minutes }, }), esTestCluster: { @@ -205,7 +189,9 @@ export const defaultConfig: ScoutServerConfig = { ], }, ])}`, - // Agent policies are now created via Fleet API using the helper function from @kbn-scout + ...(dockerRegistryPort + ? [`--xpack.fleet.registryUrl=http://localhost:${dockerRegistryPort}`] + : []), // SAML configuration ...(isRunOnCI ? [] : ['--mockIdpPlugin.enabled=true']), // This ensures that we register the Security SAML API endpoints. diff --git a/src/platform/packages/shared/kbn-scout/src/servers/index.ts b/src/platform/packages/shared/kbn-scout/src/servers/index.ts index cd01082a8cc72..fe6c666e3b343 100644 --- a/src/platform/packages/shared/kbn-scout/src/servers/index.ts +++ b/src/platform/packages/shared/kbn-scout/src/servers/index.ts @@ -11,6 +11,7 @@ export { parseServerFlags, SERVER_FLAG_OPTIONS } from './flags'; export { startServers } from './start_servers'; export { runKibanaServer } from './run_kibana_server'; export { runElasticsearch } from './run_elasticsearch'; +export { startDockerServers } from './run_docker_servers'; export { preCreateSecurityIndexesViaSamlAuth } from './pre_create_security_indexes'; export type { StartServerOptions } from './flags'; diff --git a/src/platform/packages/shared/kbn-scout/src/servers/run_docker_servers.test.ts b/src/platform/packages/shared/kbn-scout/src/servers/run_docker_servers.test.ts new file mode 100644 index 0000000000000..48be177bd30f4 --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/servers/run_docker_servers.test.ts @@ -0,0 +1,244 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import execa from 'execa'; +import type { ToolingLog } from '@kbn/tooling-log'; +import { startDockerServers } from './run_docker_servers'; +import type { Config } from './configs'; + +jest.mock('execa'); + +const execaMock = execa as jest.MockedFunction; + +const createMockLog = (): jest.Mocked => + ({ + debug: jest.fn(), + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked); + +const createMockConfig = (dockerServers: Record): Config => + ({ + get: jest.fn((key: string) => { + if (key === 'dockerServers') return dockerServers; + throw new Error(`Unexpected config key: ${key}`); + }), + } as unknown as Config); + +const registrySpec = { + enabled: true, + image: 'docker.elastic.co/kibana-ci/package-registry-distribution:lite', + portInContainer: 8080, + port: 6104, + args: ['-v', '/some/path:/package-registry/config.yml'], + waitForLogLine: 'package manifests loaded', + waitForLogLineTimeoutMs: 5000, + preferCached: true, +}; + +interface MockProc { + stdout: { on: jest.Mock }; + stderr: { on: jest.Mock }; + kill: jest.Mock; + catch: jest.Mock; +} + +const createMockLogsProcess = (logLine: string): MockProc => { + const proc: MockProc = { + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + kill: jest.fn(), + catch: jest.fn(), + }; + proc.catch.mockReturnValue(proc); + proc.stdout.on.mockImplementation((_event: string, handler: (data: Buffer) => void) => { + process.nextTick(() => handler(Buffer.from(logLine))); + }); + return proc; +}; + +describe('startDockerServers', () => { + let log: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + log = createMockLog(); + }); + + it('returns a no-op shutdown when no docker servers are configured', async () => { + const config = createMockConfig({}); + const shutdown = await startDockerServers(config, log); + + expect(log.debug).toHaveBeenCalledWith('scout: no docker servers enabled, skipping'); + expect(execaMock).not.toHaveBeenCalled(); + + await shutdown(); + }); + + it('returns a no-op shutdown when all docker servers are disabled', async () => { + const config = createMockConfig({ + registry: { ...registrySpec, enabled: false }, + }); + const shutdown = await startDockerServers(config, log); + + expect(log.debug).toHaveBeenCalledWith('scout: no docker servers enabled, skipping'); + expect(execaMock).not.toHaveBeenCalled(); + + await shutdown(); + }); + + it('skips pull when preferCached is true and image exists locally', async () => { + execaMock.mockImplementation(((cmd: string, args: string[]) => { + const command = `${cmd} ${args[0]}`; + if (command === 'docker images') { + return Promise.resolve({ stdout: 'abc123\n' }); + } + if (command === 'docker run') { + return Promise.resolve({ stdout: 'container-id-abc123' }); + } + if (command === 'docker logs') { + return createMockLogsProcess('package manifests loaded'); + } + return Promise.resolve({ stdout: '' }); + }) as any); + + const config = createMockConfig({ registry: registrySpec }); + const shutdown = await startDockerServers(config, log); + + expect(log.info).toHaveBeenCalledWith(expect.stringContaining('skipping pull')); + expect(execaMock).not.toHaveBeenCalledWith('docker', ['pull', registrySpec.image]); + + await shutdown(); + }); + + it('pulls image when it is not cached locally', async () => { + execaMock.mockImplementation(((cmd: string, args: string[]) => { + const command = `${cmd} ${args[0]}`; + if (command === 'docker images') { + return Promise.resolve({ stdout: '' }); + } + if (command === 'docker pull') { + return Promise.resolve({ stdout: '' }); + } + if (command === 'docker run') { + return Promise.resolve({ stdout: 'container-id-abc123' }); + } + if (command === 'docker logs') { + return createMockLogsProcess('package manifests loaded'); + } + return Promise.resolve({ stdout: '' }); + }) as any); + + const config = createMockConfig({ registry: registrySpec }); + await startDockerServers(config, log); + + expect(execaMock).toHaveBeenCalledWith('docker', ['pull', registrySpec.image]); + }); + + it('runs container with correct docker args', async () => { + execaMock.mockImplementation(((cmd: string, args: string[]) => { + const command = `${cmd} ${args[0]}`; + if (command === 'docker images') { + return Promise.resolve({ stdout: 'cached' }); + } + if (command === 'docker run') { + return Promise.resolve({ stdout: 'container-id-xyz789' }); + } + if (command === 'docker logs') { + return createMockLogsProcess('package manifests loaded'); + } + return Promise.resolve({ stdout: '' }); + }) as any); + + const config = createMockConfig({ registry: registrySpec }); + await startDockerServers(config, log); + + expect(execaMock).toHaveBeenCalledWith('docker', [ + 'run', + '-dit', + ...registrySpec.args, + '-p', + `${registrySpec.port}:${registrySpec.portInContainer}`, + registrySpec.image, + ]); + }); + + it('wraps port conflict error with helpful message', async () => { + execaMock.mockImplementation(((cmd: string, args: string[]) => { + const command = `${cmd} ${args[0]}`; + if (command === 'docker images') { + return Promise.resolve({ stdout: 'cached' }); + } + if (command === 'docker run') { + const error: any = new Error('port is already allocated'); + error.exitCode = 125; + return Promise.reject(error); + } + return Promise.resolve({ stdout: '' }); + }) as any); + + const config = createMockConfig({ registry: registrySpec }); + + await expect(startDockerServers(config, log)).rejects.toThrow(/port 6104 is already in use/); + }); + + it('shutdown function kills the container', async () => { + execaMock.mockImplementation(((cmd: string, args: string[]) => { + const command = `${cmd} ${args[0]}`; + if (command === 'docker images') { + return Promise.resolve({ stdout: 'cached' }); + } + if (command === 'docker run') { + return Promise.resolve({ stdout: 'container-id-shutdown-test' }); + } + if (command === 'docker logs') { + return createMockLogsProcess('package manifests loaded'); + } + if (command === 'docker kill') { + return Promise.resolve({ stdout: '' }); + } + if (command === 'docker rm') { + return Promise.resolve({ stdout: '' }); + } + return Promise.resolve({ stdout: '' }); + }) as any); + + const config = createMockConfig({ registry: registrySpec }); + const shutdown = await startDockerServers(config, log); + + await shutdown(); + + expect(execaMock).toHaveBeenCalledWith('docker', ['kill', 'container-id-shutdown-test']); + }); + + it('shutdown gracefully handles already-stopped containers', async () => { + execaMock.mockImplementation(((cmd: string, args: string[]) => { + const command = `${cmd} ${args[0]}`; + if (command === 'docker images') { + return Promise.resolve({ stdout: 'cached' }); + } + if (command === 'docker run') { + return Promise.resolve({ stdout: 'container-gone' }); + } + if (command === 'docker logs') { + return createMockLogsProcess('package manifests loaded'); + } + if (command === 'docker kill') { + return Promise.reject(new Error('No such container: container-gone')); + } + return Promise.resolve({ stdout: '' }); + }) as any); + + const config = createMockConfig({ registry: registrySpec }); + const shutdown = await startDockerServers(config, log); + + await expect(shutdown()).resolves.toBeUndefined(); + }); +}); diff --git a/src/platform/packages/shared/kbn-scout/src/servers/run_docker_servers.ts b/src/platform/packages/shared/kbn-scout/src/servers/run_docker_servers.ts new file mode 100644 index 0000000000000..10348c3d11200 --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/servers/run_docker_servers.ts @@ -0,0 +1,182 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import execa from 'execa'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { DockerServerSpec } from '@kbn/test-docker-servers'; +import type { Config } from './configs'; + +interface RunningContainer { + name: string; + containerId: string; +} + +async function isImageAvailableLocally(image: string): Promise { + try { + const { stdout } = await execa('docker', ['images', '-q', image]); + return stdout.trim().length > 0; + } catch { + return false; + } +} + +async function pullImage(name: string, spec: DockerServerSpec, log: ToolingLog): Promise { + if (spec.preferCached && (await isImageAvailableLocally(spec.image))) { + log.info(`[docker:${name}] skipping pull, image "${spec.image}" is cached locally`); + return; + } + + log.info(`[docker:${name}] pulling docker image "${spec.image}"`); + await execa('docker', ['pull', spec.image]); +} + +async function runContainer( + name: string, + spec: DockerServerSpec, + log: ToolingLog +): Promise { + log.info(`[docker:${name}] starting container from "${spec.image}"`); + + try { + const dockerArgs = [ + 'run', + '-dit', + ...(spec.args || []), + '-p', + `${spec.port}:${spec.portInContainer}`, + spec.image, + ]; + + const { stdout } = await execa('docker', dockerArgs); + const containerId = stdout.trim(); + log.info(`[docker:${name}] container started: ${containerId.substring(0, 12)}`); + return containerId; + } catch (error: any) { + if (error?.exitCode === 125 && error?.message?.includes('port is already allocated')) { + throw new Error( + `[docker:${name}] port ${spec.port} is already in use. ` + + `Check for leftover containers with 'docker ps' and kill them with 'docker kill '.` + ); + } + throw error; + } +} + +async function waitForReady( + name: string, + containerId: string, + spec: DockerServerSpec, + log: ToolingLog +): Promise { + const { waitForLogLine, waitForLogLineTimeoutMs = 30_000 } = spec; + + if (!waitForLogLine) { + log.warning(`[docker:${name}] no waitForLogLine defined, skipping readiness check`); + return; + } + + const label = + waitForLogLine instanceof RegExp ? `/${waitForLogLine.source}/` : `"${waitForLogLine}"`; + log.info( + `[docker:${name}] waiting for log line ${label} (timeout: ${waitForLogLineTimeoutMs}ms)` + ); + + const startTime = Date.now(); + const logsProcess = execa('docker', ['logs', '-f', containerId]); + + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject( + new Error( + `[docker:${name}] timed out after ${waitForLogLineTimeoutMs}ms waiting for ${label}` + ) + ); + }, waitForLogLineTimeoutMs); + + const onData = (data: Buffer) => { + const line = data.toString(); + const matched = + waitForLogLine instanceof RegExp + ? waitForLogLine.test(line) + : line.includes(waitForLogLine); + + if (matched) { + clearTimeout(timeout); + log.info(`[docker:${name}] ready after ${Date.now() - startTime}ms`); + resolve(); + } + }; + + logsProcess.stdout?.on('data', onData); + logsProcess.stderr?.on('data', onData); + + logsProcess.catch((err) => { + clearTimeout(timeout); + reject(new Error(`[docker:${name}] container exited unexpectedly: ${err.message}`)); + }); + }); + } finally { + logsProcess.kill(); + } +} + +async function stopContainer(name: string, containerId: string, log: ToolingLog): Promise { + try { + log.info(`[docker:${name}] stopping container ${containerId.substring(0, 12)}`); + await execa('docker', ['kill', containerId]); + + if (!process.env.CI) { + await execa('docker', ['rm', containerId]); + } + } catch (error: any) { + if ( + error?.message?.includes('is not running') || + error?.message?.includes('No such container') + ) { + return; + } + throw error; + } +} + +/** + * Starts all enabled Docker servers defined in the Scout config. + * Returns a shutdown function that stops and removes the containers. + * + * Follows the same pattern as `runElasticsearch` -- caller is responsible + * for invoking the returned shutdown function in a `finally` block. + */ +export async function startDockerServers( + config: Config, + log: ToolingLog +): Promise<() => Promise> { + const serverConfigs: Record = config.get('dockerServers'); + const enabledServers = Object.entries(serverConfigs).filter(([, spec]) => spec.enabled); + + if (enabledServers.length === 0) { + log.debug('scout: no docker servers enabled, skipping'); + return async () => {}; + } + + const runningContainers: RunningContainer[] = []; + + for (const [name, spec] of enabledServers) { + await pullImage(name, spec, log); + const containerId = await runContainer(name, spec, log); + runningContainers.push({ name, containerId }); + await waitForReady(name, containerId, spec, log); + } + + return async () => { + for (const { name, containerId } of runningContainers) { + await stopContainer(name, containerId, log); + } + }; +} diff --git a/src/platform/packages/shared/kbn-scout/src/servers/start_servers.ts b/src/platform/packages/shared/kbn-scout/src/servers/start_servers.ts index d236d430d9a41..50ff2fa606dd7 100644 --- a/src/platform/packages/shared/kbn-scout/src/servers/start_servers.ts +++ b/src/platform/packages/shared/kbn-scout/src/servers/start_servers.ts @@ -17,6 +17,7 @@ import { getConfigRootDir, loadServersConfig } from './configs'; import type { StartServerOptions } from './flags'; import { preCreateSecurityIndexesViaSamlAuth } from './pre_create_security_indexes'; import { ensureDefaultSpaceNPRE } from './ensure_default_space_npre'; +import { startDockerServers } from './run_docker_servers'; import { runElasticsearch } from './run_elasticsearch'; import { getExtraKbnOpts, runKibanaServer } from './run_kibana_server'; @@ -46,6 +47,8 @@ export async function startServers(log: ToolingLog, options: StartServerOptions) await ensureDefaultSpaceNPRE(config, log); + const shutdownDockerServers = await startDockerServers(config, log); + await runKibanaServer({ procs, config, @@ -75,6 +78,7 @@ export async function startServers(log: ToolingLog, options: StartServerOptions) ); await procs.waitForAllToStop(); + await shutdownDockerServers(); await shutdownEs(); }); }