mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Fleet] Tag assets based on definitions in integration tag.yml (#162643)
Closes https://github.com/elastic/kibana/issues/152814
## Summary
Tag assets based on definitions in integrations file `tag.yml` following
these guidelines:
- Tags are not be duplicated if a fleet created tag already exists with
that text in the same space (i.e two integrations using a tag with the
same text installed in the same space should share a tag, not create
two)
- Tag ids follow the template
`fleet-shared-tag-${pkgName}-${uniqueId}-${spaceId}`
- When tag is `SecuritySolution` it generates a `SecuritySolution` tag
to maintain compatibility with security requirements
- The function that generates the unique tag ids is exported so other
plugins can use it
- The tag color is randomized from a set of known colors
### Testing
To test it, I generated a customized version of a the AWS package that
has a `kibana/tags.yml `file that follows this template, since currently
there are no existing published packages of this type.
See related [package-spec
PR](https://github.com/elastic/package-spec/pull/567)
```
- text: Foo
asset_types:
- dashboard
- search
- text: Bar
asset_ids:
- id1
- id2
- text: myCustomTag
asset_types:
- dashboard
- map
asset_ids:
- id1
```
Note that `manifest.yml` needs to have at least:
```
format_version: 2.10.0
kibana.version: "^8.10.1"
```
I then verified that the tags are correctly generated and associated with correct assets:
<img width="1945" alt="Screenshot 2023-08-01 at 16 20 38" src="51ec07be
-e641-47ef-9af3-75799724b7a9">
### Checklist
- [ ] [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
---------
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
49733763ef
commit
9942fdcb2a
10 changed files with 793 additions and 42 deletions
|
@ -31,6 +31,12 @@ export interface PackageSpecManifest {
|
|||
RegistryElasticsearch,
|
||||
'index_template.settings' | 'index_template.mappings' | 'index_template.data_stream'
|
||||
>;
|
||||
asset_tags?: PackageSpecTags[];
|
||||
}
|
||||
export interface PackageSpecTags {
|
||||
text: string;
|
||||
asset_types?: string[];
|
||||
asset_ids?: string[];
|
||||
}
|
||||
|
||||
export type PackageSpecPackageType = 'integration' | 'input';
|
||||
|
|
|
@ -128,6 +128,7 @@ import {
|
|||
import { FleetActionsClient, type FleetActionsClientInterface } from './services/actions';
|
||||
import type { FilesClientFactory } from './services/files/types';
|
||||
import { PolicyWatcher } from './services/agent_policy_watch';
|
||||
import { getPackageSpecTagId } from './services/epm/kibana/assets/tag_assets';
|
||||
|
||||
export interface FleetSetupDeps {
|
||||
security: SecurityPluginSetup;
|
||||
|
@ -232,6 +233,10 @@ export interface FleetStartContract {
|
|||
messageSigningService: MessageSigningServiceInterface;
|
||||
uninstallTokenService: UninstallTokenServiceInterface;
|
||||
createFleetActionsClient: (packageName: string) => FleetActionsClientInterface;
|
||||
/*
|
||||
Function exported to allow creating unique ids for saved object tags
|
||||
*/
|
||||
getPackageSpecTagId: (spaceId: string, pkgName: string, tagName: string) => string;
|
||||
}
|
||||
|
||||
export class FleetPlugin
|
||||
|
@ -591,6 +596,7 @@ export class FleetPlugin
|
|||
createFleetActionsClient(packageName: string) {
|
||||
return new FleetActionsClient(core.elasticsearch.client.asInternalUser, packageName);
|
||||
},
|
||||
getPackageSpecTagId,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ import type {
|
|||
PackageSpecManifest,
|
||||
RegistryDataStreamRoutingRules,
|
||||
RegistryDataStreamLifecycle,
|
||||
PackageSpecTags,
|
||||
} from '../../../../common/types';
|
||||
import {
|
||||
RegistryInputKeys,
|
||||
|
@ -45,6 +46,9 @@ export const DATASTREAM_MANIFEST_NAME = 'manifest.yml';
|
|||
export const DATASTREAM_ROUTING_RULES_NAME = 'routing_rules.yml';
|
||||
export const DATASTREAM_LIFECYCLE_NAME = 'lifecycle.yml';
|
||||
|
||||
export const KIBANA_FOLDER_NAME = 'kibana';
|
||||
export const TAGS_NAME = 'tags.yml';
|
||||
|
||||
const DEFAULT_RELEASE_VALUE = 'ga';
|
||||
|
||||
// Ingest pipelines are specified in a `data_stream/<name>/elasticsearch/ingest_pipeline/` directory where a `default`
|
||||
|
@ -135,6 +139,7 @@ const PARSE_AND_VERIFY_ASSETS_NAME = [
|
|||
MANIFEST_NAME,
|
||||
DATASTREAM_ROUTING_RULES_NAME,
|
||||
DATASTREAM_LIFECYCLE_NAME,
|
||||
TAGS_NAME,
|
||||
];
|
||||
/**
|
||||
* Filter assets needed for the parse and verify archive function
|
||||
|
@ -146,14 +151,6 @@ export function filterAssetPathForParseAndVerifyArchive(assetPath: string): bool
|
|||
/*
|
||||
This function generates a package info object (see type `ArchivePackage`) by parsing and verifying the `manifest.yml` file as well
|
||||
as the directory structure for the given package archive and other files adhering to the package spec: https://github.com/elastic/package-spec.
|
||||
|
||||
Currently, this process is duplicative of logic that's already implemented in the Package Registry codebase,
|
||||
e.g. https://github.com/elastic/package-registry/blob/main/packages/package.go. Because of this duplication, it's likely for our parsing/verification
|
||||
logic to fall out of sync with the registry codebase's implementation.
|
||||
|
||||
This should be addressed in https://github.com/elastic/kibana/issues/115032
|
||||
where we'll no longer use the package registry endpoint as a source of truth for package info objects, and instead Fleet will _always_ generate
|
||||
them in the manner implemented below.
|
||||
*/
|
||||
export async function generatePackageInfoFromArchiveBuffer(
|
||||
archiveBuffer: Buffer,
|
||||
|
@ -289,6 +286,22 @@ export function parseAndVerifyArchive(
|
|||
parsed.vars = parseAndVerifyVars(manifest.vars, 'manifest.yml');
|
||||
}
|
||||
|
||||
// check that kibana/tags.yml file exists and add its content to ArchivePackage
|
||||
const tagsFile = path.posix.join(toplevelDir, KIBANA_FOLDER_NAME, TAGS_NAME);
|
||||
const tagsBuffer = assetsMap[tagsFile];
|
||||
|
||||
if (paths.includes(tagsFile) || tagsBuffer) {
|
||||
let tags: PackageSpecTags[];
|
||||
try {
|
||||
tags = yaml.safeLoad(tagsBuffer.toString());
|
||||
if (tags.length) {
|
||||
parsed.asset_tags = tags;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new PackageInvalidArchiveError(`Could not parse tags file kibana/tags.yml: ${error}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,13 @@ import type { IAssignmentService, ITagsClient } from '@kbn/saved-objects-tagging
|
|||
import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../common';
|
||||
import { getAsset, getPathParts } from '../../archive';
|
||||
import { KibanaAssetType, KibanaSavedObjectType } from '../../../../types';
|
||||
import type { AssetType, AssetReference, AssetParts, Installation } from '../../../../types';
|
||||
import type {
|
||||
AssetType,
|
||||
AssetReference,
|
||||
AssetParts,
|
||||
Installation,
|
||||
PackageSpecTags,
|
||||
} from '../../../../types';
|
||||
import { savedObjectTypes } from '../../packages';
|
||||
import { indexPatternTypes, getIndexPatternSavedObjects } from '../index_pattern/install';
|
||||
import { saveKibanaAssetsRefs } from '../../packages/install';
|
||||
|
@ -159,6 +165,7 @@ export async function installKibanaAssetsAndReferences({
|
|||
paths,
|
||||
installedPkg,
|
||||
spaceId,
|
||||
assetTags,
|
||||
}: {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
savedObjectsImporter: Pick<ISavedObjectsImporter, 'import' | 'resolveImportErrors'>;
|
||||
|
@ -170,6 +177,7 @@ export async function installKibanaAssetsAndReferences({
|
|||
paths: string[];
|
||||
installedPkg?: SavedObject<Installation>;
|
||||
spaceId: string;
|
||||
assetTags?: PackageSpecTags[];
|
||||
}) {
|
||||
const kibanaAssets = await getKibanaAssets(paths);
|
||||
if (installedPkg) await deleteKibanaSavedObjectsAssets({ savedObjectsClient, installedPkg });
|
||||
|
@ -195,6 +203,7 @@ export async function installKibanaAssetsAndReferences({
|
|||
pkgName,
|
||||
spaceId,
|
||||
importedAssets,
|
||||
assetTags,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -15,6 +15,17 @@ describe('tagKibanaAssets', () => {
|
|||
create: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const FOO_TAG_ID = 'fleet-shared-tag-test-pkg-b84ed8ed-a7b1-502f-83f6-90132e68adef-default';
|
||||
const BAR_TAG_ID = 'fleet-shared-tag-test-pkg-e8d5cf6d-de0f-5e77-9aa3-91093cdfbf62-default';
|
||||
const MY_CUSTOM_TAG_ID = 'fleet-shared-tag-test-pkg-cdc93456-cbdd-5560-a16c-117190be14ca-default';
|
||||
|
||||
const managedTagPayloadArg1 = {
|
||||
color: '#0077CC',
|
||||
description: '',
|
||||
name: 'Managed',
|
||||
};
|
||||
const managedTagPayloadArg2 = { id: 'fleet-managed-default', overwrite: true, refresh: false };
|
||||
|
||||
beforeEach(() => {
|
||||
savedObjectTagAssignmentService.updateTagAssignments.mockReset();
|
||||
savedObjectTagClient.get.mockReset();
|
||||
|
@ -42,7 +53,7 @@ describe('tagKibanaAssets', () => {
|
|||
{
|
||||
name: 'Managed',
|
||||
description: '',
|
||||
color: '#FFFFFF',
|
||||
color: '#0077CC',
|
||||
},
|
||||
{ id: 'fleet-managed-default', overwrite: true, refresh: false }
|
||||
);
|
||||
|
@ -50,7 +61,7 @@ describe('tagKibanaAssets', () => {
|
|||
{
|
||||
name: 'System',
|
||||
description: '',
|
||||
color: '#FFFFFF',
|
||||
color: '#4DD2CA',
|
||||
},
|
||||
{ id: 'fleet-pkg-system-default', overwrite: true, refresh: false }
|
||||
);
|
||||
|
@ -188,7 +199,7 @@ describe('tagKibanaAssets', () => {
|
|||
{
|
||||
name: 'Managed',
|
||||
description: '',
|
||||
color: '#FFFFFF',
|
||||
color: '#0077CC',
|
||||
},
|
||||
{ id: 'fleet-managed-default', overwrite: true, refresh: false }
|
||||
);
|
||||
|
@ -197,7 +208,7 @@ describe('tagKibanaAssets', () => {
|
|||
{
|
||||
name: 'System',
|
||||
description: '',
|
||||
color: '#FFFFFF',
|
||||
color: '#4DD2CA',
|
||||
},
|
||||
{ id: 'fleet-pkg-system-default', overwrite: true, refresh: false }
|
||||
);
|
||||
|
@ -235,7 +246,7 @@ describe('tagKibanaAssets', () => {
|
|||
{
|
||||
name: 'Managed',
|
||||
description: '',
|
||||
color: '#FFFFFF',
|
||||
color: '#0077CC',
|
||||
},
|
||||
{ id: 'fleet-managed-default', overwrite: true, refresh: false }
|
||||
);
|
||||
|
@ -244,7 +255,7 @@ describe('tagKibanaAssets', () => {
|
|||
{
|
||||
name: 'System',
|
||||
description: '',
|
||||
color: '#FFFFFF',
|
||||
color: '#4DD2CA',
|
||||
},
|
||||
{ id: 'system', overwrite: true, refresh: false }
|
||||
);
|
||||
|
@ -287,4 +298,506 @@ describe('tagKibanaAssets', () => {
|
|||
refresh: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create tags based on assetTags obtained from packageInfo and apply them to all taggable assets of that type', async () => {
|
||||
savedObjectTagClient.get.mockRejectedValue(new Error('not found'));
|
||||
savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) =>
|
||||
Promise.resolve({ id: name.toLowerCase(), name })
|
||||
);
|
||||
const kibanaAssets = {
|
||||
dashboard: [
|
||||
{ id: 'dashboard1', type: 'dashboard' },
|
||||
{ id: 'dashboard2', type: 'dashboard' },
|
||||
{ id: 'search_id1', type: 'search' },
|
||||
{ id: 'search_id2', type: 'search' },
|
||||
],
|
||||
} as any;
|
||||
const assetTags = [
|
||||
{
|
||||
text: 'Foo',
|
||||
asset_types: ['dashboard'],
|
||||
},
|
||||
];
|
||||
await tagKibanaAssets({
|
||||
savedObjectTagAssignmentService,
|
||||
savedObjectTagClient,
|
||||
kibanaAssets,
|
||||
pkgTitle: 'TestPackage',
|
||||
pkgName: 'test-pkg',
|
||||
spaceId: 'default',
|
||||
importedAssets: [],
|
||||
assetTags,
|
||||
});
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledTimes(3);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
managedTagPayloadArg1,
|
||||
managedTagPayloadArg2
|
||||
);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
{
|
||||
color: '#4DD2CA',
|
||||
description: '',
|
||||
name: 'TestPackage',
|
||||
},
|
||||
{ id: 'fleet-pkg-test-pkg-default', overwrite: true, refresh: false }
|
||||
);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
{
|
||||
color: expect.any(String),
|
||||
description: 'Tag defined in package-spec',
|
||||
name: 'Foo',
|
||||
},
|
||||
{
|
||||
id: FOO_TAG_ID,
|
||||
overwrite: true,
|
||||
refresh: false,
|
||||
}
|
||||
);
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledTimes(3);
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({
|
||||
assign: [
|
||||
{
|
||||
id: 'dashboard1',
|
||||
type: 'dashboard',
|
||||
},
|
||||
{
|
||||
id: 'dashboard2',
|
||||
type: 'dashboard',
|
||||
},
|
||||
{
|
||||
id: 'search_id1',
|
||||
type: 'search',
|
||||
},
|
||||
{
|
||||
id: 'search_id2',
|
||||
type: 'search',
|
||||
},
|
||||
],
|
||||
refresh: false,
|
||||
tags: ['fleet-managed-default', 'fleet-pkg-test-pkg-default'],
|
||||
unassign: [],
|
||||
});
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({
|
||||
assign: [
|
||||
{
|
||||
id: 'dashboard1',
|
||||
type: 'dashboard',
|
||||
},
|
||||
],
|
||||
refresh: false,
|
||||
tags: [FOO_TAG_ID],
|
||||
unassign: [],
|
||||
});
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({
|
||||
assign: [
|
||||
{
|
||||
id: 'dashboard2',
|
||||
type: 'dashboard',
|
||||
},
|
||||
],
|
||||
refresh: false,
|
||||
tags: [FOO_TAG_ID],
|
||||
unassign: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create tags based on assetTags obtained from packageInfo and apply them to the specified taggable assets ids', async () => {
|
||||
savedObjectTagClient.get.mockRejectedValue(new Error('not found'));
|
||||
savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) =>
|
||||
Promise.resolve({ id: name.toLowerCase(), name })
|
||||
);
|
||||
const kibanaAssets = {
|
||||
dashboard: [
|
||||
{ id: 'dashboard1', type: 'dashboard' },
|
||||
{ id: 'dashboard2', type: 'dashboard' },
|
||||
{ id: 'search_id1', type: 'search' },
|
||||
{ id: 'search_id2', type: 'search' },
|
||||
],
|
||||
} as any;
|
||||
const assetTags = [{ text: 'Bar', asset_ids: ['dashboard1', 'search_id1'] }];
|
||||
await tagKibanaAssets({
|
||||
savedObjectTagAssignmentService,
|
||||
savedObjectTagClient,
|
||||
kibanaAssets,
|
||||
pkgTitle: 'TestPackage',
|
||||
pkgName: 'test-pkg',
|
||||
spaceId: 'default',
|
||||
importedAssets: [],
|
||||
assetTags,
|
||||
});
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledTimes(3);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
managedTagPayloadArg1,
|
||||
managedTagPayloadArg2
|
||||
);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
{
|
||||
color: '#4DD2CA',
|
||||
description: '',
|
||||
name: 'TestPackage',
|
||||
},
|
||||
{ id: 'fleet-pkg-test-pkg-default', overwrite: true, refresh: false }
|
||||
);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
{
|
||||
color: expect.any(String),
|
||||
description: 'Tag defined in package-spec',
|
||||
name: 'Bar',
|
||||
},
|
||||
{
|
||||
id: BAR_TAG_ID,
|
||||
overwrite: true,
|
||||
refresh: false,
|
||||
}
|
||||
);
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledTimes(3);
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({
|
||||
assign: [
|
||||
{
|
||||
id: 'dashboard1',
|
||||
type: 'dashboard',
|
||||
},
|
||||
{
|
||||
id: 'dashboard2',
|
||||
type: 'dashboard',
|
||||
},
|
||||
{
|
||||
id: 'search_id1',
|
||||
type: 'search',
|
||||
},
|
||||
{
|
||||
id: 'search_id2',
|
||||
type: 'search',
|
||||
},
|
||||
],
|
||||
refresh: false,
|
||||
tags: ['fleet-managed-default', 'fleet-pkg-test-pkg-default'],
|
||||
unassign: [],
|
||||
});
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({
|
||||
assign: [
|
||||
{
|
||||
id: 'dashboard1',
|
||||
type: 'dashboard',
|
||||
},
|
||||
],
|
||||
refresh: false,
|
||||
tags: [BAR_TAG_ID],
|
||||
unassign: [],
|
||||
});
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({
|
||||
assign: [
|
||||
{
|
||||
id: 'search_id1',
|
||||
type: 'search',
|
||||
},
|
||||
],
|
||||
refresh: false,
|
||||
tags: [BAR_TAG_ID],
|
||||
unassign: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create tags based on assetTags obtained from packageInfo and apply them to all the specified assets', async () => {
|
||||
savedObjectTagClient.get.mockRejectedValue(new Error('not found'));
|
||||
savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) =>
|
||||
Promise.resolve({ id: name.toLowerCase(), name })
|
||||
);
|
||||
const kibanaAssets = {
|
||||
dashboard: [
|
||||
{ id: 'dashboard1', type: 'dashboard' },
|
||||
{ id: 'dashboard2', type: 'dashboard' },
|
||||
{ id: 'search_id1', type: 'search' },
|
||||
],
|
||||
} as any;
|
||||
const assetTags = [
|
||||
{
|
||||
text: 'myCustomTag',
|
||||
asset_types: ['search'],
|
||||
asset_ids: ['dashboard2'],
|
||||
},
|
||||
];
|
||||
await tagKibanaAssets({
|
||||
savedObjectTagAssignmentService,
|
||||
savedObjectTagClient,
|
||||
kibanaAssets,
|
||||
pkgTitle: 'TestPackage',
|
||||
pkgName: 'test-pkg',
|
||||
spaceId: 'default',
|
||||
importedAssets: [],
|
||||
assetTags,
|
||||
});
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledTimes(3);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
managedTagPayloadArg1,
|
||||
managedTagPayloadArg2
|
||||
);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
{
|
||||
color: '#4DD2CA',
|
||||
description: '',
|
||||
name: 'TestPackage',
|
||||
},
|
||||
{ id: 'fleet-pkg-test-pkg-default', overwrite: true, refresh: false }
|
||||
);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
{
|
||||
color: expect.any(String),
|
||||
description: 'Tag defined in package-spec',
|
||||
name: 'myCustomTag',
|
||||
},
|
||||
{
|
||||
id: MY_CUSTOM_TAG_ID,
|
||||
overwrite: true,
|
||||
refresh: false,
|
||||
}
|
||||
);
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({
|
||||
assign: [
|
||||
{
|
||||
id: 'dashboard1',
|
||||
type: 'dashboard',
|
||||
},
|
||||
{
|
||||
id: 'dashboard2',
|
||||
type: 'dashboard',
|
||||
},
|
||||
{
|
||||
id: 'search_id1',
|
||||
type: 'search',
|
||||
},
|
||||
],
|
||||
refresh: false,
|
||||
tags: ['fleet-managed-default', 'fleet-pkg-test-pkg-default'],
|
||||
unassign: [],
|
||||
});
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({
|
||||
assign: [
|
||||
{
|
||||
id: 'search_id1',
|
||||
type: 'search',
|
||||
},
|
||||
],
|
||||
refresh: false,
|
||||
tags: [MY_CUSTOM_TAG_ID],
|
||||
unassign: [],
|
||||
});
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({
|
||||
assign: [
|
||||
{
|
||||
id: 'dashboard2',
|
||||
type: 'dashboard',
|
||||
},
|
||||
],
|
||||
refresh: false,
|
||||
tags: [MY_CUSTOM_TAG_ID],
|
||||
unassign: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call savedObjectTagClient.create if the tag id already exists', async () => {
|
||||
savedObjectTagClient.get.mockResolvedValue({ name: 'existingTag', color: '', description: '' });
|
||||
savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) =>
|
||||
Promise.resolve({ id: name.toLowerCase(), name })
|
||||
);
|
||||
const kibanaAssets = {
|
||||
dashboard: [
|
||||
{ id: 'dashboard1', type: 'dashboard' },
|
||||
{ id: 'dashboard2', type: 'dashboard' },
|
||||
{ id: 'search_id1', type: 'search' },
|
||||
{ id: 'search_id2', type: 'search' },
|
||||
],
|
||||
} as any;
|
||||
const assetTags = [
|
||||
{
|
||||
text: 'Foo',
|
||||
asset_types: ['dashboard'],
|
||||
},
|
||||
{ text: 'Bar', asset_ids: ['dashboard1', 'search_id1'] },
|
||||
{
|
||||
text: 'myCustomTag',
|
||||
asset_types: ['search'],
|
||||
asset_ids: ['dashboard2'],
|
||||
},
|
||||
];
|
||||
await tagKibanaAssets({
|
||||
savedObjectTagAssignmentService,
|
||||
savedObjectTagClient,
|
||||
kibanaAssets,
|
||||
pkgTitle: 'TestPackage',
|
||||
pkgName: 'test-pkg',
|
||||
spaceId: 'default',
|
||||
importedAssets: [],
|
||||
assetTags,
|
||||
});
|
||||
expect(savedObjectTagClient.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call savedObjectTagClient.create if the tag id is the same but different case', async () => {
|
||||
savedObjectTagClient.get.mockImplementation(async (id: string) => {
|
||||
if (id === FOO_TAG_ID) {
|
||||
return {
|
||||
name: 'Foo',
|
||||
id,
|
||||
color: '',
|
||||
description: '',
|
||||
};
|
||||
} else throw new Error('not found');
|
||||
});
|
||||
savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) =>
|
||||
Promise.resolve({ id: name.toLowerCase(), name })
|
||||
);
|
||||
const kibanaAssets = {
|
||||
dashboard: [
|
||||
{ id: 'dashboard1', type: 'dashboard' },
|
||||
{ id: 'dashboard2', type: 'dashboard' },
|
||||
{ id: 'search_id1', type: 'search' },
|
||||
{ id: 'search_id2', type: 'search' },
|
||||
],
|
||||
} as any;
|
||||
const assetTags = [
|
||||
{
|
||||
text: 'foo',
|
||||
asset_types: ['dashboard'],
|
||||
},
|
||||
];
|
||||
await tagKibanaAssets({
|
||||
savedObjectTagAssignmentService,
|
||||
savedObjectTagClient,
|
||||
kibanaAssets,
|
||||
pkgTitle: 'TestPackage',
|
||||
pkgName: 'test-pkg',
|
||||
spaceId: 'default',
|
||||
importedAssets: [],
|
||||
assetTags,
|
||||
});
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledTimes(2);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
managedTagPayloadArg1,
|
||||
managedTagPayloadArg2
|
||||
);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'TestPackage',
|
||||
description: '',
|
||||
color: '#4DD2CA',
|
||||
},
|
||||
{ id: 'fleet-pkg-test-pkg-default', overwrite: true, refresh: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect SecuritySolution tags', async () => {
|
||||
savedObjectTagClient.get.mockRejectedValue(new Error('not found'));
|
||||
savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) =>
|
||||
Promise.resolve({ id: name.toLowerCase(), name })
|
||||
);
|
||||
const kibanaAssets = {
|
||||
dashboard: [
|
||||
{ id: 'dashboard1', type: 'dashboard' },
|
||||
{ id: 'dashboard2', type: 'dashboard' },
|
||||
{ id: 'search_id1', type: 'search' },
|
||||
{ id: 'search_id2', type: 'search' },
|
||||
],
|
||||
} as any;
|
||||
const assetTags = [
|
||||
{
|
||||
text: 'SecuritySolution',
|
||||
asset_types: ['dashboard'],
|
||||
},
|
||||
];
|
||||
await tagKibanaAssets({
|
||||
savedObjectTagAssignmentService,
|
||||
savedObjectTagClient,
|
||||
kibanaAssets,
|
||||
pkgTitle: 'TestPackage',
|
||||
pkgName: 'test-pkg',
|
||||
spaceId: 'default',
|
||||
importedAssets: [],
|
||||
assetTags,
|
||||
});
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
managedTagPayloadArg1,
|
||||
managedTagPayloadArg2
|
||||
);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
{
|
||||
color: '#4DD2CA',
|
||||
description: '',
|
||||
name: 'TestPackage',
|
||||
},
|
||||
{ id: 'fleet-pkg-test-pkg-default', overwrite: true, refresh: false }
|
||||
);
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledWith(
|
||||
{
|
||||
color: expect.any(String),
|
||||
description: 'Tag defined in package-spec',
|
||||
name: 'SecuritySolution',
|
||||
},
|
||||
{ id: 'SecuritySolution', overwrite: true, refresh: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('should only call savedObjectTagClient.create for basic tags if there are no assetTags to assign', async () => {
|
||||
savedObjectTagClient.get.mockRejectedValue(new Error('not found'));
|
||||
savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) =>
|
||||
Promise.resolve({ id: name.toLowerCase(), name })
|
||||
);
|
||||
const kibanaAssets = {
|
||||
dashboard: [
|
||||
{ id: 'dashboard1', type: 'dashboard' },
|
||||
{ id: 'dashboard2', type: 'dashboard' },
|
||||
{ id: 'search_id1', type: 'search' },
|
||||
{ id: 'search_id2', type: 'search' },
|
||||
],
|
||||
} as any;
|
||||
|
||||
await tagKibanaAssets({
|
||||
savedObjectTagAssignmentService,
|
||||
savedObjectTagClient,
|
||||
kibanaAssets,
|
||||
pkgTitle: 'TestPackage',
|
||||
pkgName: 'test-pkg',
|
||||
spaceId: 'default',
|
||||
importedAssets: [],
|
||||
assetTags: [],
|
||||
});
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledTimes(2);
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should only call savedObjectTagClient.create for basic tags if there are no taggable assetTags', async () => {
|
||||
savedObjectTagClient.get.mockRejectedValue(new Error('not found'));
|
||||
savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) =>
|
||||
Promise.resolve({ id: name.toLowerCase(), name })
|
||||
);
|
||||
const kibanaAssets = {
|
||||
dashboard: [
|
||||
{ id: 'dashboard1', type: 'dashboard' },
|
||||
{ id: 'dashboard2', type: 'dashboard' },
|
||||
{ id: 'search_id1', type: 'search' },
|
||||
{ id: 'search_id2', type: 'search' },
|
||||
],
|
||||
} as any;
|
||||
const assetTags = [
|
||||
{
|
||||
text: 'Foo',
|
||||
asset_types: ['security_rule', 'index_pattern'],
|
||||
},
|
||||
];
|
||||
|
||||
await tagKibanaAssets({
|
||||
savedObjectTagAssignmentService,
|
||||
savedObjectTagClient,
|
||||
kibanaAssets,
|
||||
pkgTitle: 'TestPackage',
|
||||
pkgName: 'test-pkg',
|
||||
spaceId: 'default',
|
||||
importedAssets: [],
|
||||
assetTags,
|
||||
});
|
||||
expect(savedObjectTagClient.create).toHaveBeenCalledTimes(3);
|
||||
expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,24 +5,77 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
import { uniqBy } from 'lodash';
|
||||
import type { SavedObjectsImportSuccess } from '@kbn/core-saved-objects-common';
|
||||
import { taggableTypes } from '@kbn/saved-objects-tagging-plugin/common/constants';
|
||||
import type { IAssignmentService, ITagsClient } from '@kbn/saved-objects-tagging-plugin/server';
|
||||
|
||||
import type { KibanaAssetType } from '../../../../../common';
|
||||
import type { PackageSpecTags } from '../../../../types';
|
||||
|
||||
import { appContextService } from '../../../app_context';
|
||||
|
||||
import type { ArchiveAsset } from './install';
|
||||
import { KibanaSavedObjectTypeMapping } from './install';
|
||||
|
||||
const TAG_COLOR = '#FFFFFF';
|
||||
interface ObjectReference {
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
interface PackageSpecTagsAssets {
|
||||
tagId: string;
|
||||
assets: ObjectReference[];
|
||||
}
|
||||
|
||||
interface GroupedAssets {
|
||||
[assetId: string]: { type: string; tags: string[] };
|
||||
}
|
||||
|
||||
const MANAGED_TAG_COLOR = '#0077CC';
|
||||
const PACKAGE_TAG_COLOR = '#4DD2CA';
|
||||
const MANAGED_TAG_NAME = 'Managed';
|
||||
const LEGACY_MANAGED_TAG_ID = 'managed';
|
||||
const SECURITY_SOLUTION_TAG_ID = 'SecuritySolution';
|
||||
|
||||
// the tag service only accepts 6-digits hex colors
|
||||
const TAG_COLORS = [
|
||||
'#FEC514',
|
||||
'#F583B7',
|
||||
'#F04E98',
|
||||
'#00BFB3',
|
||||
'#FEC514',
|
||||
'#BADA55',
|
||||
'#FFA500',
|
||||
'#9696F1',
|
||||
'#D36086',
|
||||
'#54B399',
|
||||
'#AAA8A5',
|
||||
'#A0A0A0',
|
||||
];
|
||||
|
||||
const getManagedTagId = (spaceId: string) => `fleet-managed-${spaceId}`;
|
||||
const getPackageTagId = (spaceId: string, pkgName: string) => `fleet-pkg-${pkgName}-${spaceId}`;
|
||||
const getLegacyPackageTagId = (pkgName: string) => pkgName;
|
||||
|
||||
/*
|
||||
This function is exported via fleet/plugin.ts to make it available to other plugins
|
||||
The `SecuritySolution` tag is a special case that needs to be handled separately
|
||||
In that case simply return `SecuritySolution`
|
||||
*/
|
||||
export const getPackageSpecTagId = (spaceId: string, pkgName: string, tagName: string) => {
|
||||
if (tagName.toLowerCase() === SECURITY_SOLUTION_TAG_ID.toLowerCase())
|
||||
return SECURITY_SOLUTION_TAG_ID;
|
||||
// UUID v5 needs a namespace (uuid.DNS) to generate a predictable uuid
|
||||
const uniqueId = uuidv5(`${tagName.toLowerCase()}`, uuidv5.DNS);
|
||||
return `fleet-shared-tag-${pkgName}-${uniqueId}-${spaceId}`;
|
||||
};
|
||||
|
||||
const getRandomColor = () => {
|
||||
const randomizedIndex = Math.floor(Math.random() * TAG_COLORS.length);
|
||||
return TAG_COLORS[randomizedIndex];
|
||||
};
|
||||
|
||||
interface TagAssetsParams {
|
||||
savedObjectTagAssignmentService: IAssignmentService;
|
||||
savedObjectTagClient: ITagsClient;
|
||||
|
@ -31,40 +84,61 @@ interface TagAssetsParams {
|
|||
pkgName: string;
|
||||
spaceId: string;
|
||||
importedAssets: SavedObjectsImportSuccess[];
|
||||
assetTags?: PackageSpecTags[];
|
||||
}
|
||||
|
||||
export async function tagKibanaAssets(opts: TagAssetsParams) {
|
||||
const { savedObjectTagAssignmentService, kibanaAssets, importedAssets } = opts;
|
||||
|
||||
const getNewId = (assetId: string) =>
|
||||
importedAssets.find((imported) => imported.id === assetId)?.destinationId ?? assetId;
|
||||
const taggableAssets = getTaggableAssets(kibanaAssets).map((asset) => ({
|
||||
...asset,
|
||||
id: getNewId(asset.id),
|
||||
}));
|
||||
|
||||
// no assets to tag
|
||||
if (taggableAssets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [managedTagId, packageTagId] = await Promise.all([
|
||||
ensureManagedTag(opts),
|
||||
ensurePackageTag(opts),
|
||||
]);
|
||||
|
||||
try {
|
||||
await savedObjectTagAssignmentService.updateTagAssignments({
|
||||
tags: [managedTagId, packageTagId],
|
||||
assign: taggableAssets,
|
||||
unassign: [],
|
||||
refresh: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
appContextService.getLogger().warn(error.message);
|
||||
return;
|
||||
if (taggableAssets.length > 0) {
|
||||
const [managedTagId, packageTagId] = await Promise.all([
|
||||
ensureManagedTag(opts),
|
||||
ensurePackageTag(opts),
|
||||
]);
|
||||
try {
|
||||
await savedObjectTagAssignmentService.updateTagAssignments({
|
||||
tags: [managedTagId, packageTagId],
|
||||
assign: taggableAssets,
|
||||
unassign: [],
|
||||
refresh: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
appContextService.getLogger().warn(error.message);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const packageSpecAssets = await getPackageSpecTags(taggableAssets, opts);
|
||||
const groupedAssets = groupByAssetId(packageSpecAssets);
|
||||
|
||||
if (Object.entries(groupedAssets).length > 0) {
|
||||
await Promise.all(
|
||||
Object.entries(groupedAssets).map(async ([assetId, asset]) => {
|
||||
try {
|
||||
await savedObjectTagAssignmentService.updateTagAssignments({
|
||||
tags: asset.tags,
|
||||
assign: [{ id: assetId, type: asset.type }],
|
||||
unassign: [],
|
||||
refresh: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
appContextService.getLogger().warn(error.message);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -100,7 +174,7 @@ async function ensureManagedTag(
|
|||
{
|
||||
name: MANAGED_TAG_NAME,
|
||||
description: '',
|
||||
color: TAG_COLOR,
|
||||
color: MANAGED_TAG_COLOR,
|
||||
},
|
||||
{ id: managedTagId, overwrite: true, refresh: false }
|
||||
);
|
||||
|
@ -127,10 +201,92 @@ async function ensurePackageTag(
|
|||
{
|
||||
name: pkgTitle,
|
||||
description: '',
|
||||
color: TAG_COLOR,
|
||||
color: PACKAGE_TAG_COLOR,
|
||||
},
|
||||
{ id: packageTagId, overwrite: true, refresh: false }
|
||||
);
|
||||
|
||||
return packageTagId;
|
||||
}
|
||||
|
||||
// Ensure that asset tags coming from the kibana/tags.yml file are correctly parsed and created
|
||||
async function getPackageSpecTags(
|
||||
taggableAssets: ArchiveAsset[],
|
||||
opts: Pick<TagAssetsParams, 'spaceId' | 'savedObjectTagClient' | 'pkgName' | 'assetTags'>
|
||||
): Promise<PackageSpecTagsAssets[]> {
|
||||
const { spaceId, savedObjectTagClient, pkgName, assetTags } = opts;
|
||||
if (!assetTags || assetTags?.length === 0) return [];
|
||||
|
||||
const assetsWithTags = await Promise.all(
|
||||
assetTags.map(async (tag) => {
|
||||
const uniqueTagId = getPackageSpecTagId(spaceId, pkgName, tag.text);
|
||||
const existingPackageSpecTag = await savedObjectTagClient.get(uniqueTagId).catch(() => {});
|
||||
|
||||
if (!existingPackageSpecTag) {
|
||||
await savedObjectTagClient.create(
|
||||
{
|
||||
name: tag.text,
|
||||
description: 'Tag defined in package-spec',
|
||||
color: getRandomColor(),
|
||||
},
|
||||
{ id: uniqueTagId, overwrite: true, refresh: false }
|
||||
);
|
||||
}
|
||||
const assetTypes = getAssetTypesObjectReferences(tag?.asset_types, taggableAssets);
|
||||
const assetIds = getAssetIdsObjectReferences(tag?.asset_ids, taggableAssets);
|
||||
const totAssetsToAssign = assetTypes.concat(assetIds);
|
||||
const assetsToAssign = totAssetsToAssign.length > 0 ? uniqBy(totAssetsToAssign, 'id') : [];
|
||||
|
||||
return { tagId: uniqueTagId, assets: assetsToAssign };
|
||||
})
|
||||
);
|
||||
return assetsWithTags;
|
||||
}
|
||||
|
||||
// Get all the assets of types defined in tag.asset_types from taggable kibanaAssets
|
||||
const getAssetTypesObjectReferences = (
|
||||
assetTypes: string[] | undefined,
|
||||
taggableAssets: ArchiveAsset[]
|
||||
): ObjectReference[] => {
|
||||
if (!assetTypes || assetTypes.length === 0) return [];
|
||||
|
||||
return taggableAssets
|
||||
.filter((taggable) => assetTypes.includes(taggable.type))
|
||||
.map((assetType) => {
|
||||
return { type: assetType.type, id: assetType.id };
|
||||
});
|
||||
};
|
||||
|
||||
// Get the references to ids defined in tag.asset_ids from taggable kibanaAssets
|
||||
const getAssetIdsObjectReferences = (
|
||||
assetIds: string[] | undefined,
|
||||
taggableAssets: ArchiveAsset[]
|
||||
): ObjectReference[] => {
|
||||
if (!assetIds || assetIds.length === 0) return [];
|
||||
|
||||
return taggableAssets
|
||||
.filter((taggable) => assetIds.includes(taggable.id))
|
||||
.map((assetType) => {
|
||||
return { type: assetType.type, id: assetType.id };
|
||||
});
|
||||
};
|
||||
|
||||
// Utility function that groups the assets by asset id
|
||||
// It makes easier to update the tags in batches
|
||||
const groupByAssetId = (packageSpecsAssets: PackageSpecTagsAssets[]): GroupedAssets => {
|
||||
if (packageSpecsAssets.length === 0) return {};
|
||||
|
||||
const groupedAssets: GroupedAssets = {};
|
||||
|
||||
packageSpecsAssets.forEach(({ tagId, assets }) => {
|
||||
assets.forEach((asset) => {
|
||||
const { id } = asset;
|
||||
|
||||
if (!groupedAssets[id]) {
|
||||
groupedAssets[id] = { type: asset.type, tags: [] };
|
||||
}
|
||||
groupedAssets[id].tags.push(tagId);
|
||||
});
|
||||
});
|
||||
return groupedAssets;
|
||||
};
|
||||
|
|
|
@ -144,6 +144,7 @@ export async function _installPackage({
|
|||
installedPkg,
|
||||
logger,
|
||||
spaceId,
|
||||
assetTags: packageInfo?.asset_tags,
|
||||
})
|
||||
);
|
||||
// Necessary to avoid async promise rejection warning
|
||||
|
|
|
@ -91,6 +91,7 @@ export type {
|
|||
PackageList,
|
||||
InstallationInfo,
|
||||
ActionStatusOptions,
|
||||
PackageSpecTags,
|
||||
} from '../../common/types';
|
||||
export { ElasticsearchAssetType, KibanaAssetType, KibanaSavedObjectType } from '../../common/types';
|
||||
export { dataTypes } from '../../common/constants';
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import expect from '@kbn/expect';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
|
||||
import { skipIfNoDockerRegistry } from '../../helpers';
|
||||
import { setupFleetAndAgents } from '../agents/services';
|
||||
|
@ -65,7 +67,7 @@ export default function (providerContext: FtrProviderContext) {
|
|||
const deleteSpace = async (spaceId: string) => {
|
||||
await supertest.delete(`/api/spaces/space/${spaceId}`).set('kbn-xsrf', 'xxxx').send();
|
||||
};
|
||||
describe('asset tagging', () => {
|
||||
describe('Assets tagging', () => {
|
||||
skipIfNoDockerRegistry(providerContext);
|
||||
setupFleetAndAgents(providerContext);
|
||||
|
||||
|
@ -151,5 +153,49 @@ export default function (providerContext: FtrProviderContext) {
|
|||
expect(pkgTag).equal(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Handles presence of tags inside integration package', async () => {
|
||||
const testPackage = 'assets_with_tags';
|
||||
const testPackageVersion = '0.1.0';
|
||||
// tag corresponding to `OnlySomeAssets`
|
||||
const ONLY_SOME_ASSETS_TAG = `fleet-shared-tag-${testPackage}-ef823f10-b5af-5fcb-95da-2340a5257599-default`;
|
||||
// tag corresponding to `MixedTypesTag`
|
||||
const MIXED_TYPES_TAG = `fleet-shared-tag-${testPackage}-ef823f10-b5af-5fcb-95da-2340a5257599-default`;
|
||||
|
||||
before(async () => {
|
||||
if (!server.enabled) return;
|
||||
|
||||
const testPkgArchiveZip = path.join(
|
||||
path.dirname(__filename),
|
||||
'../fixtures/direct_upload_packages/assets_with_tags-0.1.0.zip'
|
||||
);
|
||||
const buf = fs.readFileSync(testPkgArchiveZip);
|
||||
await supertest
|
||||
.post(`/api/fleet/epm/packages`)
|
||||
.set('kbn-xsrf', 'xxxx')
|
||||
.type('application/zip')
|
||||
.send(buf)
|
||||
.expect(200);
|
||||
});
|
||||
after(async () => {
|
||||
if (!server.enabled) return;
|
||||
await uninstallPackage(testPackage, testPackageVersion);
|
||||
await deleteTag('managed');
|
||||
});
|
||||
|
||||
it('Should create tags based on package spec tags', async () => {
|
||||
const managedTag = await getTag('fleet-managed-default');
|
||||
expect(managedTag).not.equal(undefined);
|
||||
|
||||
const securitySolutionTag = await getTag('SecuritySolution');
|
||||
expect(securitySolutionTag).not.equal(undefined);
|
||||
|
||||
const pkgTag1 = await getTag(ONLY_SOME_ASSETS_TAG);
|
||||
expect(pkgTag1).equal(undefined);
|
||||
|
||||
const pkgTag2 = await getTag(MIXED_TYPES_TAG);
|
||||
expect(pkgTag2).equal(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue