[Serverless/Connector] Fix bug with index name generation logic (#216293)

## Summary

Improve index name generation logic. Allow any ingex name. Use
combination of lodash kebabCase + some custom checks to safely map this
into valid ES index name.

Added unit tests

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Jedr Blaszyk 2025-03-31 15:06:10 +02:00 committed by GitHub
parent c7218a3fdb
commit 6adc005809
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 175 additions and 16 deletions

View file

@ -0,0 +1,129 @@
/*
* 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 { generateConnectorName, toValidIndexName } from './generate_connector_name';
import { indexOrAliasExists } from './exists_index';
import { MANAGED_CONNECTOR_INDEX_PREFIX } from '../constants';
jest.mock('./exists_index');
jest.mock('uuid', () => ({
v4: jest.fn(() => '12345678-abcd-1234-efgh-123456789012'),
}));
describe('generateConnectorName', () => {
const mockClient = {} as any;
beforeEach(() => {
jest.clearAllMocks();
// Default behavior: index doesn't exist
(indexOrAliasExists as jest.Mock).mockResolvedValue(false);
});
describe('toValidIndexName function', () => {
it('converts strings to valid index names', () => {
const testCases = [
{ input: 'Test String', expected: 'test-string' },
{ input: 'test/invalid*chars?', expected: 'test-invalid-chars' },
{ input: '_leadingUnderscore', expected: 'leading-underscore' },
{ input: 'camelCase', expected: 'camel-case' },
{ input: '< My Connector 1234!#$$>', expected: 'my-connector-1234' },
];
testCases.forEach(({ input, expected }) => {
expect(toValidIndexName(input)).toBe(expected);
});
});
});
describe('with provided connector name', () => {
it('uses original name for connector and sanitized name for index', async () => {
const result = await generateConnectorName(mockClient, 'test-type', false, 'My Connector!');
expect(result).toEqual({
connectorName: 'My Connector!',
indexName: 'connector-my-connector',
});
expect(indexOrAliasExists).toHaveBeenCalledWith(mockClient, 'connector-my-connector');
});
it('appends a suffix if index name already exists', async () => {
// First call: index exists, second call: index doesn't exist
(indexOrAliasExists as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
const result = await generateConnectorName(mockClient, 'test-type', false, 'My Connector!');
expect(result).toEqual({
connectorName: 'My Connector!',
indexName: 'connector-my-connector-abcd',
});
expect(indexOrAliasExists).toHaveBeenCalledTimes(2);
});
it('uses managed prefix for native connectors', async () => {
const result = await generateConnectorName(mockClient, 'test-type', true, 'My Connector!');
expect(result.indexName).toBe(`${MANAGED_CONNECTOR_INDEX_PREFIX}connector-my-connector`);
});
it('throws error after 20 failed attempts to generate unique name', async () => {
// Always return true (index exists) for all calls
(indexOrAliasExists as jest.Mock).mockResolvedValue(true);
await expect(
generateConnectorName(mockClient, 'test-type', false, 'My Connector!')
).rejects.toThrow('generate_index_name_error');
expect(indexOrAliasExists).toHaveBeenCalledTimes(21); // Initial check + 20 attempts
});
});
describe('without provided connector name', () => {
it('auto-generates connector and index names', async () => {
const result = await generateConnectorName(mockClient, 'testType', false);
expect(result).toEqual({
connectorName: 'testtype-abcd',
indexName: 'connector-testtype-abcd',
});
});
it('uses managed prefix for native connectors', async () => {
const result = await generateConnectorName(mockClient, 'testType', true);
expect(result.indexName).toBe(`${MANAGED_CONNECTOR_INDEX_PREFIX}connector-testtype-abcd`);
});
it('tries different suffixes if index name already exists', async () => {
(indexOrAliasExists as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
const result = await generateConnectorName(mockClient, 'testType', false);
expect(result.connectorName).toMatch(/testtype-/);
expect(indexOrAliasExists).toHaveBeenCalledTimes(2);
});
it('throws error if connectorType is empty', async () => {
await expect(generateConnectorName(mockClient, '', false)).rejects.toThrow(
'Connector type or connectorName is required'
);
});
it('throws error after 20 failed attempts to generate unique name', async () => {
(indexOrAliasExists as jest.Mock).mockResolvedValue(true);
await expect(generateConnectorName(mockClient, 'testType', false)).rejects.toThrow(
'generate_index_name_error'
);
expect(indexOrAliasExists).toHaveBeenCalledTimes(20);
});
});
});

View file

@ -9,6 +9,8 @@
import { v4 as uuidv4 } from 'uuid';
import { kebabCase } from 'lodash';
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { toAlphanumeric } from '../utils/to_alphanumeric';
@ -17,6 +19,26 @@ import { MANAGED_CONNECTOR_INDEX_PREFIX } from '../constants';
const GENERATE_INDEX_NAME_ERROR = 'generate_index_name_error';
export const toValidIndexName = (str: string): string => {
if (!str || str.trim() === '') {
return 'index';
}
// Start with kebabCase to handle most transformations
let result = kebabCase(str);
// Additional processing for ES index name requirements
result = result
// ES doesn't allow \, /, *, ?, ", <, >, |, comma, #, :
.replace(/[\\/*?"<>|,#:]/g, '-')
// Cannot start with -, _, +
.replace(/^[-_+]/, '')
// Remove trailing hyphens
.replace(/-+$/, '');
return result;
};
export const generateConnectorName = async (
client: ElasticsearchClient,
connectorType: string,
@ -30,37 +52,44 @@ export const generateConnectorName = async (
const nativePrefix = isNative ? MANAGED_CONNECTOR_INDEX_PREFIX : '';
// Handle user-provided connector name
if (userConnectorName) {
let indexName = `${nativePrefix}connector-${userConnectorName}`;
const resultSameName = await indexOrAliasExists(client, indexName);
// index with same name doesn't exist
if (!resultSameName) {
// Keep original connector name, but sanitize it for index name
const sanitizedName = toValidIndexName(userConnectorName);
// First try with the sanitized name directly
let indexName = `${nativePrefix}connector-${sanitizedName}`;
const baseNameExists = await indexOrAliasExists(client, indexName);
if (!baseNameExists) {
return {
connectorName: userConnectorName,
connectorName: userConnectorName, // Keep original connector name
indexName,
};
}
// if the index name already exists, we will generate until it doesn't for 20 times
for (let i = 0; i < 20; i++) {
indexName = `${nativePrefix}connector-${userConnectorName}-${uuidv4()
.split('-')[1]
.slice(0, 4)}`;
const result = await indexOrAliasExists(client, indexName);
if (!result) {
// If base name exists, try with random suffixes
for (let i = 0; i < 20; i++) {
const uniqueSuffix = uuidv4().split('-')[1].slice(0, 4);
indexName = `${nativePrefix}connector-${sanitizedName}-${uniqueSuffix}`;
const exists = await indexOrAliasExists(client, indexName);
if (!exists) {
return {
connectorName: userConnectorName,
connectorName: userConnectorName, // Keep original connector name
indexName,
};
}
}
} else {
// Auto-generate a connector name
for (let i = 0; i < 20; i++) {
const connectorName = `${prefix}-${uuidv4().split('-')[1].slice(0, 4)}`;
const uniqueSuffix = uuidv4().split('-')[1].slice(0, 4);
const connectorName = `${toValidIndexName(prefix)}-${uniqueSuffix}`;
const indexName = `${nativePrefix}connector-${connectorName}`;
const result = await indexOrAliasExists(client, indexName);
if (!result) {
const exists = await indexOrAliasExists(client, indexName);
if (!exists) {
return {
connectorName,
indexName,
@ -68,5 +97,6 @@ export const generateConnectorName = async (
}
}
}
throw new Error(GENERATE_INDEX_NAME_ERROR);
};