Make interactive setup work properly in Docker container. (#110629)

This commit is contained in:
Aleh Zasypkin 2021-09-07 10:22:58 +02:00 committed by GitHub
parent 9b41b3feae
commit 1eed669095
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 315 additions and 97 deletions

View file

@ -73,6 +73,8 @@ kibana_vars=(
enterpriseSearch.host
externalUrl.policy
i18n.locale
interactiveSetup.enabled
interactiveSetup.connectionCheck.interval
interpreter.enableInVisualize
kibana.autocompleteTerminateAfter
kibana.autocompleteTimeout

View file

@ -89,6 +89,7 @@ COPY --from=builder --chown=1000:0 /usr/share/kibana /usr/share/kibana
WORKDIR /usr/share/kibana
RUN ln -s /usr/share/kibana /opt/kibana
{{! Please notify @elastic/kibana-security if you want to remove or change this environment variable. }}
ENV ELASTIC_CONTAINER true
ENV PATH=/usr/share/kibana/bin:$PATH

View file

@ -51,6 +51,7 @@ COPY --from=prep_files --chown=1000:0 /usr/share/kibana /usr/share/kibana
WORKDIR /usr/share/kibana
RUN ln -s /usr/share/kibana /opt/kibana
{{! Please notify @elastic/kibana-security if you want to remove or change this environment variable. }}
ENV ELASTIC_CONTAINER true
ENV PATH=/usr/share/kibana/bin:$PATH

View file

@ -10,6 +10,8 @@ import dedent from 'dedent';
import { TemplateContext } from '../template_context';
// IMPORTANT: Please notify @elastic/kibana-security if you're changing any of the Docker specific
// configuration defaults. We rely on these defaults in the interactive setup mode.
function generator({ imageFlavor }: TemplateContext) {
return dedent(`
#

View file

@ -16,7 +16,7 @@ import { KibanaConfigWriter } from './kibana_config_writer';
describe('KibanaConfigWriter', () => {
let mockFsAccess: jest.Mock;
let mockWriteFile: jest.Mock;
let mockAppendFile: jest.Mock;
let mockReadFile: jest.Mock;
let kibanaConfigWriter: KibanaConfigWriter;
beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(1234);
@ -24,7 +24,9 @@ describe('KibanaConfigWriter', () => {
const fsMocks = jest.requireMock('fs/promises');
mockFsAccess = fsMocks.access;
mockWriteFile = fsMocks.writeFile;
mockAppendFile = fsMocks.appendFile;
mockReadFile = fsMocks.readFile;
mockReadFile.mockResolvedValue('');
kibanaConfigWriter = new KibanaConfigWriter(
'/some/path/kibana.yml',
@ -69,39 +71,42 @@ describe('KibanaConfigWriter', () => {
});
describe('#writeConfig()', () => {
it('throws if cannot write CA file', async () => {
mockWriteFile.mockRejectedValue(new Error('Oh no!'));
describe('without existing config', () => {
beforeEach(() => {
mockReadFile.mockResolvedValue('');
});
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: '',
serviceAccountToken: { name: '', value: '' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
it('throws if cannot write CA file', async () => {
mockWriteFile.mockRejectedValue(new Error('Oh no!'));
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockAppendFile).not.toHaveBeenCalled();
});
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: '',
serviceAccountToken: { name: '', value: '' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
it('throws if cannot append config to yaml file', async () => {
mockAppendFile.mockRejectedValue(new Error('Oh no!'));
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
});
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
it('throws if cannot write config to yaml file', async () => {
mockWriteFile.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('Oh no!'));
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockAppendFile).toHaveBeenCalledTimes(1);
expect(mockAppendFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
expect(mockWriteFile).toHaveBeenCalledTimes(2);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockWriteFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
@ -109,24 +114,55 @@ elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
`
);
});
);
});
it('can successfully write CA certificate and elasticsearch config with service token', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).resolves.toBeUndefined();
it('throws if cannot read existing config', async () => {
mockReadFile.mockRejectedValue(new Error('Oh no!'));
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockAppendFile).toHaveBeenCalledTimes(1);
expect(mockAppendFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('throws if cannot parse existing config', async () => {
mockReadFile.mockResolvedValue('foo: bar\nfoo: baz');
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).rejects.toMatchInlineSnapshot(`
[YAMLException: duplicated mapping key at line 2, column 1:
foo: baz
^]
`);
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('can successfully write CA certificate and elasticsearch config with service token', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).resolves.toBeUndefined();
expect(mockWriteFile).toHaveBeenCalledTimes(2);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockWriteFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
@ -134,25 +170,24 @@ elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
`
);
});
);
});
it('can successfully write CA certificate and elasticsearch config with credentials', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
username: 'username',
password: 'password',
})
).resolves.toBeUndefined();
it('can successfully write CA certificate and elasticsearch config with credentials', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
username: 'username',
password: 'password',
})
).resolves.toBeUndefined();
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockAppendFile).toHaveBeenCalledTimes(1);
expect(mockAppendFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
expect(mockWriteFile).toHaveBeenCalledTimes(2);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockWriteFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
@ -161,23 +196,22 @@ elasticsearch.username: username
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
`
);
});
);
});
it('can successfully write elasticsearch config without CA certificate', async () => {
await expect(
kibanaConfigWriter.writeConfig({
host: 'some-host',
username: 'username',
password: 'password',
})
).resolves.toBeUndefined();
it('can successfully write elasticsearch config without CA certificate', async () => {
await expect(
kibanaConfigWriter.writeConfig({
host: 'some-host',
username: 'username',
password: 'password',
})
).resolves.toBeUndefined();
expect(mockWriteFile).not.toHaveBeenCalled();
expect(mockAppendFile).toHaveBeenCalledTimes(1);
expect(mockAppendFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockWriteFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
@ -185,7 +219,106 @@ elasticsearch.password: password
elasticsearch.username: username
`
);
);
});
});
describe('with existing config (no conflicts)', () => {
beforeEach(() => {
mockReadFile.mockResolvedValue(
'# Default Kibana configuration for docker target\nserver.host: "0.0.0.0"\nserver.shutdownTimeout: "5s"'
);
});
it('can successfully write CA certificate and elasticsearch config', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).resolves.toBeUndefined();
expect(mockReadFile).toHaveBeenCalledTimes(1);
expect(mockReadFile).toHaveBeenCalledWith('/some/path/kibana.yml', 'utf-8');
expect(mockWriteFile).toHaveBeenCalledTimes(2);
expect(mockWriteFile.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/some/path/ca_1234.crt",
"ca-content",
],
Array [
"/some/path/kibana.yml",
"# Default Kibana configuration for docker target
server.host: \\"0.0.0.0\\"
server.shutdownTimeout: \\"5s\\"
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
",
],
]
`);
});
});
describe('with existing config (with conflicts)', () => {
beforeEach(() => {
jest.spyOn(Date.prototype, 'toISOString').mockReturnValue('some date');
mockReadFile.mockResolvedValue(
'# Default Kibana configuration for docker target\nserver.host: "0.0.0.0"\nserver.shutdownTimeout: "5s"\nelasticsearch.hosts: [ "http://elasticsearch:9200" ]\n\nmonitoring.ui.container.elasticsearch.enabled: true'
);
});
it('can successfully write CA certificate and elasticsearch config', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).resolves.toBeUndefined();
expect(mockReadFile).toHaveBeenCalledTimes(1);
expect(mockReadFile).toHaveBeenCalledWith('/some/path/kibana.yml', 'utf-8');
expect(mockWriteFile).toHaveBeenCalledTimes(2);
expect(mockWriteFile.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/some/path/ca_1234.crt",
"ca-content",
],
Array [
"/some/path/kibana.yml",
"### >>>>>>> BACKUP START: Kibana interactive setup (some date)
# Default Kibana configuration for docker target
#server.host: \\"0.0.0.0\\"
#server.shutdownTimeout: \\"5s\\"
#elasticsearch.hosts: [ \\"http://elasticsearch:9200\\" ]
#monitoring.ui.container.elasticsearch.enabled: true
### >>>>>>> BACKUP END: Kibana interactive setup (some date)
# This section was automatically generated during setup.
server.host: 0.0.0.0
server.shutdownTimeout: 5s
elasticsearch.hosts: [some-host]
monitoring.ui.container.elasticsearch.enabled: true
elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
",
],
]
`);
});
});
});
});

View file

@ -11,6 +11,7 @@ import fs from 'fs/promises';
import yaml from 'js-yaml';
import path from 'path';
import { getFlattenedObject } from '@kbn/std';
import type { Logger } from 'src/core/server';
import { getDetailedErrorMessage } from './errors';
@ -61,6 +62,45 @@ export class KibanaConfigWriter {
*/
public async writeConfig(params: WriteConfigParameters) {
const caPath = path.join(path.dirname(this.configPath), `ca_${Date.now()}.crt`);
const config: Record<string, string | string[]> = { 'elasticsearch.hosts': [params.host] };
if ('serviceAccountToken' in params) {
config['elasticsearch.serviceAccountToken'] = params.serviceAccountToken.value;
} else if ('username' in params) {
config['elasticsearch.password'] = params.password;
config['elasticsearch.username'] = params.username;
}
if (params.caCert) {
config['elasticsearch.ssl.certificateAuthorities'] = [caPath];
}
// Load and parse existing configuration file to check if it already has values for the config
// entries we want to write.
const existingConfig = await this.loadAndParseKibanaConfig();
const conflictingKeys = Object.keys(config).filter(
(configKey) => configKey in existingConfig.parsed
);
// If existing config has conflicting entries, back it up first.
let configToWrite;
if (conflictingKeys.length > 0) {
this.logger.warn(
`Kibana configuration file has the following conflicting keys that will be overridden: [${conflictingKeys.join(
', '
)}].`
);
const existingCommentedConfig = KibanaConfigWriter.commentOutKibanaConfig(existingConfig.raw);
configToWrite = `${existingCommentedConfig}\n\n# This section was automatically generated during setup.\n${yaml.safeDump(
{ ...existingConfig.parsed, ...config },
{ flowLevel: 1 }
)}\n`;
} else {
configToWrite = `${
existingConfig.raw
}\n\n# This section was automatically generated during setup.\n${yaml.safeDump(config, {
flowLevel: 1,
})}\n`;
}
if (params.caCert) {
this.logger.debug(`Writing CA certificate to ${caPath}.`);
@ -75,25 +115,9 @@ export class KibanaConfigWriter {
}
}
const config: Record<string, string | string[]> = { 'elasticsearch.hosts': [params.host] };
if ('serviceAccountToken' in params) {
config['elasticsearch.serviceAccountToken'] = params.serviceAccountToken.value;
} else if ('username' in params) {
config['elasticsearch.password'] = params.password;
config['elasticsearch.username'] = params.username;
}
if (params.caCert) {
config['elasticsearch.ssl.certificateAuthorities'] = [caPath];
}
this.logger.debug(`Writing Elasticsearch configuration to ${this.configPath}.`);
try {
await fs.appendFile(
this.configPath,
`\n\n# This section was automatically generated during setup.\n${yaml.safeDump(config, {
flowLevel: 1,
})}\n`
);
await fs.writeFile(this.configPath, configToWrite);
this.logger.debug(`Successfully wrote Elasticsearch configuration to ${this.configPath}.`);
} catch (err) {
this.logger.error(
@ -101,7 +125,55 @@ export class KibanaConfigWriter {
this.configPath
}: ${getDetailedErrorMessage(err)}.`
);
throw err;
}
}
/**
* Loads and parses existing Kibana configuration file.
*/
private async loadAndParseKibanaConfig() {
let rawConfig: string;
try {
rawConfig = await fs.readFile(this.configPath, 'utf-8');
} catch (err) {
this.logger.error(`Failed to read configuration file: ${getDetailedErrorMessage(err)}.`);
throw err;
}
let parsedConfig: Record<string, unknown>;
try {
parsedConfig = getFlattenedObject(yaml.safeLoad(rawConfig) ?? {});
} catch (err) {
this.logger.error(`Failed to parse configuration file: ${getDetailedErrorMessage(err)}.`);
throw err;
}
return { raw: rawConfig, parsed: parsedConfig };
}
/**
* Comments out all non-commented entries in the Kibana configuration file.
* @param rawConfig Content of the Kibana configuration file.
*/
private static commentOutKibanaConfig(rawConfig: string) {
const backupTimestamp = new Date().toISOString();
const commentedRawConfigLines = [
`### >>>>>>> BACKUP START: Kibana interactive setup (${backupTimestamp})\n`,
];
for (const rawConfigLine of rawConfig.split('\n')) {
const trimmedLine = rawConfigLine.trim();
commentedRawConfigLines.push(
trimmedLine.length === 0 || trimmedLine.startsWith('#')
? rawConfigLine
: `#${rawConfigLine}`
);
}
return [
...commentedRawConfigLines,
`### >>>>>>> BACKUP END: Kibana interactive setup (${backupTimestamp})`,
].join('\n');
}
}

View file

@ -19,6 +19,13 @@ import { KibanaConfigWriter } from './kibana_config_writer';
import { defineRoutes } from './routes';
import { VerificationCode } from './verification_code';
// List of the Elasticsearch hosts Kibana uses by default.
const DEFAULT_ELASTICSEARCH_HOSTS = [
'http://localhost:9200',
// It's a default host we use in the official Kibana Docker image (see `kibana_yml.template.ts`).
...(process.env.ELASTIC_CONTAINER ? ['http://elasticsearch:9200'] : []),
];
export class InteractiveSetupPlugin implements PrebootPlugin {
readonly #logger: Logger;
readonly #elasticsearch: ElasticsearchService;
@ -58,7 +65,7 @@ export class InteractiveSetupPlugin implements PrebootPlugin {
const shouldActiveSetupMode =
!core.elasticsearch.config.credentialsSpecified &&
core.elasticsearch.config.hosts.length === 1 &&
core.elasticsearch.config.hosts[0] === 'http://localhost:9200';
DEFAULT_ELASTICSEARCH_HOSTS.includes(core.elasticsearch.config.hosts[0]);
if (!shouldActiveSetupMode) {
this.#logger.debug(
'Interactive setup mode will not be activated since Elasticsearch connection is already configured.'