[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)
This commit is contained in:
James Gowdy 2025-01-07 09:56:00 +00:00 committed by GitHub
parent 128739dc46
commit 3d65e892a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 265 additions and 51 deletions

View file

@ -39,6 +39,10 @@ export interface CanDeleteMLSpaceAwareItemsResponse {
};
}
export interface CanSyncToAllSpacesResponse {
canSync: boolean;
}
export type JobsSpacesResponse = {
[jobType in JobType]: { [jobId: string]: string[] };
};

View file

@ -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<Props> = ({ onClose }) => {
const { displayErrorToast, displaySuccessToast } = useToastNotificationService();
const [loading, setLoading] = useState(false);
const [canSync, setCanSync] = useState(false);
const [canSyncToAllSpaces, setCanSyncToAllSpaces] = useState(true);
const [syncResp, setSyncResp] = useState<SyncSavedObjectResponse | null>(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<Props> = ({ onClose }) => {
/>
</EuiText>
</EuiCallOut>
{canSyncToAllSpaces === false ? (
<>
<EuiSpacer size="s" />
<SyncToAllSpacesWarning />
</>
) : null}
<EuiSpacer />
<SyncList syncItems={syncResp} />
</EuiFlyoutBody>

View file

@ -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 (
<EuiCallOut
size="s"
iconType="help"
title={
<FormattedMessage
id="xpack.ml.management.syncSavedObjectsFlyout.allSpacesWarning.title"
defaultMessage="Sync can only add items to the current space"
/>
}
color="warning"
>
<EuiText size="s">
<FormattedMessage
id="xpack.ml.management.syncSavedObjectsFlyout.allSpacesWarning.description"
defaultMessage="Without {readAndWritePrivilegesLink} for all spaces you can only add jobs and trained models to the current space when syncing."
values={{
readAndWritePrivilegesLink: (
<EuiLink href={docLink} target="_blank">
<FormattedMessage
id="xpack.ml.management.syncSavedObjectsFlyout.privilegeWarningLink"
defaultMessage="read and write privileges"
/>
</EuiLink>
),
}}
/>
</EuiText>
</EuiCallOut>
);
};

View file

@ -22,3 +22,7 @@ export const basicResolvers = (): Resolvers => ({
getMlNodeCount,
loadMlServerInfo,
});
export const initSavedObjects = async (mlApi: MlApi) => {
return mlApi.savedObjects.initSavedObjects().catch(() => {});
};

View file

@ -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 (
<PageLoader context={context}>
<Page />

View file

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

View file

@ -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<PageProps> = () => {
const { context } = useRouteResolver('full', ['canGetMlInfo'], {
getMlNodeCount,
loadMlServerInfo,
initSavedObjects,
});
useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false });

View file

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

View file

@ -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<SyncSavedObjectResponse>({
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<CanSyncToAllSpacesResponse>({
path: `${ML_INTERNAL_BASE_PATH}/saved_objects/can_sync_to_all_spaces${
mlSavedObjectType !== undefined ? `/${mlSavedObjectType}` : ''
}`,
method: 'GET',
version: '1',
});
},
trainedModelsSpaces() {
return httpService.http<TrainedModelsSpacesResponse>({
path: `${ML_INTERNAL_BASE_PATH}/saved_objects/trained_models_spaces`,

View file

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

View file

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

View file

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

View file

@ -319,8 +319,8 @@ export function checksFactory(
}, {} as DeleteMLSpaceAwareItemsCheckResponse);
}
const canCreateGlobalMlSavedObjects = await mlSavedObjectService.canCreateGlobalMlSavedObjects(
mlSavedObjectType,
request
request,
mlSavedObjectType
);
const savedObjects =

View file

@ -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<JobObject>(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<TrainedModelObject>(

View file

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

View file

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

View file

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

View file

@ -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, ['*']);
});
});
};

View file

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