From bef9cfd9df5bb342833dc1fa510818dd46cc2fb1 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 7 Jan 2025 09:56:00 +0000 Subject: [PATCH] [ML] Sync ML saved objects to all spaces (#202175) When manually syncing ML saved objects using the sync flyout, the saved objects are now tagged to the `*` space. This now matches the behaviour of the server side auto sync and the sync which happens when the trained models page is loaded. The trained models page load sync has been extended to the AD and DA jobs lists and the overview page. If the user does not have write permission for ML in every space they cannot sync jobs to the `*` space. In this situation a warning is shown in the flyout and when they sync, the jobs/models will only be added to the current space. ![image](https://github.com/user-attachments/assets/9e6ede10-d7aa-4724-9b1c-adabe96593a8) (cherry picked from commit 3d65e892a014aa5a027d82caa9d92392a515390b) --- .../shared/ml/common/types/saved_objects.ts | 4 ++ .../job_spaces_sync_flyout.tsx | 16 +++++- .../sync_to_all_spaces_warning.tsx | 51 ++++++++++++++++++ .../public/application/routing/resolvers.ts | 4 ++ .../analytics_jobs_list.tsx | 7 ++- .../application/routing/routes/jobs_list.tsx | 7 ++- .../application/routing/routes/overview.tsx | 2 + .../routes/trained_models/models_list.tsx | 17 ++---- .../services/ml_api_service/saved_objects.ts | 14 ++++- .../models/data_recognizer/data_recognizer.ts | 4 +- .../shared/ml/server/routes/saved_objects.ts | 46 +++++++++++++++- .../ml/server/routes/schemas/saved_objects.ts | 5 +- .../shared/ml/server/saved_objects/checks.ts | 4 +- .../shared/ml/server/saved_objects/service.ts | 48 ++++++++++++----- .../shared/ml/server/saved_objects/sync.ts | 20 ++++--- .../saved_objects/can_sync_to_all_spaces.ts | 52 +++++++++++++++++++ .../apis/ml/saved_objects/index.ts | 1 + .../ml/saved_objects/sync_trained_models.ts | 4 +- .../ml/stack_management_jobs/synchronize.ts | 10 +++- 19 files changed, 265 insertions(+), 51 deletions(-) create mode 100644 x-pack/platform/plugins/shared/ml/public/application/components/job_spaces_sync/sync_to_all_spaces_warning.tsx create mode 100644 x-pack/test/api_integration/apis/ml/saved_objects/can_sync_to_all_spaces.ts diff --git a/x-pack/platform/plugins/shared/ml/common/types/saved_objects.ts b/x-pack/platform/plugins/shared/ml/common/types/saved_objects.ts index adaf00fd9405f..0fbd48f0c28ae 100644 --- a/x-pack/platform/plugins/shared/ml/common/types/saved_objects.ts +++ b/x-pack/platform/plugins/shared/ml/common/types/saved_objects.ts @@ -39,6 +39,10 @@ export interface CanDeleteMLSpaceAwareItemsResponse { }; } +export interface CanSyncToAllSpacesResponse { + canSync: boolean; +} + export type JobsSpacesResponse = { [jobType in JobType]: { [jobId: string]: string[] }; }; diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx index fe4b23fbdf3a1..1b16df01e49a3 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx @@ -28,6 +28,7 @@ import { useMlApi } from '../../contexts/kibana'; import type { SyncSavedObjectResponse, SyncResult } from '../../../../common/types/saved_objects'; import { SyncList } from './sync_list'; import { useToastNotificationService } from '../../services/toast_notification_service'; +import { SyncToAllSpacesWarning } from './sync_to_all_spaces_warning'; export interface Props { onClose: () => void; @@ -37,17 +38,22 @@ export const JobSpacesSyncFlyout: FC = ({ onClose }) => { const { displayErrorToast, displaySuccessToast } = useToastNotificationService(); const [loading, setLoading] = useState(false); const [canSync, setCanSync] = useState(false); + const [canSyncToAllSpaces, setCanSyncToAllSpaces] = useState(true); const [syncResp, setSyncResp] = useState(null); const { - savedObjects: { syncSavedObjects }, + savedObjects: { syncSavedObjects, canSyncToAllSpaces: canSyncToAllSpacesFunc }, } = useMlApi(); async function loadSyncList(simulate: boolean = true) { setLoading(true); try { - const resp = await syncSavedObjects(simulate); + const resp = await syncSavedObjects(simulate, canSyncToAllSpaces); setSyncResp(resp); + if (simulate === true) { + setCanSyncToAllSpaces((await canSyncToAllSpacesFunc()).canSync); + } + const count = Object.values(resp).reduce((acc, cur) => acc + Object.keys(cur).length, 0); setCanSync(count > 0); setLoading(false); @@ -118,6 +124,12 @@ export const JobSpacesSyncFlyout: FC = ({ onClose }) => { /> + {canSyncToAllSpaces === false ? ( + <> + + + + ) : null} diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_spaces_sync/sync_to_all_spaces_warning.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_spaces_sync/sync_to_all_spaces_warning.tsx new file mode 100644 index 0000000000000..e97f319fb6554 --- /dev/null +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_spaces_sync/sync_to_all_spaces_warning.tsx @@ -0,0 +1,51 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiCallOut, EuiLink, EuiText } from '@elastic/eui'; +import { useMlKibana } from '../../contexts/kibana/kibana_context'; + +export const SyncToAllSpacesWarning: FC = () => { + const { + services: { + docLinks: { links }, + }, + } = useMlKibana(); + const docLink = links.security.kibanaPrivileges; + return ( + + } + color="warning" + > + + + + + ), + }} + /> + + + ); +}; diff --git a/x-pack/platform/plugins/shared/ml/public/application/routing/resolvers.ts b/x-pack/platform/plugins/shared/ml/public/application/routing/resolvers.ts index 7d7ca41ebf9c3..c504013eb3ec3 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/routing/resolvers.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/routing/resolvers.ts @@ -22,3 +22,7 @@ export const basicResolvers = (): Resolvers => ({ getMlNodeCount, loadMlServerInfo, }); + +export const initSavedObjects = async (mlApi: MlApi) => { + return mlApi.savedObjects.initSavedObjects().catch(() => {}); +}; diff --git a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx index cdfb854314a72..b494d78b66294 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx @@ -14,7 +14,7 @@ import type { NavigateToPath } from '../../../contexts/kibana'; import type { MlRoute } from '../../router'; import { createPath, PageLoader } from '../../router'; import { useRouteResolver } from '../../use_resolver'; -import { basicResolvers } from '../../resolvers'; +import { basicResolvers, initSavedObjects } from '../../resolvers'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; const Page = dynamic(async () => ({ @@ -45,7 +45,10 @@ export const analyticsJobsListRouteFactory = ( }); const PageWrapper: FC = () => { - const { context } = useRouteResolver('full', ['canGetDataFrameAnalytics'], basicResolvers()); + const { context } = useRouteResolver('full', ['canGetDataFrameAnalytics'], { + ...basicResolvers(), + initSavedObjects, + }); return ( diff --git a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/jobs_list.tsx b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/jobs_list.tsx index 28a71b5e7c819..359fc7236ddd6 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/jobs_list.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/jobs_list.tsx @@ -24,7 +24,7 @@ import { useRouteResolver } from '../use_resolver'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; import { AnnotationUpdatesService } from '../../services/annotations_service'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; -import { basicResolvers } from '../resolvers'; +import { basicResolvers, initSavedObjects } from '../resolvers'; const JobsPage = dynamic(async () => ({ default: (await import('../../jobs/jobs_list')).JobsPage, @@ -51,7 +51,10 @@ export const jobListRouteFactory = (navigateToPath: NavigateToPath, basePath: st }); const PageWrapper: FC = () => { - const { context } = useRouteResolver('full', ['canGetJobs'], basicResolvers()); + const { context } = useRouteResolver('full', ['canGetJobs'], { + ...basicResolvers(), + initSavedObjects, + }); const timefilter = useTimefilter({ timeRangeSelector: false, autoRefreshSelector: true }); diff --git a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/overview.tsx b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/overview.tsx index 4fec14bc41294..c578cc17544e7 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/overview.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/overview.tsx @@ -19,6 +19,7 @@ import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; import type { MlRoute, PageProps } from '../router'; import { createPath, PageLoader } from '../router'; import { useRouteResolver } from '../use_resolver'; +import { initSavedObjects } from '../resolvers'; const OverviewPage = React.lazy(() => import('../../overview/overview_page')); @@ -48,6 +49,7 @@ const PageWrapper: FC = () => { const { context } = useRouteResolver('full', ['canGetMlInfo'], { getMlNodeCount, loadMlServerInfo, + initSavedObjects, }); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); diff --git a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/trained_models/models_list.tsx b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/trained_models/models_list.tsx index cee2a92f03ada..2fa7f47a83ead 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/trained_models/models_list.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/trained_models/models_list.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { type FC, useCallback } from 'react'; +import type { FC } from 'react'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; @@ -16,10 +16,9 @@ import type { NavigateToPath } from '../../../contexts/kibana'; import type { MlRoute } from '../../router'; import { createPath, PageLoader } from '../../router'; import { useRouteResolver } from '../../use_resolver'; -import { basicResolvers } from '../../resolvers'; +import { basicResolvers, initSavedObjects } from '../../resolvers'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; import { MlPageHeader } from '../../../components/page_header'; -import { useSavedObjectsApiService } from '../../../services/ml_api_service/saved_objects'; const ModelsList = dynamic(async () => ({ default: (await import('../../../model_management/models_list')).ModelsList, @@ -49,19 +48,9 @@ export const modelsListRouteFactory = ( }); const PageWrapper: FC = () => { - const { initSavedObjects } = useSavedObjectsApiService(); - - const initSavedObjectsWrapper = useCallback(async () => { - try { - await initSavedObjects(); - } catch (error) { - // ignore error as user may not have permission to sync - } - }, [initSavedObjects]); - const { context } = useRouteResolver('full', ['canGetTrainedModels'], { ...basicResolvers(), - initSavedObjectsWrapper, + initSavedObjects, }); return ( diff --git a/x-pack/platform/plugins/shared/ml/public/application/services/ml_api_service/saved_objects.ts b/x-pack/platform/plugins/shared/ml/public/application/services/ml_api_service/saved_objects.ts index 125256770f1d4..e382eee7661a3 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/services/ml_api_service/saved_objects.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/services/ml_api_service/saved_objects.ts @@ -23,6 +23,7 @@ import type { JobsSpacesResponse, TrainedModelsSpacesResponse, SyncCheckResponse, + CanSyncToAllSpacesResponse, } from '../../../../common/types/saved_objects'; export const savedObjectsApiProvider = (httpService: HttpService) => ({ @@ -56,11 +57,11 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({ version: '1', }); }, - syncSavedObjects(simulate: boolean = false) { + syncSavedObjects(simulate: boolean = false, addToAllSpaces?: boolean) { return httpService.http({ path: `${ML_EXTERNAL_BASE_PATH}/saved_objects/sync`, method: 'GET', - query: { simulate }, + query: { simulate, addToAllSpaces }, version: '2023-10-31', }); }, @@ -90,6 +91,15 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({ version: '1', }); }, + canSyncToAllSpaces(mlSavedObjectType?: MlSavedObjectType) { + return httpService.http({ + path: `${ML_INTERNAL_BASE_PATH}/saved_objects/can_sync_to_all_spaces${ + mlSavedObjectType !== undefined ? `/${mlSavedObjectType}` : '' + }`, + method: 'GET', + version: '1', + }); + }, trainedModelsSpaces() { return httpService.http({ path: `${ML_INTERNAL_BASE_PATH}/saved_objects/trained_models_spaces`, diff --git a/x-pack/platform/plugins/shared/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/platform/plugins/shared/ml/server/models/data_recognizer/data_recognizer.ts index e585d73a9d894..41a8f60f912f3 100644 --- a/x-pack/platform/plugins/shared/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/platform/plugins/shared/ml/server/models/data_recognizer/data_recognizer.ts @@ -870,8 +870,8 @@ export class DataRecognizer { ); if (applyToAllSpaces === true) { const canCreateGlobalJobs = await this._mlSavedObjectService.canCreateGlobalMlSavedObjects( - 'anomaly-detector', - this._request + this._request, + 'anomaly-detector' ); if (canCreateGlobalJobs === true) { await this._mlSavedObjectService.updateJobsSpaces( diff --git a/x-pack/platform/plugins/shared/ml/server/routes/saved_objects.ts b/x-pack/platform/plugins/shared/ml/server/routes/saved_objects.ts index 437f0a80eb1d7..962678cbfa867 100644 --- a/x-pack/platform/plugins/shared/ml/server/routes/saved_objects.ts +++ b/x-pack/platform/plugins/shared/ml/server/routes/saved_objects.ts @@ -92,9 +92,9 @@ export function savedObjectsRoutes( routeGuard.fullLicenseAPIGuard( async ({ client, request, response, mlSavedObjectService }) => { try { - const { simulate } = request.query; + const { simulate, addToAllSpaces } = request.query; const { syncSavedObjects } = syncSavedObjectsFactory(client, mlSavedObjectService); - const savedObjects = await syncSavedObjects(simulate); + const savedObjects = await syncSavedObjects(simulate, addToAllSpaces ?? true); return response.ok({ body: savedObjects, @@ -450,4 +450,46 @@ export function savedObjectsRoutes( } ) ); + + router.versioned + .get({ + path: `${ML_INTERNAL_BASE_PATH}/saved_objects/can_sync_to_all_spaces/{mlSavedObjectType?}`, + access: 'internal', + security: { + authz: { + requiredPrivileges: [ + 'ml:canGetJobs', + 'ml:canGetDataFrameAnalytics', + 'ml:canGetTrainedModels', + ], + }, + }, + summary: 'Check whether user can sync a job or trained model to the * space', + description: `Check the user's ability to sync jobs or trained models to the * space. Returns whether they are able to sync the job or trained model to the * space.`, + }) + .addVersion( + { + version: '1', + validate: { + request: { + params: syncCheckSchema, + }, + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ request, response, mlSavedObjectService }) => { + try { + const { mlSavedObjectType } = request.params; + const canSync = await mlSavedObjectService.canCreateGlobalMlSavedObjects( + request, + mlSavedObjectType as MlSavedObjectType + ); + + return response.ok({ + body: { canSync }, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); } diff --git a/x-pack/platform/plugins/shared/ml/server/routes/schemas/saved_objects.ts b/x-pack/platform/plugins/shared/ml/server/routes/schemas/saved_objects.ts index d40638c496f00..8feac4f005d0c 100644 --- a/x-pack/platform/plugins/shared/ml/server/routes/schemas/saved_objects.ts +++ b/x-pack/platform/plugins/shared/ml/server/routes/schemas/saved_objects.ts @@ -39,7 +39,10 @@ export const itemsAndCurrentSpace = schema.object({ ids: schema.arrayOf(schema.string()), }); -export const syncJobObjects = schema.object({ simulate: schema.maybe(schema.boolean()) }); +export const syncJobObjects = schema.object({ + simulate: schema.maybe(schema.boolean()), + addToAllSpaces: schema.maybe(schema.boolean()), +}); export const syncCheckSchema = schema.object({ mlSavedObjectType: schema.maybe(schema.string()) }); diff --git a/x-pack/platform/plugins/shared/ml/server/saved_objects/checks.ts b/x-pack/platform/plugins/shared/ml/server/saved_objects/checks.ts index f8e1827c78a87..73a3601ca748b 100644 --- a/x-pack/platform/plugins/shared/ml/server/saved_objects/checks.ts +++ b/x-pack/platform/plugins/shared/ml/server/saved_objects/checks.ts @@ -319,8 +319,8 @@ export function checksFactory( }, {} as DeleteMLSpaceAwareItemsCheckResponse); } const canCreateGlobalMlSavedObjects = await mlSavedObjectService.canCreateGlobalMlSavedObjects( - mlSavedObjectType, - request + request, + mlSavedObjectType ); const savedObjects = diff --git a/x-pack/platform/plugins/shared/ml/server/saved_objects/service.ts b/x-pack/platform/plugins/shared/ml/server/saved_objects/service.ts index 3e0f81a8ba13b..a8e829d8401c3 100644 --- a/x-pack/platform/plugins/shared/ml/server/saved_objects/service.ts +++ b/x-pack/platform/plugins/shared/ml/server/saved_objects/service.ts @@ -102,7 +102,12 @@ export function mlSavedObjectServiceFactory( return jobs.saved_objects; } - async function _createJob(jobType: JobType, jobId: string, datafeedId?: string) { + async function _createJob( + jobType: JobType, + jobId: string, + datafeedId?: string, + addToAllSpaces = false + ) { await isMlReady(); const job: JobObject = { @@ -133,6 +138,7 @@ export function mlSavedObjectServiceFactory( await savedObjectsClient.create(ML_JOB_SAVED_OBJECT_TYPE, job, { id, + ...(addToAllSpaces ? { initialNamespaces: ['*'] } : {}), }); _clearSavedObjectsClientCache(); } @@ -182,8 +188,12 @@ export function mlSavedObjectServiceFactory( _clearSavedObjectsClientCache(); } - async function createAnomalyDetectionJob(jobId: string, datafeedId?: string) { - await _createJob('anomaly-detector', jobId, datafeedId); + async function createAnomalyDetectionJob( + jobId: string, + datafeedId?: string, + addToAllSpaces = false + ) { + await _createJob('anomaly-detector', jobId, datafeedId, addToAllSpaces); } async function deleteAnomalyDetectionJob(jobId: string) { @@ -194,8 +204,8 @@ export function mlSavedObjectServiceFactory( await _forceDeleteJob('anomaly-detector', jobId, namespace); } - async function createDataFrameAnalyticsJob(jobId: string) { - await _createJob('data-frame-analytics', jobId); + async function createDataFrameAnalyticsJob(jobId: string, addToAllSpaces = false) { + await _createJob('data-frame-analytics', jobId, undefined, addToAllSpaces); } async function deleteDataFrameAnalyticsJob(jobId: string) { @@ -418,8 +428,8 @@ export function mlSavedObjectServiceFactory( } async function canCreateGlobalMlSavedObjects( - mlSavedObjectType: MlSavedObjectType, - request: KibanaRequest + request: KibanaRequest, + mlSavedObjectType?: MlSavedObjectType ) { if (authorization === undefined) { return true; @@ -428,6 +438,10 @@ export function mlSavedObjectServiceFactory( const { canCreateJobsGlobally, canCreateTrainedModelsGlobally } = await authorizationCheck( request ); + if (mlSavedObjectType === undefined) { + return canCreateJobsGlobally && canCreateTrainedModelsGlobally; + } + return mlSavedObjectType === 'trained-model' ? canCreateTrainedModelsGlobally : canCreateJobsGlobally; @@ -441,8 +455,12 @@ export function mlSavedObjectServiceFactory( return modelObject; } - async function createTrainedModel(modelId: string, job: TrainedModelJob | null) { - await _createTrainedModel(modelId, job); + async function createTrainedModel( + modelId: string, + job: TrainedModelJob | null, + addToAllSpaces = false + ) { + await _createTrainedModel(modelId, job, addToAllSpaces); } async function bulkCreateTrainedModel(models: TrainedModelObject[], namespaceFallback?: string) { @@ -486,7 +504,11 @@ export function mlSavedObjectServiceFactory( return models.saved_objects; } - async function _createTrainedModel(modelId: string, job: TrainedModelJob | null) { + async function _createTrainedModel( + modelId: string, + job: TrainedModelJob | null, + addToAllSpaces = false + ) { await isMlReady(); const modelObject: TrainedModelObject = { @@ -513,7 +535,7 @@ export function mlSavedObjectServiceFactory( // the saved object may exist if a previous job with the same ID has been deleted. // if not, this error will be throw which we ignore. } - let initialNamespaces; + let initialNamespaces = addToAllSpaces ? ['*'] : undefined; // if a job exists for this model, ensure the initial namespaces for the model // are the same as the job if (job !== null) { @@ -522,7 +544,9 @@ export function mlSavedObjectServiceFactory( job.job_id ); - initialNamespaces = existingJobObject?.namespaces ?? undefined; + if (existingJobObject?.namespaces !== undefined) { + initialNamespaces = existingJobObject?.namespaces; + } } await savedObjectsClient.create( diff --git a/x-pack/platform/plugins/shared/ml/server/saved_objects/sync.ts b/x-pack/platform/plugins/shared/ml/server/saved_objects/sync.ts index f96233debf9a1..edb4e7dfca6c9 100644 --- a/x-pack/platform/plugins/shared/ml/server/saved_objects/sync.ts +++ b/x-pack/platform/plugins/shared/ml/server/saved_objects/sync.ts @@ -34,7 +34,7 @@ export function syncSavedObjectsFactory( ) { const { checkStatus } = checksFactory(client, mlSavedObjectService); - async function syncSavedObjects(simulate: boolean = false) { + async function syncSavedObjects(simulate: boolean = false, addToAllSpaces = false) { const results: SyncSavedObjectResponse = { savedObjectsCreated: {}, savedObjectsDeleted: {}, @@ -71,7 +71,11 @@ export function syncSavedObjectsFactory( const datafeedId = job.datafeedId; tasks.push(async () => { try { - await mlSavedObjectService.createAnomalyDetectionJob(jobId, datafeedId ?? undefined); + await mlSavedObjectService.createAnomalyDetectionJob( + jobId, + datafeedId ?? undefined, + addToAllSpaces + ); results.savedObjectsCreated[type]![job.jobId] = { success: true }; } catch (error) { results.savedObjectsCreated[type]![job.jobId] = { @@ -97,7 +101,7 @@ export function syncSavedObjectsFactory( const jobId = job.jobId; tasks.push(async () => { try { - await mlSavedObjectService.createDataFrameAnalyticsJob(jobId); + await mlSavedObjectService.createDataFrameAnalyticsJob(jobId, addToAllSpaces); results.savedObjectsCreated[type]![job.jobId] = { success: true, }; @@ -136,7 +140,11 @@ export function syncSavedObjectsFactory( return; } const job = getJobDetailsFromTrainedModel(mod); - await mlSavedObjectService.createTrainedModel(modelId, job); + await mlSavedObjectService.createTrainedModel( + modelId, + job, + addToAllSpaces || modelId.startsWith('.') + ); if (modelId.startsWith('.')) { // if the model id starts with a dot, it is an internal model and should be in all spaces await mlSavedObjectService.updateTrainedModelsSpaces([modelId], ['*'], []); @@ -344,9 +352,9 @@ export function syncSavedObjectsFactory( const jobObjects: Array<{ job: JobObject; namespaces: string[] }> = []; const datafeeds: Array<{ jobId: string; datafeedId: string }> = []; - const types: JobType[] = ['anomaly-detector', 'data-frame-analytics']; + const jobTypes: JobType[] = ['anomaly-detector', 'data-frame-analytics']; - types.forEach((type) => { + jobTypes.forEach((type) => { status.jobs[type].forEach((job) => { if (job.checks.savedObjectExits === false) { if (simulate === true) { diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/can_sync_to_all_spaces.ts b/x-pack/test/api_integration/apis/ml/saved_objects/can_sync_to_all_spaces.ts new file mode 100644 index 0000000000000..59b3c0d13f1e2 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/saved_objects/can_sync_to_all_spaces.ts @@ -0,0 +1,52 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { getCommonRequestHeader } from '../../../../functional/services/ml/common_api'; + +export default ({ getService }: FtrProviderContext) => { + const ml = getService('ml'); + const spacesService = getService('spaces'); + const supertest = getService('supertestWithoutAuth'); + + const idSpace1 = 'space1'; + const idSpace2 = 'space2'; + + async function runRequest(user: USER, expectedStatusCode: number) { + const { body, status } = await supertest + .get(`/internal/ml/saved_objects/can_sync_to_all_spaces`) + .auth(user, ml.securityCommon.getPasswordForUser(user)) + .set(getCommonRequestHeader('1')); + ml.api.assertResponseStatusCode(expectedStatusCode, status, body); + + return body; + } + + describe('GET saved_objects/can_sync_to_all_spaces', () => { + beforeEach(async () => { + await spacesService.create({ id: idSpace1, name: 'space_one', disabledFeatures: [] }); + await spacesService.create({ id: idSpace2, name: 'space_two', disabledFeatures: [] }); + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + afterEach(async () => { + await spacesService.delete(idSpace1); + await spacesService.delete(idSpace2); + }); + + it('user can sync to all spaces', async () => { + const body = await runRequest(USER.ML_POWERUSER, 200); + expect(body).to.eql({ canSync: true }); + }); + it('user can not sync to all spaces', async () => { + const body = await runRequest(USER.ML_POWERUSER_SPACE1, 200); + expect(body).to.eql({ canSync: false }); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/index.ts b/x-pack/test/api_integration/apis/ml/saved_objects/index.ts index b4b8e1d0653f3..de56161d5aef3 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/index.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/index.ts @@ -21,5 +21,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./update_jobs_spaces')); loadTestFile(require.resolve('./update_trained_model_spaces')); loadTestFile(require.resolve('./remove_from_current_space')); + loadTestFile(require.resolve('./can_sync_to_all_spaces')); }); } diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/sync_trained_models.ts b/x-pack/test/api_integration/apis/ml/saved_objects/sync_trained_models.ts index 33c5da4d2e01c..7b5956de227b6 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/sync_trained_models.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/sync_trained_models.ts @@ -187,7 +187,7 @@ export default ({ getService }: FtrProviderContext) => { const model1 = getTestModel(modelIdSpace1, 'classification', dfaJobId1); await ml.api.createTrainedModelES(model1.model_id, model1.body); - // create trained model not linked to job, it should have the current space + // create trained model not linked to job, it should have * space after sync const model2 = getTestModel(modelIdSpace2, 'classification'); await ml.api.createTrainedModelES(model2.model_id, model2.body); @@ -199,7 +199,7 @@ export default ({ getService }: FtrProviderContext) => { await runSyncRequest(USER.ML_POWERUSER_ALL_SPACES, 200); await ml.api.assertTrainedModelSpaces(modelIdSpace1, [idSpace1, idSpace2]); - await ml.api.assertTrainedModelSpaces(modelIdSpace2, [idSpace1]); + await ml.api.assertTrainedModelSpaces(modelIdSpace2, ['*']); }); }); }; diff --git a/x-pack/test/functional/apps/ml/stack_management_jobs/synchronize.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/synchronize.ts index 42a462d259812..e8c255212251b 100644 --- a/x-pack/test/functional/apps/ml/stack_management_jobs/synchronize.ts +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/synchronize.ts @@ -63,6 +63,12 @@ export default function ({ getService }: FtrProviderContext) { await ml.stackManagementJobs.assertSyncFlyoutSyncButtonEnabled(false); }); + it('should not have objects to sync', async () => { + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToAnomalyDetection(); + await ml.overviewPage.assertJobSyncRequiredWarningNotExists(); + }); + it('should prepare test data', async () => { // create jobs @@ -102,8 +108,8 @@ export default function ({ getService }: FtrProviderContext) { }); it('should have objects to sync', async () => { - // sync required warning is displayed - await ml.navigation.navigateToMl(); + await ml.jobTable.refreshJobList(); + await ml.overviewPage.assertJobSyncRequiredWarningExists(); // object counts in sync flyout are all 1, sync button is enabled