[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:
Jen Huang 2020-07-06 09:45:51 -07:00 committed by GitHub
parent f28d4e920e
commit cbd39d98a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 86 additions and 30 deletions

View file

@ -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'>;

View file

@ -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;

View file

@ -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,

View file

@ -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');
} }
}; };

View file

@ -15,6 +15,7 @@ export {
EnrollmentAPIKey, EnrollmentAPIKey,
PackageConfig, PackageConfig,
NewPackageConfig, NewPackageConfig,
UpdatePackageConfig,
PackageConfigInput, PackageConfigInput,
PackageConfigInputStream, PackageConfigInputStream,
PackageConfigConfigRecordEntry, PackageConfigConfigRecordEntry,

View file

@ -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 },
}); });
} }

View file

@ -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, {

View file

@ -21,6 +21,7 @@ export {
PackageConfigInput, PackageConfigInput,
PackageConfigInputStream, PackageConfigInputStream,
NewPackageConfig, NewPackageConfig,
UpdatePackageConfig,
PackageConfigSOAttributes, PackageConfigSOAttributes,
FullAgentConfigInput, FullAgentConfigInput,
FullAgentConfig, FullAgentConfig,

View file

@ -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()),
}); });

View file

@ -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 = {