[Ingest Manager] Implement concurrency control for package configs (#70680)
* Send SO version field as part of package configs, enforce it during package config update * Fix typings, extend response error to include optional status code * Revert unnecessary version fields in tests, fix schema Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
f28d4e920e
commit
cbd39d98a6
10 changed files with 86 additions and 30 deletions
|
@ -55,9 +55,14 @@ export interface NewPackageConfig {
|
||||||
inputs: NewPackageConfigInput[];
|
inputs: NewPackageConfigInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdatePackageConfig extends NewPackageConfig {
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PackageConfig extends Omit<NewPackageConfig, 'inputs'> {
|
export interface PackageConfig extends Omit<NewPackageConfig, 'inputs'> {
|
||||||
id: string;
|
id: string;
|
||||||
inputs: PackageConfigInput[];
|
inputs: PackageConfigInput[];
|
||||||
|
version?: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
updated_by: string;
|
updated_by: string;
|
||||||
|
@ -65,4 +70,4 @@ export interface PackageConfig extends Omit<NewPackageConfig, 'inputs'> {
|
||||||
created_by: string;
|
created_by: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PackageConfigSOAttributes = Omit<PackageConfig, 'id'>;
|
export type PackageConfigSOAttributes = Omit<PackageConfig, 'id' | 'version'>;
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* or more contributor license agreements. Licensed under the Elastic License;
|
* or more contributor license agreements. Licensed under the Elastic License;
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
import { PackageConfig, NewPackageConfig } from '../models';
|
import { PackageConfig, NewPackageConfig, UpdatePackageConfig } from '../models';
|
||||||
|
|
||||||
export interface GetPackageConfigsRequest {
|
export interface GetPackageConfigsRequest {
|
||||||
query: {
|
query: {
|
||||||
|
@ -42,7 +42,7 @@ export interface CreatePackageConfigResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdatePackageConfigRequest = GetOnePackageConfigRequest & {
|
export type UpdatePackageConfigRequest = GetOnePackageConfigRequest & {
|
||||||
body: NewPackageConfig;
|
body: UpdatePackageConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdatePackageConfigResponse = CreatePackageConfigResponse;
|
export type UpdatePackageConfigResponse = CreatePackageConfigResponse;
|
||||||
|
|
|
@ -17,33 +17,39 @@ let httpClient: HttpSetup;
|
||||||
|
|
||||||
export type UseRequestConfig = _UseRequestConfig;
|
export type UseRequestConfig = _UseRequestConfig;
|
||||||
|
|
||||||
|
interface RequestError extends Error {
|
||||||
|
statusCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const setHttpClient = (client: HttpSetup) => {
|
export const setHttpClient = (client: HttpSetup) => {
|
||||||
httpClient = client;
|
httpClient = client;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendRequest = <D = any>(
|
export const sendRequest = <D = any, E = RequestError>(
|
||||||
config: SendRequestConfig
|
config: SendRequestConfig
|
||||||
): Promise<SendRequestResponse<D>> => {
|
): Promise<SendRequestResponse<D, E>> => {
|
||||||
if (!httpClient) {
|
if (!httpClient) {
|
||||||
throw new Error('sendRequest has no http client set');
|
throw new Error('sendRequest has no http client set');
|
||||||
}
|
}
|
||||||
return _sendRequest<D>(httpClient, config);
|
return _sendRequest<D, E>(httpClient, config);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRequest = <D = any>(config: UseRequestConfig) => {
|
export const useRequest = <D = any, E = RequestError>(config: UseRequestConfig) => {
|
||||||
if (!httpClient) {
|
if (!httpClient) {
|
||||||
throw new Error('sendRequest has no http client set');
|
throw new Error('sendRequest has no http client set');
|
||||||
}
|
}
|
||||||
return _useRequest<D>(httpClient, config);
|
return _useRequest<D, E>(httpClient, config);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SendConditionalRequestConfig =
|
export type SendConditionalRequestConfig =
|
||||||
| (SendRequestConfig & { shouldSendRequest: true })
|
| (SendRequestConfig & { shouldSendRequest: true })
|
||||||
| (Partial<SendRequestConfig> & { shouldSendRequest: false });
|
| (Partial<SendRequestConfig> & { shouldSendRequest: false });
|
||||||
|
|
||||||
export const useConditionalRequest = <D = any>(config: SendConditionalRequestConfig) => {
|
export const useConditionalRequest = <D = any, E = RequestError>(
|
||||||
|
config: SendConditionalRequestConfig
|
||||||
|
) => {
|
||||||
const [state, setState] = useState<{
|
const [state, setState] = useState<{
|
||||||
error: Error | null;
|
error: RequestError | null;
|
||||||
data: D | null;
|
data: D | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}>({
|
}>({
|
||||||
|
@ -70,7 +76,7 @@ export const useConditionalRequest = <D = any>(config: SendConditionalRequestCon
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
const res = await sendRequest<D>({
|
const res = await sendRequest<D, E>({
|
||||||
method: config.method,
|
method: config.method,
|
||||||
path: config.path,
|
path: config.path,
|
||||||
query: config.query,
|
query: config.query,
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {
|
||||||
EuiFlexItem,
|
EuiFlexItem,
|
||||||
EuiSpacer,
|
EuiSpacer,
|
||||||
} from '@elastic/eui';
|
} from '@elastic/eui';
|
||||||
import { AgentConfig, PackageInfo, NewPackageConfig } from '../../../types';
|
import { AgentConfig, PackageInfo, UpdatePackageConfig } from '../../../types';
|
||||||
import {
|
import {
|
||||||
useLink,
|
useLink,
|
||||||
useBreadcrumbs,
|
useBreadcrumbs,
|
||||||
|
@ -72,7 +72,7 @@ export const EditPackageConfigPage: React.FunctionComponent = () => {
|
||||||
const [loadingError, setLoadingError] = useState<Error>();
|
const [loadingError, setLoadingError] = useState<Error>();
|
||||||
const [agentConfig, setAgentConfig] = useState<AgentConfig>();
|
const [agentConfig, setAgentConfig] = useState<AgentConfig>();
|
||||||
const [packageInfo, setPackageInfo] = useState<PackageInfo>();
|
const [packageInfo, setPackageInfo] = useState<PackageInfo>();
|
||||||
const [packageConfig, setPackageConfig] = useState<NewPackageConfig>({
|
const [packageConfig, setPackageConfig] = useState<UpdatePackageConfig>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
namespace: '',
|
namespace: '',
|
||||||
|
@ -80,6 +80,7 @@ export const EditPackageConfigPage: React.FunctionComponent = () => {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
output_id: '',
|
output_id: '',
|
||||||
inputs: [],
|
inputs: [],
|
||||||
|
version: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Retrieve agent config, package, and package config info
|
// Retrieve agent config, package, and package config info
|
||||||
|
@ -160,7 +161,7 @@ export const EditPackageConfigPage: React.FunctionComponent = () => {
|
||||||
const hasErrors = validationResults ? validationHasErrors(validationResults) : false;
|
const hasErrors = validationResults ? validationHasErrors(validationResults) : false;
|
||||||
|
|
||||||
// Update package config method
|
// Update package config method
|
||||||
const updatePackageConfig = (updatedFields: Partial<NewPackageConfig>) => {
|
const updatePackageConfig = (updatedFields: Partial<UpdatePackageConfig>) => {
|
||||||
const newPackageConfig = {
|
const newPackageConfig = {
|
||||||
...packageConfig,
|
...packageConfig,
|
||||||
...updatedFields,
|
...updatedFields,
|
||||||
|
@ -178,7 +179,7 @@ export const EditPackageConfigPage: React.FunctionComponent = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePackageConfigValidation = (newPackageConfig?: NewPackageConfig) => {
|
const updatePackageConfigValidation = (newPackageConfig?: UpdatePackageConfig) => {
|
||||||
if (packageInfo) {
|
if (packageInfo) {
|
||||||
const newValidationResult = validatePackageConfig(
|
const newValidationResult = validatePackageConfig(
|
||||||
newPackageConfig || packageConfig,
|
newPackageConfig || packageConfig,
|
||||||
|
@ -234,9 +235,31 @@ export const EditPackageConfigPage: React.FunctionComponent = () => {
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
notifications.toasts.addError(error, {
|
if (error.statusCode === 409) {
|
||||||
title: 'Error',
|
notifications.toasts.addError(error, {
|
||||||
});
|
title: i18n.translate('xpack.ingestManager.editPackageConfig.failedNotificationTitle', {
|
||||||
|
defaultMessage: `Error updating '{packageConfigName}'`,
|
||||||
|
values: {
|
||||||
|
packageConfigName: packageConfig.name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
toastMessage: i18n.translate(
|
||||||
|
'xpack.ingestManager.editPackageConfig.failedConflictNotificationMessage',
|
||||||
|
{
|
||||||
|
defaultMessage: `Data is out of date. Refresh the page to get the latest configuration.`,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifications.toasts.addError(error, {
|
||||||
|
title: i18n.translate('xpack.ingestManager.editPackageConfig.failedNotificationTitle', {
|
||||||
|
defaultMessage: `Error updating '{packageConfigName}'`,
|
||||||
|
values: {
|
||||||
|
packageConfigName: packageConfig.name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
setFormState('VALID');
|
setFormState('VALID');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,6 +15,7 @@ export {
|
||||||
EnrollmentAPIKey,
|
EnrollmentAPIKey,
|
||||||
PackageConfig,
|
PackageConfig,
|
||||||
NewPackageConfig,
|
NewPackageConfig,
|
||||||
|
UpdatePackageConfig,
|
||||||
PackageConfigInput,
|
PackageConfigInput,
|
||||||
PackageConfigInputStream,
|
PackageConfigInputStream,
|
||||||
PackageConfigConfigRecordEntry,
|
PackageConfigConfigRecordEntry,
|
||||||
|
|
|
@ -178,7 +178,7 @@ export const updatePackageConfigHandler: RequestHandler<
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return response.customError({
|
return response.customError({
|
||||||
statusCode: 500,
|
statusCode: e.statusCode || 500,
|
||||||
body: { message: e.message },
|
body: { message: e.message },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
import { PACKAGE_CONFIG_SAVED_OBJECT_TYPE } from '../constants';
|
import { PACKAGE_CONFIG_SAVED_OBJECT_TYPE } from '../constants';
|
||||||
import {
|
import {
|
||||||
NewPackageConfig,
|
NewPackageConfig,
|
||||||
|
UpdatePackageConfig,
|
||||||
PackageConfig,
|
PackageConfig,
|
||||||
ListWithKuery,
|
ListWithKuery,
|
||||||
PackageConfigSOAttributes,
|
PackageConfigSOAttributes,
|
||||||
|
@ -60,6 +61,7 @@ class PackageConfigService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: newSo.id,
|
id: newSo.id,
|
||||||
|
version: newSo.version,
|
||||||
...newSo.attributes,
|
...newSo.attributes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -71,7 +73,7 @@ class PackageConfigService {
|
||||||
options?: { user?: AuthenticatedUser }
|
options?: { user?: AuthenticatedUser }
|
||||||
): Promise<PackageConfig[]> {
|
): Promise<PackageConfig[]> {
|
||||||
const isoDate = new Date().toISOString();
|
const isoDate = new Date().toISOString();
|
||||||
const { saved_objects: newSos } = await soClient.bulkCreate<Omit<PackageConfig, 'id'>>(
|
const { saved_objects: newSos } = await soClient.bulkCreate<PackageConfigSOAttributes>(
|
||||||
packageConfigs.map((packageConfig) => ({
|
packageConfigs.map((packageConfig) => ({
|
||||||
type: SAVED_OBJECT_TYPE,
|
type: SAVED_OBJECT_TYPE,
|
||||||
attributes: {
|
attributes: {
|
||||||
|
@ -98,6 +100,7 @@ class PackageConfigService {
|
||||||
|
|
||||||
return newSos.map((newSo) => ({
|
return newSos.map((newSo) => ({
|
||||||
id: newSo.id,
|
id: newSo.id,
|
||||||
|
version: newSo.version,
|
||||||
...newSo.attributes,
|
...newSo.attributes,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -117,6 +120,7 @@ class PackageConfigService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: packageConfigSO.id,
|
id: packageConfigSO.id,
|
||||||
|
version: packageConfigSO.version,
|
||||||
...packageConfigSO.attributes,
|
...packageConfigSO.attributes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -137,6 +141,7 @@ class PackageConfigService {
|
||||||
|
|
||||||
return packageConfigSO.saved_objects.map((so) => ({
|
return packageConfigSO.saved_objects.map((so) => ({
|
||||||
id: so.id,
|
id: so.id,
|
||||||
|
version: so.version,
|
||||||
...so.attributes,
|
...so.attributes,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -163,8 +168,9 @@ class PackageConfigService {
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: packageConfigs.saved_objects.map<PackageConfig>((packageConfigSO) => ({
|
items: packageConfigs.saved_objects.map((packageConfigSO) => ({
|
||||||
id: packageConfigSO.id,
|
id: packageConfigSO.id,
|
||||||
|
version: packageConfigSO.version,
|
||||||
...packageConfigSO.attributes,
|
...packageConfigSO.attributes,
|
||||||
})),
|
})),
|
||||||
total: packageConfigs.total,
|
total: packageConfigs.total,
|
||||||
|
@ -176,21 +182,29 @@ class PackageConfigService {
|
||||||
public async update(
|
public async update(
|
||||||
soClient: SavedObjectsClientContract,
|
soClient: SavedObjectsClientContract,
|
||||||
id: string,
|
id: string,
|
||||||
packageConfig: NewPackageConfig,
|
packageConfig: UpdatePackageConfig,
|
||||||
options?: { user?: AuthenticatedUser }
|
options?: { user?: AuthenticatedUser }
|
||||||
): Promise<PackageConfig> {
|
): Promise<PackageConfig> {
|
||||||
const oldPackageConfig = await this.get(soClient, id);
|
const oldPackageConfig = await this.get(soClient, id);
|
||||||
|
const { version, ...restOfPackageConfig } = packageConfig;
|
||||||
|
|
||||||
if (!oldPackageConfig) {
|
if (!oldPackageConfig) {
|
||||||
throw new Error('Package config not found');
|
throw new Error('Package config not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await soClient.update<PackageConfigSOAttributes>(SAVED_OBJECT_TYPE, id, {
|
await soClient.update<PackageConfigSOAttributes>(
|
||||||
...packageConfig,
|
SAVED_OBJECT_TYPE,
|
||||||
revision: oldPackageConfig.revision + 1,
|
id,
|
||||||
updated_at: new Date().toISOString(),
|
{
|
||||||
updated_by: options?.user?.username ?? 'system',
|
...restOfPackageConfig,
|
||||||
});
|
revision: oldPackageConfig.revision + 1,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
updated_by: options?.user?.username ?? 'system',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Bump revision of associated agent config
|
// Bump revision of associated agent config
|
||||||
await agentConfigService.bumpRevision(soClient, packageConfig.config_id, {
|
await agentConfigService.bumpRevision(soClient, packageConfig.config_id, {
|
||||||
|
|
|
@ -21,6 +21,7 @@ export {
|
||||||
PackageConfigInput,
|
PackageConfigInput,
|
||||||
PackageConfigInputStream,
|
PackageConfigInputStream,
|
||||||
NewPackageConfig,
|
NewPackageConfig,
|
||||||
|
UpdatePackageConfig,
|
||||||
PackageConfigSOAttributes,
|
PackageConfigSOAttributes,
|
||||||
FullAgentConfigInput,
|
FullAgentConfigInput,
|
||||||
FullAgentConfig,
|
FullAgentConfig,
|
||||||
|
|
|
@ -66,7 +66,13 @@ export const NewPackageConfigSchema = schema.object({
|
||||||
...PackageConfigBaseSchema,
|
...PackageConfigBaseSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const UpdatePackageConfigSchema = schema.object({
|
||||||
|
...PackageConfigBaseSchema,
|
||||||
|
version: schema.maybe(schema.string()),
|
||||||
|
});
|
||||||
|
|
||||||
export const PackageConfigSchema = schema.object({
|
export const PackageConfigSchema = schema.object({
|
||||||
...PackageConfigBaseSchema,
|
...PackageConfigBaseSchema,
|
||||||
id: schema.string(),
|
id: schema.string(),
|
||||||
|
version: schema.maybe(schema.string()),
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
import { schema } from '@kbn/config-schema';
|
import { schema } from '@kbn/config-schema';
|
||||||
import { NewPackageConfigSchema } from '../models';
|
import { NewPackageConfigSchema, UpdatePackageConfigSchema } from '../models';
|
||||||
import { ListWithKuerySchema } from './index';
|
import { ListWithKuerySchema } from './index';
|
||||||
|
|
||||||
export const GetPackageConfigsRequestSchema = {
|
export const GetPackageConfigsRequestSchema = {
|
||||||
|
@ -23,7 +23,7 @@ export const CreatePackageConfigRequestSchema = {
|
||||||
|
|
||||||
export const UpdatePackageConfigRequestSchema = {
|
export const UpdatePackageConfigRequestSchema = {
|
||||||
...GetOnePackageConfigRequestSchema,
|
...GetOnePackageConfigRequestSchema,
|
||||||
body: NewPackageConfigSchema,
|
body: UpdatePackageConfigSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeletePackageConfigsRequestSchema = {
|
export const DeletePackageConfigsRequestSchema = {
|
||||||
|
|
Loading…
Reference in a new issue