diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index a861a97df357c..88ff7d2fe1414 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -44039,6 +44039,143 @@ paths: x-metaTags: - content: Kibana, Elastic Cloud Serverless name: product_name + /api/fleet/epm/packages/_bulk_namespace_customization: + post: + description: |- + **Spaces method and path for this operation:** + +
post /s/{space_id}/api/fleet/epm/packages/_bulk_namespace_customization
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + Enable or disable namespace-level index template customization for a list of packages in one call. Use this for IaC-style declarative flows.

[Required authorization] Route required privileges: integrations-all AND fleet-agent-policies-all. + operationId: post-fleet-epm-packages-bulk-namespace-customization + parameters: + - description: A required header to protect against CSRF attacks + in: header + name: kbn-xsrf + required: true + schema: + example: 'true' + type: string + requestBody: + content: + application/json: + examples: + bulkNamespaceCustomizationRequest: + value: + disable: + - dev + enable: + - production + - staging + packages: + - system + - nginx + schema: + additionalProperties: false + type: object + properties: + disable: + description: Namespaces to disable namespace-level customization for on each package. + items: + type: string + maxItems: 100 + type: array + enable: + description: Namespaces to enable namespace-level customization for on each package. + items: + type: string + maxItems: 100 + type: array + packages: + description: Package names to apply the customization changes to. + items: + type: string + maxItems: 1000 + minItems: 1 + type: array + required: + - packages + responses: + '200': + content: + application/json: + examples: + successResponse: + value: + items: + - name: system + namespace_customization_enabled_for: + - production + - staging + success: true + - error: Package nginx is not installed + name: nginx + success: false + schema: + additionalProperties: false + type: object + properties: + items: + items: + additionalProperties: false + type: object + properties: + error: + type: string + name: + type: string + namespace_customization_enabled_for: + description: 'The opt-in list on the package. Returned whenever the package is installed: the new list on success, or the unchanged list when the request is rejected (for example, because of a namespace-prefix restriction).' + items: + type: string + maxItems: 100 + type: array + success: + type: boolean + required: + - name + - success + maxItems: 1000 + type: array + required: + - items + description: 'OK: A successful request.' + '400': + content: + application/json: + examples: + badRequestResponse: + value: + error: Bad Request + message: 'Namespaces must not appear in both enable and disable: production' + statusCode: 400 + schema: + additionalProperties: false + description: Generic Error + type: object + properties: + attributes: + nullable: true + error: + type: string + errorType: + type: string + message: + type: string + statusCode: + type: number + required: + - message + - attributes + description: A bad request. + summary: Bulk enable/disable namespace-level customization for packages + tags: + - Elastic Package Manager (EPM) + x-metaTags: + - content: Kibana, Elastic Cloud Serverless + name: product_name /api/fleet/epm/packages/_bulk_rollback: post: description: |- @@ -45669,6 +45806,12 @@ paths: content: application/json: examples: + putUpdatePackageNamespaceCustomizationExample: + description: Enable namespace-level customization for the `production` and `staging` namespaces + value: + namespace_customization_enabled_for: + - production + - staging putUpdatePackageRequestExample: description: Update keep_policies_up_to_date setting for a package value: @@ -45679,8 +45822,12 @@ paths: properties: keepPoliciesUpToDate: type: boolean - required: - - keepPoliciesUpToDate + namespace_customization_enabled_for: + description: Namespaces for which namespace-level customization is enabled on this package. + items: + type: string + maxItems: 100 + type: array responses: '200': content: @@ -47300,6 +47447,12 @@ paths: content: application/json: examples: + putUpdatePackageNamespaceCustomizationExample: + description: Enable namespace-level customization for the `production` and `staging` namespaces + value: + namespace_customization_enabled_for: + - production + - staging putUpdatePackageRequestExample: description: Update keep_policies_up_to_date setting for a package value: @@ -47310,8 +47463,12 @@ paths: properties: keepPoliciesUpToDate: type: boolean - required: - - keepPoliciesUpToDate + namespace_customization_enabled_for: + description: Namespaces for which namespace-level customization is enabled on this package. + items: + type: string + maxItems: 100 + type: array responses: '200': content: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index f40d70a21c56c..06f63322fb703 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -47168,6 +47168,143 @@ paths: x-metaTags: - content: Kibana name: product_name + /api/fleet/epm/packages/_bulk_namespace_customization: + post: + description: |- + **Spaces method and path for this operation:** + +
post /s/{space_id}/api/fleet/epm/packages/_bulk_namespace_customization
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + Enable or disable namespace-level index template customization for a list of packages in one call. Use this for IaC-style declarative flows.

[Required authorization] Route required privileges: integrations-all AND fleet-agent-policies-all. + operationId: post-fleet-epm-packages-bulk-namespace-customization + parameters: + - description: A required header to protect against CSRF attacks + in: header + name: kbn-xsrf + required: true + schema: + example: 'true' + type: string + requestBody: + content: + application/json: + examples: + bulkNamespaceCustomizationRequest: + value: + disable: + - dev + enable: + - production + - staging + packages: + - system + - nginx + schema: + additionalProperties: false + type: object + properties: + disable: + description: Namespaces to disable namespace-level customization for on each package. + items: + type: string + maxItems: 100 + type: array + enable: + description: Namespaces to enable namespace-level customization for on each package. + items: + type: string + maxItems: 100 + type: array + packages: + description: Package names to apply the customization changes to. + items: + type: string + maxItems: 1000 + minItems: 1 + type: array + required: + - packages + responses: + '200': + content: + application/json: + examples: + successResponse: + value: + items: + - name: system + namespace_customization_enabled_for: + - production + - staging + success: true + - error: Package nginx is not installed + name: nginx + success: false + schema: + additionalProperties: false + type: object + properties: + items: + items: + additionalProperties: false + type: object + properties: + error: + type: string + name: + type: string + namespace_customization_enabled_for: + description: 'The opt-in list on the package. Returned whenever the package is installed: the new list on success, or the unchanged list when the request is rejected (for example, because of a namespace-prefix restriction).' + items: + type: string + maxItems: 100 + type: array + success: + type: boolean + required: + - name + - success + maxItems: 1000 + type: array + required: + - items + description: 'OK: A successful request.' + '400': + content: + application/json: + examples: + badRequestResponse: + value: + error: Bad Request + message: 'Namespaces must not appear in both enable and disable: production' + statusCode: 400 + schema: + additionalProperties: false + description: Generic Error + type: object + properties: + attributes: + nullable: true + error: + type: string + errorType: + type: string + message: + type: string + statusCode: + type: number + required: + - message + - attributes + description: A bad request. + summary: Bulk enable/disable namespace-level customization for packages + tags: + - Elastic Package Manager (EPM) + x-metaTags: + - content: Kibana + name: product_name /api/fleet/epm/packages/_bulk_rollback: post: description: |- @@ -48798,6 +48935,12 @@ paths: content: application/json: examples: + putUpdatePackageNamespaceCustomizationExample: + description: Enable namespace-level customization for the `production` and `staging` namespaces + value: + namespace_customization_enabled_for: + - production + - staging putUpdatePackageRequestExample: description: Update keep_policies_up_to_date setting for a package value: @@ -48808,8 +48951,12 @@ paths: properties: keepPoliciesUpToDate: type: boolean - required: - - keepPoliciesUpToDate + namespace_customization_enabled_for: + description: Namespaces for which namespace-level customization is enabled on this package. + items: + type: string + maxItems: 100 + type: array responses: '200': content: @@ -50429,6 +50576,12 @@ paths: content: application/json: examples: + putUpdatePackageNamespaceCustomizationExample: + description: Enable namespace-level customization for the `production` and `staging` namespaces + value: + namespace_customization_enabled_for: + - production + - staging putUpdatePackageRequestExample: description: Update keep_policies_up_to_date setting for a package value: @@ -50439,8 +50592,12 @@ paths: properties: keepPoliciesUpToDate: type: boolean - required: - - keepPoliciesUpToDate + namespace_customization_enabled_for: + description: Namespaces for which namespace-level customization is enabled on this package. + items: + type: string + maxItems: 100 + type: array responses: '200': content: diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/epm-packages/10.9.0.json b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/epm-packages/10.9.0.json index cbcb568f5e6a4..7e57aa8fe7cf1 100644 --- a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/epm-packages/10.9.0.json +++ b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/epm-packages/10.9.0.json @@ -69,4 +69,4 @@ "installed_as_dependency": true } ] - } \ No newline at end of file + } diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 10f8bc9a999c9..d3a6da86484bf 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -95,7 +95,7 @@ describe('checking migration metadata changes on all registered SO types', () => "entity-engine-status": "005903620a00737932aa54ae57817b078810b2f71cc42e7715d1c22c5e5b715e", "entity-store-ccs-state": "79b9cdbb27444593a2c07a4384313ea37c8399604f016e9d633b71cb9c937489", "entity-store-global-state": "8581bc65d1b2bf6d0218b693509129a2515599aeff8933d85353a3fb28d52bda", - "epm-packages": "b59264fcbd91ad78497a10f6cf64d0e1d319833d637dfa404619f7ccfeee3b35", + "epm-packages": "09698b258d4fcf5b860d1d58f5807dbec13e38b112abbb2cdd3646c6dda660a5", "epm-packages-assets": "1095b56fabdeb3994a60f4da02e87179dfaf57d5bb23b97458129bf14c66b46e", "event-annotation-group": "21141aa64bba4d05ee6ebe0b0d75475452bca50e73f902a38800457d0727014d", "event_loop_delays_daily": "5f320d1628eebdcfd38969dbef3304ed62d19cdc902c20188892187cff3cbc8b", @@ -663,7 +663,7 @@ describe('checking migration metadata changes on all registered SO types', () => "epm-packages|global: 9d90d41b665a6b53aa6e984ad0e100ff733e05b9", "epm-packages|mappings: 1cbcdbf7f2f945430538f54a97fafaa76b428223", "epm-packages|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", - "epm-packages|10.9.0: 9c55a1b830381dc955df321d3c27a4b73c985cacc62bdc478e68c27d85cf1ebc", + "epm-packages|10.9.0: 289199b5ae3c469a4f54b408967cc85b0d81b817d0ae82a3004ba5c8761e0dbb", "epm-packages|10.8.0: 394d4e58c2cba229ddebd3ca9bf74377e9113c9072e3c8bbe9a22bd435823a77", "epm-packages|10.7.0: ef826e5d67dfd5a82eb199e125517372b7898cd72480a49679f8444f9816aed7", "epm-packages|10.6.0: 895e16e564980bb84eb75fad3b08a7d4b98d7b193629263c82a4030754b87955", diff --git a/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts b/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts index ba6efe4a12844..e45028ccec658 100644 --- a/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts +++ b/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts @@ -33,6 +33,7 @@ export const EPM_API_ROUTES = { BULK_UNINSTALL_INFO_PATTERN: `${EPM_PACKAGES_MANY}/_bulk_uninstall/{taskId}`, BULK_ROLLBACK_PATTERN: `${EPM_PACKAGES_MANY}/_bulk_rollback`, BULK_ROLLBACK_INFO_PATTERN: `${EPM_PACKAGES_MANY}/_bulk_rollback/{taskId}`, + BULK_NAMESPACE_CUSTOMIZATION_PATTERN: `${EPM_PACKAGES_MANY}/_bulk_namespace_customization`, LIST_PATTERN: EPM_PACKAGES_MANY, INSTALLED_LIST_PATTERN: EPM_PACKAGES_INSTALLED, LIMITED_LIST_PATTERN: `${EPM_PACKAGES_MANY}/limited`, diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts index 6732f4199a978..b8a5c43f1a449 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts @@ -814,6 +814,8 @@ export interface Installation { is_dependency_of?: IsDependencyOf | null; /** Whether the package was installed as a dependency (not manually by a user) */ installed_as_dependency?: boolean; + /** Namespaces opted in for namespace-level customization for this package. */ + namespace_customization_enabled_for?: string[]; /** Snapshot of dependency version changes made when this (composable) package was last installed/upgraded; used for rollback */ previous_dependency_versions?: Array<{ name: string; previous_version: string | null }> | null; } diff --git a/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/epm.ts b/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/epm.ts index f3becfff19fea..eea2b6b57f795 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/epm.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/epm.ts @@ -105,6 +105,7 @@ export interface UpdatePackageRequest { }; body: { keepPoliciesUpToDate?: boolean; + namespace_customization_enabled_for?: string[]; }; } diff --git a/x-pack/platform/plugins/shared/fleet/server/plugin.ts b/x-pack/platform/plugins/shared/fleet/server/plugin.ts index bc7202385ae9b..2fa18c2ef10fd 100644 --- a/x-pack/platform/plugins/shared/fleet/server/plugin.ts +++ b/x-pack/platform/plugins/shared/fleet/server/plugin.ts @@ -170,6 +170,7 @@ import { scheduleVerifyPermissionsTask, } from './tasks/agentless/verify_permissions_task'; import { registerReindexIntegrationKnowledgeTask } from './tasks/reindex_integration_knowledge_task'; +import { registerSyncNamespaceTemplatesTask } from './tasks/sync_namespace_templates_task'; import { type AgentlessPoliciesService, AgentlessPoliciesServiceImpl, @@ -709,6 +710,7 @@ export class FleetPlugin registerVerifyPermissionsTask(deps.taskManager); registerVerifierPolicyCleanupTask(deps.taskManager); registerReindexIntegrationKnowledgeTask(deps.taskManager); + registerSyncNamespaceTemplatesTask(deps.taskManager); registerReassignAgentsToVersionSpecificPoliciesTask(deps.taskManager); this.bulkActionsResolver = new BulkActionsResolver(deps.taskManager, core); diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/bulk_handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/epm/bulk_handler.ts index d6cba05339f72..108fa239516c2 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/epm/bulk_handler.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/bulk_handler.ts @@ -8,15 +8,24 @@ import type { TypeOf } from '@kbn/config-schema'; import type { KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; +import pMap from 'p-map'; import { appContextService, licenseService, packagePolicyService } from '../../services'; import type { BulkRollbackPackagesRequestSchema, + BulkNamespaceCustomizationRequestSchema, BulkUninstallPackagesRequestSchema, BulkUpgradePackagesRequestSchema, FleetRequestHandler, GetOneBulkOperationPackagesRequestSchema, } from '../../types'; +import { updatePackage } from '../../services/epm/packages/update'; +import { scheduleSyncNamespaceTemplatesTask } from '../../tasks/sync_namespace_templates_task'; +import { + getAllowedNamespacePrefixesForSpace, + isNamespaceAllowedByPrefixes, +} from '../../services/spaces/policy_namespaces'; import type { BulkOperationPackagesResponse, @@ -180,3 +189,108 @@ export const postBulkRollbackPackagesHandler: FleetRequestHandler< }; return response.ok({ body }); }; + +// Number of concurrent per-package namespace-customization updates in the bulk +// endpoint. Each one hits the Installation SO; kept small to avoid SO client contention. +const BULK_NAMESPACE_CUSTOMIZATION_CONCURRENCY = 5; + +export const postBulkNamespaceCustomizationHandler: FleetRequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const fleetContext = await context.fleet; + const savedObjectsClient = fleetContext.internalSoClient; + const spaceId = savedObjectsClient.getCurrentNamespace() ?? DEFAULT_SPACE_ID; + + const { packages, enable = [], disable = [] } = request.body; + + const conflicts = enable.filter((ns) => disable.includes(ns)); + if (conflicts.length > 0) { + throw new FleetError( + `Namespaces must not appear in both enable and disable: ${conflicts.join(', ')}` + ); + } + + const taskManagerStart = getTaskManagerStart(); + const allowedPrefixes = await getAllowedNamespacePrefixesForSpace(spaceId); + + const items = await pMap( + packages, + async (packageName) => { + const installation = await getInstallationsByName({ + savedObjectsClient, + pkgNames: [packageName], + }); + if (installation.length === 0) { + return { + name: packageName, + success: false, + error: `Package ${packageName} is not installed`, + }; + } + + const current = installation[0].namespace_customization_enabled_for ?? []; + // Tracks the persisted state across the try block: starts at `current`, advances + // to `newList` once `updatePackage` has committed. The catch handler returns this + // so the response always reflects what's actually in the SO. + let persistedList = current; + + try { + const afterEnable = [...new Set([...current, ...enable])]; + const newList = afterEnable.filter((ns) => !disable.includes(ns)); + + // Gate both added and removed namespaces on the current space's allowed_namespace_prefixes. + const added = newList.filter((ns) => !current.includes(ns)); + const removed = current.filter((ns) => !newList.includes(ns)); + const changed = [...added, ...removed]; + const blocked = changed.filter((ns) => !isNamespaceAllowedByPrefixes(ns, allowedPrefixes)); + if (blocked.length > 0) { + return { + name: packageName, + success: false, + namespace_customization_enabled_for: current, + error: `Cannot change namespace customization for: ${blocked.join( + ', ' + )}. Allowed prefixes in this space: ${(allowedPrefixes ?? []).join(', ')}`, + }; + } + + const { namespaceCustomizationDiff } = await updatePackage({ + savedObjectsClient, + pkgName: packageName, + namespace_customization_enabled_for: newList, + }); + persistedList = newList; + + if ( + namespaceCustomizationDiff.addedNamespaces.length > 0 || + namespaceCustomizationDiff.removedNamespaces.length > 0 + ) { + await scheduleSyncNamespaceTemplatesTask(taskManagerStart, { + spaceId, + packageName, + addedNamespaces: namespaceCustomizationDiff.addedNamespaces, + removedNamespaces: namespaceCustomizationDiff.removedNamespaces, + }); + } + + return { + name: packageName, + success: true, + namespace_customization_enabled_for: newList, + }; + } catch (err) { + return { + name: packageName, + success: false, + namespace_customization_enabled_for: persistedList, + error: err instanceof Error ? err.message : String(err), + }; + } + }, + { concurrency: BULK_NAMESPACE_CUSTOMIZATION_CONCURRENCY } + ); + + return response.ok({ body: { items } }); +}; diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/examples/put_update_package.yaml b/x-pack/platform/plugins/shared/fleet/server/routes/epm/examples/put_update_package.yaml index b69f100f1db30..13147a571bfb3 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/epm/examples/put_update_package.yaml +++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/examples/put_update_package.yaml @@ -6,6 +6,10 @@ requestBody: description: 'Update keep_policies_up_to_date setting for a package' value: keepPoliciesUpToDate: true + putUpdatePackageNamespaceCustomizationExample: + description: 'Enable namespace-level customization for the `production` and `staging` namespaces' + value: + namespace_customization_enabled_for: ['production', 'staging'] responses: 200: description: 'Successful response' diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/handlers.ts b/x-pack/platform/plugins/shared/fleet/server/routes/epm/handlers.ts index 16f41cb457b5f..2e95960fc6b07 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/epm/handlers.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/handlers.ts @@ -9,6 +9,7 @@ import type { TypeOf } from '@kbn/config-schema'; import semverValid from 'semver/functions/valid'; import { type HttpResponseOptions } from '@kbn/core/server'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import { omit, pick } from 'lodash'; @@ -95,7 +96,16 @@ import { licenseService, packagePolicyService, } from '../../services'; -import { getPackageUsageStats, getPackageDependencies } from '../../services/epm/packages/get'; +import { + getInstallation, + getPackageUsageStats, + getPackageDependencies, +} from '../../services/epm/packages/get'; +import { + getAllowedNamespacePrefixesForSpace, + isNamespaceAllowedByPrefixes, +} from '../../services/spaces/policy_namespaces'; +import { PolicyNamespaceValidationError } from '../../../common/errors'; import { bulkRollbackAvailableCheck, isIntegrationRollbackTTLExpired, @@ -103,6 +113,7 @@ import { rollbackInstallation, } from '../../services/epm/packages/rollback'; import { updatePackage, reviewUpgrade } from '../../services/epm/packages/update'; +import { scheduleSyncNamespaceTemplatesTask } from '../../tasks/sync_namespace_templates_task'; import { getGpgKeyIdOrUndefined } from '../../services/epm/packages/package_verification'; import type { ReauthorizeTransformRequestSchema, @@ -307,6 +318,14 @@ export const getBulkAssetsHandler: FleetRequestHandler< return response.ok({ body }); }; +function getTaskManagerStart() { + const taskManagerStart = appContextService.getTaskManagerStart(); + if (!taskManagerStart) { + throw new Error('Task manager not defined'); + } + return taskManagerStart; +} + export const updatePackageHandler: FleetRequestHandler< | TypeOf | TypeOf, @@ -315,10 +334,49 @@ export const updatePackageHandler: FleetRequestHandler< > = async (context, request, response) => { const savedObjectsClient = (await context.fleet).internalSoClient; const { pkgName } = request.params; + const spaceId = savedObjectsClient.getCurrentNamespace() ?? DEFAULT_SPACE_ID; + + // Gate both added and removed namespaces on the current space's allowed_namespace_prefixes. + const requestedList = request.body.namespace_customization_enabled_for; + if (requestedList) { + const installation = await getInstallation({ savedObjectsClient, pkgName }); + const currentList = installation?.namespace_customization_enabled_for ?? []; + const added = requestedList.filter((ns) => !currentList.includes(ns)); + const removed = currentList.filter((ns) => !requestedList.includes(ns)); + const changed = [...added, ...removed]; + if (changed.length > 0) { + const prefixes = await getAllowedNamespacePrefixesForSpace(spaceId); + const blocked = changed.filter((ns) => !isNamespaceAllowedByPrefixes(ns, prefixes)); + if (blocked.length > 0) { + throw new PolicyNamespaceValidationError( + `Cannot change namespace customization for: ${blocked.join( + ', ' + )}. Allowed prefixes in this space: ${(prefixes ?? []).join(', ')}` + ); + } + } + } + + const { packageInfo, namespaceCustomizationDiff } = await updatePackage({ + savedObjectsClient, + pkgName, + ...request.body, + }); + + if ( + namespaceCustomizationDiff.addedNamespaces.length > 0 || + namespaceCustomizationDiff.removedNamespaces.length > 0 + ) { + await scheduleSyncNamespaceTemplatesTask(getTaskManagerStart(), { + spaceId, + packageName: pkgName, + addedNamespaces: namespaceCustomizationDiff.addedNamespaces, + removedNamespaces: namespaceCustomizationDiff.removedNamespaces, + }); + } - const res = await updatePackage({ savedObjectsClient, pkgName, ...request.body }); const body: UpdatePackageResponse = { - item: res, + item: packageInfo, }; return response.ok({ body }); @@ -745,6 +803,7 @@ const soToInstallationInfo = (pkg: PackageListItem | PackageInfo) => { is_rollback_ttl_expired: isIntegrationRollbackTTLExpired(attributes.install_started_at), pending_upgrade_review: attributes.pending_upgrade_review, keep_policies_up_to_date: attributes.keep_policies_up_to_date, + namespace_customization_enabled_for: attributes.namespace_customization_enabled_for, }; return { diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.ts b/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.ts index bb046d44c91d0..cc299bbc3e8d3 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.ts @@ -84,6 +84,8 @@ import { GetKnowledgeBaseResponseSchema, BulkRollbackPackagesRequestSchema, BulkRollbackPackagesResponseSchema, + BulkNamespaceCustomizationRequestSchema, + BulkNamespaceCustomizationResponseSchema, InstallRuleAssetsRequestSchema, } from '../../types'; import type { FleetConfigType } from '../../config'; @@ -127,6 +129,7 @@ import { postBulkUninstallPackagesHandler, getOneBulkOperationPackagesHandler, postBulkRollbackPackagesHandler, + postBulkNamespaceCustomizationHandler, } from './bulk_handler'; import { deletePackageDatastreamAssetsHandler } from './package_datastream_assets_handler'; @@ -1100,6 +1103,97 @@ export const registerRoutes = (router: FleetAuthzRouter, config: FleetConfigType ); } + router.versioned + .post({ + path: EPM_API_ROUTES.BULK_NAMESPACE_CUSTOMIZATION_PATTERN, + security: INSTALL_PACKAGES_SECURITY, + summary: `Bulk enable/disable namespace-level customization for packages`, + description: `Enable or disable namespace-level index template customization for a list of packages in one call. Use this for IaC-style declarative flows.`, + options: { + tags: ['oas-tag:Elastic Package Manager (EPM)'], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + options: { + oasOperationObject: () => ({ + requestBody: { + content: { + 'application/json': { + examples: { + bulkNamespaceCustomizationRequest: { + value: { + packages: ['system', 'nginx'], + enable: ['production', 'staging'], + disable: ['dev'], + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + examples: { + successResponse: { + value: { + items: [ + { + name: 'system', + success: true, + namespace_customization_enabled_for: ['production', 'staging'], + }, + { + name: 'nginx', + success: false, + error: 'Package nginx is not installed', + }, + ], + }, + }, + }, + }, + }, + }, + 400: { + content: { + 'application/json': { + examples: { + badRequestResponse: { + value: { + statusCode: 400, + error: 'Bad Request', + message: + 'Namespaces must not appear in both enable and disable: production', + }, + }, + }, + }, + }, + }, + }, + }), + }, + validate: { + request: BulkNamespaceCustomizationRequestSchema, + response: { + 200: { + body: () => BulkNamespaceCustomizationResponseSchema, + description: 'OK: A successful request.', + }, + 400: { + body: genericErrorResponse, + description: 'A bad request.', + }, + }, + }, + }, + postBulkNamespaceCustomizationHandler + ); + router.versioned .get({ path: EPM_API_ROUTES.BULK_UNINSTALL_INFO_PATTERN, diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.test.ts b/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.test.ts index 2d263a4b66686..8a4827b888cde 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.test.ts @@ -14,9 +14,10 @@ import { SettingsResponseSchema, SpaceSettingsResponseSchema } from '../../types import { getSettingsHandler, getSpaceSettingsHandler } from './settings_handler'; jest.mock('../../services/spaces/space_settings', () => ({ - getSpaceSettings: jest - .fn() - .mockResolvedValue({ allowed_namespace_prefixes: [], managed_by: 'kibana' }), + getSpaceSettings: jest.fn().mockResolvedValue({ + allowed_namespace_prefixes: [], + managed_by: 'kibana', + }), saveSpaceSettings: jest.fn(), })); @@ -57,7 +58,12 @@ describe('SettingsHandler', () => { it('should return valid space settings', async () => { await getSpaceSettingsHandler(context, {} as any, response); - const expectedResponse = { item: { allowed_namespace_prefixes: [], managed_by: 'kibana' } }; + const expectedResponse = { + item: { + allowed_namespace_prefixes: [], + managed_by: 'kibana', + }, + }; expect(response.ok).toHaveBeenCalledWith({ body: expectedResponse, }); diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.ts index eeda186a4bc4a..fce52d959f29c 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/settings/settings_handler.ts @@ -6,6 +6,7 @@ */ import type { TypeOf } from '@kbn/config-schema'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import type { FleetRequestHandler, @@ -31,13 +32,17 @@ export const putSpaceSettingsHandler: FleetRequestHandler< TypeOf > = async (context, request, response) => { const soClient = (await context.fleet).internalSoClient; + const spaceId = soClient.getCurrentNamespace() ?? DEFAULT_SPACE_ID; + await saveSpaceSettings({ settings: { allowed_namespace_prefixes: request.body.allowed_namespace_prefixes, }, - spaceId: soClient.getCurrentNamespace(), + spaceId, }); - const settings = await getSpaceSettings(soClient.getCurrentNamespace()); + + const settings = await getSpaceSettings(spaceId); + const body = { item: settings, }; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/install.ts index 9460803e6a54c..4f5bba08280f1 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/install.ts @@ -56,6 +56,7 @@ import { generateTemplateIndexPattern, getTemplate, getTemplatePriority, + isNamespaceTemplate, } from './template'; import { buildDefaultSettings, getILMMigrationStatus } from './default_settings'; import { isUserSettingsTemplate } from './utils'; @@ -73,11 +74,17 @@ export const prepareToInstallTemplates = async ( install: (esClient: ElasticsearchClient, logger: Logger) => Promise; }> => { const { packageInfo } = packageInstallContext; - // remove package installation's references to index templates + // Remove package installation's references to index templates, but preserve + // namespace-scoped index templates (e.g. `logs-nginx.access@namespace.production`) + // — they are rebuilt separately in handleNamespaceTemplateRestoreAfterPackageInstall + // after base templates are in place. Note: `@custom` component template + // refs ARE removed here (they don't match `@namespace.`) and are re-added during + // the restore step. const assetsToRemove = esReferences.filter( - ({ type }) => - type === ElasticsearchAssetType.indexTemplate || - type === ElasticsearchAssetType.componentTemplate + ({ type, id }) => + (type === ElasticsearchAssetType.indexTemplate || + type === ElasticsearchAssetType.componentTemplate) && + !isNamespaceTemplate(id) ); const fieldAssetsMap: AssetsMap = new Map(); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/template.ts index e09e6aa55f609..930f81f522d4a 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/template/template.ts @@ -78,6 +78,10 @@ const DEFAULT_IGNORE_ABOVE = 1024; const DEFAULT_TEMPLATE_PRIORITY = 200; const DATASET_IS_PREFIX_TEMPLATE_PRIORITY = 150; +// Namespace-scoped templates get a higher priority so ES picks them over +// the base template for data streams belonging to that namespace. +export const NAMESPACE_TEMPLATE_PRIORITY_BOOST = 50; + const META_PROP_KEYS = ['metric_type', 'unit']; /** @@ -877,6 +881,79 @@ export function getTemplatePriority(dataStream: RegistryDataStream): number { } } +// --------------------------------------------------------------------------- +// Namespace-scoped index template helpers +// --------------------------------------------------------------------------- + +/** + * Returns the index template name for a namespace-scoped template. + * Example: `logs-nginx.access@namespace.production` + */ +export function generateNamespaceTemplateName(baseName: string, namespace: string): string { + return `${baseName}@namespace.${namespace}`; +} + +/** + * Returns the index pattern for a namespace-scoped template. + * + * The pattern matches the data stream name exactly (no trailing wildcard on the + * namespace segment) so that namespaces with shared prefixes do not collide — + * e.g. the template for namespace `production` must not also match data streams + * for `production_eu` or `production_us`. + * + * Example (non-prefix): `logs-nginx.access-production` + * Example (dataset_is_prefix): `metrics-test.*-production` + * Example (OTel): `traces-generic.otel-production` + */ +export function generateNamespaceTemplateIndexPattern( + dataStream: RegistryDataStream, + namespace: string, + isOtelInputType?: boolean +): string { + const baseName = getRegistryDataStreamAssetBaseName(dataStream, isOtelInputType); + if (!dataStream.dataset_is_prefix) { + return `${baseName}-${namespace}`; + } else { + return `${baseName}.*-${namespace}`; + } +} + +/** + * Returns the priority for a namespace-scoped index template. + * Always higher than the base template so ES picks it for matching data streams. + * + * Note: for data streams with `dataset_is_prefix: true`, the base template priority is 150 + * and the namespace template priority is 200 — the same numeric value as a regular base + * template. This is intentional: Elasticsearch resolves priority ties by index pattern + * specificity, so the more specific namespace pattern (e.g. `metrics-test.*-production`) + * wins over the regular base pattern (e.g. `metrics-test.*-*`) even at equal priority. + */ +export function getNamespaceTemplatePriority(dataStream: RegistryDataStream): number { + return getTemplatePriority(dataStream) + NAMESPACE_TEMPLATE_PRIORITY_BOOST; +} + +/** + * Returns true if the given template ID is a namespace-scoped index template, + * identifiable by the `@namespace.` discriminator in the name. + */ +export function isNamespaceTemplate(id: string): boolean { + return id.includes('@namespace.'); +} + +/** + * Extracts the namespace from a namespace-scoped template ID. + * Returns undefined if the ID is not a namespace template. + * Example: `logs-nginx.access@namespace.production` → `'production'` + */ +export function getNamespaceFromTemplateId(id: string): string | undefined { + const marker = '@namespace.'; + const idx = id.indexOf(marker); + if (idx === -1) { + return undefined; + } + return id.slice(idx + marker.length); +} + /** * Returns a map of the data stream path fields to elasticsearch index pattern. * @param dataStreams an array of RegistryDataStream objects diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/index.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/index.ts index 9af5668e3449b..d4b16a45eeee8 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/index.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/index.ts @@ -35,6 +35,13 @@ export { handleInstallPackageFailure, installPackage, ensureInstalledPackage } f export { reinstallPackageForInstallation } from './reinstall'; export { removeInstallation } from './remove'; export { updateCustomIntegration, incrementVersionAndUpdate } from './update_custom_integration'; +export { + handleNamespaceTemplateRestoreAfterPackageInstall, + insertNamespaceCustomTemplate, + isNamespaceCustomizationEnabledForPackage, + syncNamespaceTemplates, +} from './namespace_datastream_templates'; +export type { SyncNamespaceTemplatesSummary } from './namespace_datastream_templates'; export class PackageNotInstalledError extends Error { constructor(pkgkey: string) { super(`${pkgkey} is not installed`); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.ts index dae7c9f8b54c7..5cf23cfe8206f 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.ts @@ -12,6 +12,7 @@ import { SO_SEARCH_LIMIT, FLEET_INSTALL_FORMAT_VERSION, } from '../../../../../constants'; +import { handleNamespaceTemplateRestoreAfterPackageInstall } from '../..'; import type { Installation } from '../../../../../types'; import { packagePolicyService } from '../../../../package_policy'; @@ -65,6 +66,23 @@ export async function stepSaveSystemObject(context: InstallContext) { pkgName ); logger.debug(`Package install - Install status ${updatedPackage?.attributes?.install_status}`); + // Recreate namespace-scoped index templates for every namespace opted in on this + // package's Installation SO. On first install this is a no-op (opt-in list is empty). + if (packageInfo.type === 'integration') { + try { + await handleNamespaceTemplateRestoreAfterPackageInstall({ + soClient: savedObjectsClient, + esClient, + packageName: pkgName, + dataStreams: packageInfo.data_streams ?? [], + }); + } catch (err: any) { + logger.warn( + `[stepSaveSystemObject] Failed to restore namespace templates for ${pkgName}: ${err.message}` + ); + } + } + // If the package is flagged with the `keep_policies_up_to_date` flag, upgrade its // associated package policies after installation if (updatedPackage.attributes.keep_policies_up_to_date) { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.test.ts new file mode 100644 index 0000000000000..fa32d1780f6cb --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.test.ts @@ -0,0 +1,462 @@ +/* + * 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 { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; + +import { ElasticsearchAssetType } from '../../../../common/types'; +import { appContextService } from '../../app_context'; +import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; + +import { getInstalledPackageWithAssets, getInstallation } from './get'; +import { updateEsAssetReferences } from './es_assets_reference'; + +import { + handleNamespaceTemplateRestoreAfterPackageInstall, + insertNamespaceCustomTemplate, + isNamespaceCustomizationEnabledForPackage, + syncNamespaceTemplates, +} from './namespace_datastream_templates'; + +jest.mock('./get'); +jest.mock('../elasticsearch/template/template', () => { + const actual = jest.requireActual('../elasticsearch/template/template'); + return { + ...actual, + updateCurrentWriteIndices: jest.fn(), + }; +}); +jest.mock('./es_assets_reference'); +jest.mock('../../app_context'); + +const mockedAppContextService = appContextService as jest.Mocked; +mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ + ...securityMock.createSetup(), +})); +mockedAppContextService.getLogger.mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +} as any); + +const mockedGetInstalledPackageWithAssets = getInstalledPackageWithAssets as jest.MockedFunction< + typeof getInstalledPackageWithAssets +>; +const mockedGetInstallation = getInstallation as jest.MockedFunction; +const mockedUpdateCurrentWriteIndices = updateCurrentWriteIndices as jest.MockedFunction< + typeof updateCurrentWriteIndices +>; +const mockedUpdateEsAssetReferences = updateEsAssetReferences as jest.MockedFunction< + typeof updateEsAssetReferences +>; + +// --------------------------------------------------------------------------- +// insertNamespaceCustomTemplate — pure function tests +// --------------------------------------------------------------------------- + +describe('insertNamespaceCustomTemplate', () => { + it('inserts namespace entry before dataset-level @custom', () => { + const composedOf = [ + 'logs-nginx.access@package', + 'logs@custom', + 'nginx@custom', + 'logs-nginx.access@custom', + ]; + const result = insertNamespaceCustomTemplate(composedOf, 'production', 'logs-nginx.access'); + expect(result).toEqual([ + 'logs-nginx.access@package', + 'logs@custom', + 'nginx@custom', + 'production@custom', + 'logs-nginx.access@custom', + ]); + }); + + it('inserts namespace entry after last package-level @custom when no dataset entry exists', () => { + const composedOf = ['logs-nginx.access@package', 'logs@custom', 'nginx@custom']; + const result = insertNamespaceCustomTemplate(composedOf, 'default', 'logs-nginx.access'); + expect(result).toEqual([ + 'logs-nginx.access@package', + 'logs@custom', + 'nginx@custom', + 'default@custom', + ]); + }); + + it('appends at end when no package-level @custom entries exist', () => { + const composedOf = ['logs-nginx.access@package']; + const result = insertNamespaceCustomTemplate(composedOf, 'default', 'logs-nginx.access'); + expect(result).toEqual(['logs-nginx.access@package', 'default@custom']); + }); + + it('is a no-op when namespace entry already present', () => { + const composedOf = [ + 'logs-nginx.access@package', + 'logs@custom', + 'default@custom', + 'logs-nginx.access@custom', + ]; + const result = insertNamespaceCustomTemplate(composedOf, 'default', 'logs-nginx.access'); + expect(result).toEqual(composedOf); + }); + + it('does not treat hyphenated dataset-level entries as package-level entries', () => { + const composedOf = ['logs-nginx.access@package', 'logs@custom', 'logs-nginx.access@custom']; + const result = insertNamespaceCustomTemplate(composedOf, 'staging', 'logs-nginx.access'); + expect(result).toEqual([ + 'logs-nginx.access@package', + 'logs@custom', + 'staging@custom', + 'logs-nginx.access@custom', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// isNamespaceCustomizationEnabledForPackage — per-package opt-in helper +// --------------------------------------------------------------------------- + +describe('isNamespaceCustomizationEnabledForPackage', () => { + const soClient = savedObjectsClientMock.create(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when namespace is in the opt-in list', async () => { + mockedGetInstallation.mockResolvedValue({ + namespace_customization_enabled_for: ['production', 'staging'], + } as any); + + await expect( + isNamespaceCustomizationEnabledForPackage(soClient, 'nginx', 'production') + ).resolves.toBe(true); + }); + + it('returns false when namespace is not in the opt-in list', async () => { + mockedGetInstallation.mockResolvedValue({ + namespace_customization_enabled_for: ['production'], + } as any); + + await expect( + isNamespaceCustomizationEnabledForPackage(soClient, 'nginx', 'staging') + ).resolves.toBe(false); + }); + + it('returns false when the package is not installed', async () => { + mockedGetInstallation.mockResolvedValue(undefined); + + await expect( + isNamespaceCustomizationEnabledForPackage(soClient, 'nginx', 'production') + ).resolves.toBe(false); + }); + + it('returns false when the opt-in list is missing from the installation', async () => { + mockedGetInstallation.mockResolvedValue({} as any); + + await expect( + isNamespaceCustomizationEnabledForPackage(soClient, 'nginx', 'production') + ).resolves.toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers shared by the remaining test suites +// --------------------------------------------------------------------------- + +const BASE_COMPOSED_OF = [ + 'logs-nginx.access@package', + 'logs@custom', + 'nginx@custom', + 'logs-nginx.access@custom', +]; + +function mockInstalledPackage( + dataStreams: Array<{ dataset: string; type: string }> = [ + { dataset: 'nginx.access', type: 'logs' }, + ] +) { + mockedGetInstalledPackageWithAssets.mockResolvedValue({ + packageInfo: { name: 'nginx', data_streams: dataStreams }, + installation: { installed_es: [] }, + } as any); +} + +function makeEsClientWithTemplate(composedOf: string[] = BASE_COMPOSED_OF) { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.indices.getIndexTemplate.mockResolvedValue({ + index_templates: [ + { + name: 'logs-nginx.access', + index_template: { + composed_of: composedOf, + index_patterns: ['logs-nginx.access-*'], + priority: 200, + template: { settings: {}, mappings: {} }, + data_stream: {}, + _meta: { package: { name: 'nginx' } }, + }, + }, + ], + } as any); + return esClient; +} + +// --------------------------------------------------------------------------- +// handleNamespaceTemplateRestoreAfterPackageInstall +// --------------------------------------------------------------------------- + +describe('handleNamespaceTemplateRestoreAfterPackageInstall', () => { + const soClient = savedObjectsClientMock.create(); + const dataStreams = [{ dataset: 'nginx.access', type: 'logs' }] as any[]; + + beforeEach(() => { + jest.clearAllMocks(); + mockedAppContextService.getLogger.mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as any); + mockedUpdateCurrentWriteIndices.mockResolvedValue(undefined); + mockedUpdateEsAssetReferences.mockResolvedValue([]); + }); + + it('is a no-op when there are no data streams', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + await handleNamespaceTemplateRestoreAfterPackageInstall({ + soClient, + esClient, + packageName: 'nginx', + dataStreams: [], + }); + expect(esClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + }); + + it('is a no-op when the package opt-in list is empty', async () => { + mockedGetInstallation.mockResolvedValue({ + namespace_customization_enabled_for: [], + } as any); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + + await handleNamespaceTemplateRestoreAfterPackageInstall({ + soClient, + esClient, + packageName: 'nginx', + dataStreams, + }); + + expect(esClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + }); + + it('rebuilds namespace templates for every opted-in namespace on the package', async () => { + mockedGetInstallation.mockResolvedValue({ + namespace_customization_enabled_for: ['production', 'staging'], + installed_es: [], + } as any); + const esClient = makeEsClientWithTemplate(); + + await handleNamespaceTemplateRestoreAfterPackageInstall({ + soClient, + esClient, + packageName: 'nginx', + dataStreams, + }); + + const putCalls = (esClient.indices.putIndexTemplate as unknown as jest.Mock).mock.calls; + const templateNames = putCalls.map((c: any) => c[0].name).sort(); + expect(templateNames).toEqual([ + 'logs-nginx.access@namespace.production', + 'logs-nginx.access@namespace.staging', + ]); + }); + + it('tracks restored namespace templates in installed_es', async () => { + mockedGetInstallation.mockResolvedValue({ + namespace_customization_enabled_for: ['production'], + installed_es: [], + } as any); + const esClient = makeEsClientWithTemplate(); + + await handleNamespaceTemplateRestoreAfterPackageInstall({ + soClient, + esClient, + packageName: 'nginx', + dataStreams, + }); + + expect(mockedUpdateEsAssetReferences).toHaveBeenCalledWith( + soClient, + 'nginx', + expect.any(Array), + expect.objectContaining({ + assetsToAdd: expect.arrayContaining([ + { + id: 'logs-nginx.access@namespace.production', + type: ElasticsearchAssetType.indexTemplate, + }, + ]), + }) + ); + }); +}); + +// --------------------------------------------------------------------------- +// syncNamespaceTemplates — per-package opt-in sync +// --------------------------------------------------------------------------- + +describe('syncNamespaceTemplates', () => { + const soClient = savedObjectsClientMock.create(); + + beforeEach(() => { + jest.clearAllMocks(); + mockedAppContextService.getLogger.mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as any); + mockedUpdateCurrentWriteIndices.mockResolvedValue(undefined); + mockedUpdateEsAssetReferences.mockResolvedValue([]); + mockedGetInstallation.mockResolvedValue({ installed_es: [] } as any); + }); + + it('is a no-op when both added and removed are empty', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + + const summary = await syncNamespaceTemplates({ + soClient, + esClient, + packageName: 'nginx', + addedNamespaces: [], + removedNamespaces: [], + }); + + expect(esClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + expect(esClient.indices.deleteIndexTemplate).not.toHaveBeenCalled(); + expect(summary.created).toEqual([]); + expect(summary.removed).toEqual([]); + }); + + it('marks the summary as skipped when the package is not installed', async () => { + mockedGetInstalledPackageWithAssets.mockResolvedValue(undefined); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + + const summary = await syncNamespaceTemplates({ + soClient, + esClient, + packageName: 'nginx', + addedNamespaces: ['production'], + removedNamespaces: [], + }); + + expect(esClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + expect(summary).toMatchObject({ packageName: 'nginx', skipped: true }); + }); + + it('creates namespace templates for added namespaces', async () => { + mockInstalledPackage(); + const esClient = makeEsClientWithTemplate(); + + const summary = await syncNamespaceTemplates({ + soClient, + esClient, + packageName: 'nginx', + addedNamespaces: ['production'], + removedNamespaces: [], + }); + + expect(esClient.indices.putIndexTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'logs-nginx.access@namespace.production', + priority: 250, + composed_of: expect.arrayContaining(['production@custom']), + }), + expect.any(Object) + ); + expect(summary.created).toEqual(['production']); + expect(summary.removed).toEqual([]); + }); + + it('does not throw when no data stream exists yet for the new namespace', async () => { + mockInstalledPackage(); + const esClient = makeEsClientWithTemplate(); + // updateCurrentWriteIndices throws index_not_found when the namespace's + // data stream doesn't exist yet (no data ingested). The handler should + // swallow the 404 so the sync task can complete. + mockedUpdateCurrentWriteIndices.mockRejectedValueOnce({ + meta: { statusCode: 404 }, + message: 'index_not_found_exception', + }); + + const summary = await syncNamespaceTemplates({ + soClient, + esClient, + packageName: 'nginx', + addedNamespaces: ['production'], + removedNamespaces: [], + }); + + expect(summary.created).toEqual(['production']); + }); + + it('deletes namespace templates for removed namespaces', async () => { + mockInstalledPackage(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + + const summary = await syncNamespaceTemplates({ + soClient, + esClient, + packageName: 'nginx', + addedNamespaces: [], + removedNamespaces: ['staging'], + }); + + expect(esClient.indices.deleteIndexTemplate).toHaveBeenCalledWith( + { name: 'logs-nginx.access@namespace.staging' }, + expect.objectContaining({ ignore: [404] }) + ); + expect(summary.removed).toEqual(['staging']); + expect(summary.created).toEqual([]); + }); + + it('tracks created templates and removes deleted ones from installed_es', async () => { + mockInstalledPackage(); + const esClient = makeEsClientWithTemplate(); + + await syncNamespaceTemplates({ + soClient, + esClient, + packageName: 'nginx', + addedNamespaces: ['production'], + removedNamespaces: ['staging'], + }); + + const updateCalls = mockedUpdateEsAssetReferences.mock.calls; + const assetsToAddCall = updateCalls.find((c) => c[3]?.assetsToAdd); + const assetsToRemoveCall = updateCalls.find((c) => c[3]?.assetsToRemove); + + expect(assetsToAddCall?.[3].assetsToAdd).toEqual( + expect.arrayContaining([ + { + id: 'logs-nginx.access@namespace.production', + type: ElasticsearchAssetType.indexTemplate, + }, + ]) + ); + expect(assetsToRemoveCall?.[3].assetsToRemove).toEqual( + expect.arrayContaining([ + { + id: 'logs-nginx.access@namespace.staging', + type: ElasticsearchAssetType.indexTemplate, + }, + ]) + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.ts new file mode 100644 index 0000000000000..913cd7acda968 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/namespace_datastream_templates.ts @@ -0,0 +1,504 @@ +/* + * 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 pMap from 'p-map'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; + +import type { IndexTemplate, IndexTemplateEntry, RegistryDataStream } from '../../../types'; +import { ElasticsearchAssetType } from '../../../../common/types'; +import { appContextService } from '../../app_context'; +import { + updateCurrentWriteIndices, + generateNamespaceTemplateName, + generateNamespaceTemplateIndexPattern, + getNamespaceTemplatePriority, +} from '../elasticsearch/template/template'; +import { isUserSettingsTemplate } from '../elasticsearch/template/utils'; +import { getRegistryDataStreamAssetBaseName } from '../../../../common/services'; +import { MAX_CONCURRENT_COMPONENT_TEMPLATES } from '../../../constants'; +import { OTEL_COLLECTOR_INPUT_TYPE } from '../../../../common/constants'; +import { throwIfAborted } from '../../../tasks/utils'; + +import { updateEsAssetReferences } from './es_assets_reference'; +import { getInstalledPackageWithAssets, getInstallation } from './get'; + +/** + * Returns true if any of the data stream's streams use the OTel collector input type + * AND OTel integrations are enabled. Mirrors the check in `installTemplateForDataStream`. + */ +function isOtelDataStream(dataStream: RegistryDataStream): boolean { + const experimentalFeature = appContextService.getExperimentalFeatures(); + return ( + !!experimentalFeature?.enableOtelIntegrations && + (dataStream?.streams || []).some((stream) => stream.input === OTEL_COLLECTOR_INPUT_TYPE) + ); +} + +/** + * Returns true if namespace-level customization is opted in for `namespace` on + * the installed `packageName`. Reads the Installation saved object's + * `namespace_customization_enabled_for` list. + */ +export async function isNamespaceCustomizationEnabledForPackage( + soClient: SavedObjectsClientContract, + packageName: string, + namespace: string +): Promise { + const installation = await getInstallation({ + savedObjectsClient: soClient, + pkgName: packageName, + }); + return !!installation?.namespace_customization_enabled_for?.includes(namespace); +} + +/** + * Inserts `@custom` into a `composed_of` array at the correct position: + * - after the last package-level `@custom` (e.g. `system@custom`, no hyphen before `@custom`) + * - before the dataset-level `@custom` (e.g. `logs-system.application@custom`) + * + * If `@custom` is already present, the array is returned unchanged. + * This includes the case where a namespace name matches a package name (e.g. + * namespace "nginx" for package "nginx") — the existing `nginx@custom` entry + * already serves both the package-level and namespace-level roles, so no + * duplicate is inserted. + * + * Used when building the `composed_of` for a namespace-scoped index template. + */ +export function insertNamespaceCustomTemplate( + composedOf: string[], + namespace: string, + templateName: string +): string[] { + const namespaceEntry = `${namespace}@custom`; + if (composedOf.includes(namespaceEntry)) { + return composedOf; + } + + const datasetEntry = `${templateName}@custom`; + // Package-level @custom: name contains no hyphen before `@custom` + // e.g. "system@custom" matches, "logs@custom" matches + // but "logs-system.application@custom" does NOT + const isPackageLevelCustom = (name: string): boolean => + name.endsWith('@custom') && !name.slice(0, -7).includes('-'); + + let insertAt: number; + const datasetIdx = composedOf.indexOf(datasetEntry); + + if (datasetIdx !== -1) { + // Insert immediately before the dataset-level entry + insertAt = datasetIdx; + } else { + // Find the last package-level @custom entry and insert after it + let lastPkgCustomIdx = -1; + for (let i = 0; i < composedOf.length; i++) { + if (isPackageLevelCustom(composedOf[i])) { + lastPkgCustomIdx = i; + } + } + insertAt = lastPkgCustomIdx !== -1 ? lastPkgCustomIdx + 1 : composedOf.length; + } + + const result = [...composedOf]; + result.splice(insertAt, 0, namespaceEntry); + return result; +} + +/** + * Fetches a base index template from ES and strips read-only date properties. + * Returns the cleaned template or undefined if not found. + */ +async function fetchBaseTemplate( + esClient: ElasticsearchClient, + templateName: string, + logContext: string, + abortController?: AbortController +): Promise { + const logger = appContextService.getLogger(); + let rawTemplate; + try { + const res = await esClient.indices.getIndexTemplate( + { name: templateName }, + { signal: abortController?.signal } + ); + rawTemplate = res.index_templates[0]?.index_template; + } catch (err: unknown) { + if ((err as { meta?: { statusCode?: number } })?.meta?.statusCode !== 404) { + throw err; + } + logger.debug(`[${logContext}] index template ${templateName} not found, skipping`); + return undefined; + } + + if (!rawTemplate) { + return undefined; + } + + // Strip system-managed date properties that cannot be set on PUT + const { + created_date: _cd, + created_date_millis: _cdm, + modified_date: _md, + modified_date_millis: _mdm, + ...indexTemplate + } = rawTemplate as IndexTemplate; + + return indexTemplate; +} + +/** + * Builds a namespace-scoped index template from a base template. + * The namespace template has a more specific index pattern, higher priority, + * and includes `@custom` in its `composed_of`. + */ +function buildNamespaceTemplate({ + baseTemplate, + dataStream, + namespace, + templateName, + isOtelInputType, +}: { + baseTemplate: IndexTemplate; + dataStream: RegistryDataStream; + namespace: string; + templateName: string; + isOtelInputType?: boolean; +}): { name: string; template: IndexTemplate } { + const nsTemplateName = generateNamespaceTemplateName(templateName, namespace); + const nsIndexPattern = generateNamespaceTemplateIndexPattern( + dataStream, + namespace, + isOtelInputType + ); + const nsPriority = getNamespaceTemplatePriority(dataStream); + + const composedOf = insertNamespaceCustomTemplate( + [...(baseTemplate.composed_of ?? [])], + namespace, + templateName + ); + + const ignoreMissing = composedOf.filter(isUserSettingsTemplate); + + const nsTemplate: IndexTemplate = { + ...baseTemplate, + index_patterns: [nsIndexPattern], + priority: nsPriority, + composed_of: composedOf, + ignore_missing_component_templates: ignoreMissing, + }; + + return { name: nsTemplateName, template: nsTemplate }; +} + +/** + * Creates namespace-scoped index templates for every `(dataStream, namespace)` pair + * and tracks them in `installed_es`. Returns the created template names. + */ +async function createNamespaceTemplatesForPackage({ + soClient, + esClient, + packageName, + dataStreams, + namespaces, + logContext, + abortController, +}: { + soClient: SavedObjectsClientContract; + esClient: ElasticsearchClient; + packageName: string; + dataStreams: RegistryDataStream[]; + namespaces: string[]; + logContext: string; + abortController?: AbortController; +}): Promise { + if (dataStreams.length === 0 || namespaces.length === 0) { + return []; + } + const logger = appContextService.getLogger(); + const updatedIndexTemplates: IndexTemplateEntry[] = []; + + await pMap( + dataStreams, + async (dataStream) => { + if (abortController) throwIfAborted(abortController); + const isOtelInputType = isOtelDataStream(dataStream); + const templateName = getRegistryDataStreamAssetBaseName(dataStream, isOtelInputType); + const baseTemplate = await fetchBaseTemplate( + esClient, + templateName, + logContext, + abortController + ); + if (!baseTemplate) return; + + for (const namespace of namespaces) { + const { name: nsName, template: nsTemplate } = buildNamespaceTemplate({ + baseTemplate, + dataStream, + namespace, + templateName, + isOtelInputType, + }); + + await esClient.indices.putIndexTemplate( + { name: nsName, ...nsTemplate }, + { signal: abortController?.signal } + ); + updatedIndexTemplates.push({ templateName: nsName, indexTemplate: nsTemplate }); + } + }, + { concurrency: MAX_CONCURRENT_COMPONENT_TEMPLATES } + ); + + if (updatedIndexTemplates.length === 0) { + return []; + } + + if (abortController) throwIfAborted(abortController); + // A user can opt in a namespace before any data stream for that namespace exists + // (no data has been ingested yet). In that case `getDataStream` 404s on the + // namespace-scoped pattern; nothing to update, so just continue. + try { + await updateCurrentWriteIndices(esClient, logger, updatedIndexTemplates); + } catch (err: unknown) { + if ((err as { meta?: { statusCode?: number } })?.meta?.statusCode !== 404) { + throw err; + } + logger.debug(`[${logContext}] no existing data streams to update for new namespace templates`); + } + + const freshInstallation = await getInstallation({ + savedObjectsClient: soClient, + pkgName: packageName, + }); + const assetsToAdd = updatedIndexTemplates.map(({ templateName }) => ({ + id: templateName, + type: ElasticsearchAssetType.indexTemplate, + })); + await updateEsAssetReferences(soClient, packageName, freshInstallation?.installed_es ?? [], { + assetsToAdd, + }); + + return updatedIndexTemplates.map(({ templateName }) => templateName); +} + +/** + * Deletes namespace-scoped index templates for the given `(dataStream, namespace)` + * pairs and removes them from `installed_es`. Returns the deleted template names. + */ +async function deleteNamespaceTemplatesForPackage({ + soClient, + esClient, + packageName, + dataStreams, + namespaces, + logContext, + abortController, +}: { + soClient: SavedObjectsClientContract; + esClient: ElasticsearchClient; + packageName: string; + dataStreams: RegistryDataStream[]; + namespaces: string[]; + logContext: string; + abortController?: AbortController; +}): Promise { + if (dataStreams.length === 0 || namespaces.length === 0) { + return []; + } + const logger = appContextService.getLogger(); + const deleted: string[] = []; + + await pMap( + dataStreams, + async (dataStream) => { + if (abortController) throwIfAborted(abortController); + const templateName = getRegistryDataStreamAssetBaseName( + dataStream, + isOtelDataStream(dataStream) + ); + for (const namespace of namespaces) { + const nsName = generateNamespaceTemplateName(templateName, namespace); + try { + await esClient.indices.deleteIndexTemplate( + { name: nsName }, + { ignore: [404], signal: abortController?.signal } + ); + deleted.push(nsName); + } catch (err: unknown) { + logger.warn( + `[${logContext}] Failed to delete namespace template ${nsName}: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + } + }, + { concurrency: MAX_CONCURRENT_COMPONENT_TEMPLATES } + ); + + if (deleted.length === 0) { + return []; + } + + const freshInstallation = await getInstallation({ + savedObjectsClient: soClient, + pkgName: packageName, + }); + const assetsToRemove = deleted.map((id) => ({ + id, + type: ElasticsearchAssetType.indexTemplate, + })); + await updateEsAssetReferences(soClient, packageName, freshInstallation?.installed_es ?? [], { + assetsToRemove, + }); + + return deleted; +} + +// --------------------------------------------------------------------------- +// Package reinstall/upgrade hook +// --------------------------------------------------------------------------- + +/** + * After a package is (re)installed, rebuild the namespace-scoped index templates for + * every namespace currently opted-in on the package (via + * `Installation.namespace_customization_enabled_for`). Called from the state machine's + * final step so namespace templates survive reinstalls and upgrades. + * + * On first install the opt-in list is empty, so this is a no-op. + */ +export async function handleNamespaceTemplateRestoreAfterPackageInstall({ + soClient, + esClient, + packageName, + dataStreams, +}: { + soClient: SavedObjectsClientContract; + esClient: ElasticsearchClient; + packageName: string; + dataStreams: RegistryDataStream[]; +}) { + if (dataStreams.length === 0) { + return; + } + + const installation = await getInstallation({ + savedObjectsClient: soClient, + pkgName: packageName, + }); + const namespaces = installation?.namespace_customization_enabled_for ?? []; + if (namespaces.length === 0) { + return; + } + + await createNamespaceTemplatesForPackage({ + soClient, + esClient, + packageName, + dataStreams, + namespaces, + logContext: 'handleNamespaceTemplateRestoreAfterPackageInstall', + }); +} + +// --------------------------------------------------------------------------- +// syncNamespaceTemplates — opt-in driven, per-package +// --------------------------------------------------------------------------- + +export interface SyncNamespaceTemplatesSummary { + packageName: string; + created: string[]; // namespace names for which templates were created + removed: string[]; // namespace names for which templates were deleted + skipped: boolean; +} + +/** + * Creates or deletes namespace-scoped index templates for a single package, driven + * by additions to and removals from `Installation.namespace_customization_enabled_for`. + * + * Called from the `fleet:sync_namespace_templates` task after the Installation SO's + * opt-in list has been updated by the API handler. + */ +export async function syncNamespaceTemplates({ + soClient, + esClient, + packageName, + addedNamespaces, + removedNamespaces, + abortController, +}: { + soClient: SavedObjectsClientContract; + esClient: ElasticsearchClient; + packageName: string; + addedNamespaces: string[]; + removedNamespaces: string[]; + abortController?: AbortController; +}): Promise { + const summary: SyncNamespaceTemplatesSummary = { + packageName, + created: [], + removed: [], + skipped: false, + }; + if (addedNamespaces.length === 0 && removedNamespaces.length === 0) { + return summary; + } + + const installedPkg = await getInstalledPackageWithAssets({ + savedObjectsClient: soClient, + pkgName: packageName, + }); + if (!installedPkg) { + appContextService + .getLogger() + .debug(`[syncNamespaceTemplates] Package ${packageName} not installed, skipping`); + summary.skipped = true; + return summary; + } + + const { packageInfo } = installedPkg; + const dataStreams = packageInfo.data_streams ?? []; + if (dataStreams.length === 0) { + return summary; + } + + if (addedNamespaces.length > 0) { + if (abortController) throwIfAborted(abortController); + const createdTemplates = await createNamespaceTemplatesForPackage({ + soClient, + esClient, + packageName, + dataStreams, + namespaces: addedNamespaces, + logContext: 'syncNamespaceTemplates', + abortController, + }); + if (createdTemplates.length > 0) { + summary.created = addedNamespaces; + } + } + + if (removedNamespaces.length > 0) { + if (abortController) throwIfAborted(abortController); + const deletedTemplates = await deleteNamespaceTemplatesForPackage({ + soClient, + esClient, + packageName, + dataStreams, + namespaces: removedNamespaces, + logContext: 'syncNamespaceTemplates', + abortController, + }); + if (deletedTemplates.length > 0) { + summary.removed = removedNamespaces; + } + } + + return summary; +} diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/update.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/update.ts index d142c47e09adc..12aa0a13fd4c8 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/update.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/update.ts @@ -18,14 +18,27 @@ import { auditLoggingService } from '../../audit_logging'; import { getInstallationObject, getPackageInfo } from './get'; +export interface NamespaceCustomizationDiff { + addedNamespaces: string[]; + removedNamespaces: string[]; +} + export async function updatePackage( options: { savedObjectsClient: SavedObjectsClientContract; pkgName: string; keepPoliciesUpToDate?: boolean; } & TypeOf -) { - const { savedObjectsClient, pkgName, keepPoliciesUpToDate } = options; +): Promise<{ + packageInfo: Awaited>; + namespaceCustomizationDiff: NamespaceCustomizationDiff; +}> { + const { + savedObjectsClient, + pkgName, + keepPoliciesUpToDate, + namespace_customization_enabled_for: newNamespaceCustomization, + } = options; const installedPackage = await getInstallationObject({ savedObjectsClient, pkgName }); if (!installedPackage) { @@ -39,12 +52,27 @@ export async function updatePackage( savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, }); - const updateAttrs: Partial = { - keep_policies_up_to_date: keepPoliciesUpToDate ?? false, + const updateAttrs: Partial = {}; + const namespaceCustomizationDiff: NamespaceCustomizationDiff = { + addedNamespaces: [], + removedNamespaces: [], }; - if (keepPoliciesUpToDate === false) { - updateAttrs.pending_upgrade_review = undefined; + + if (keepPoliciesUpToDate !== undefined) { + updateAttrs.keep_policies_up_to_date = keepPoliciesUpToDate; + if (keepPoliciesUpToDate === false) { + updateAttrs.pending_upgrade_review = undefined; + } + } + + if (newNamespaceCustomization) { + const oldList = installedPackage.attributes.namespace_customization_enabled_for ?? []; + const newList = [...new Set(newNamespaceCustomization)]; + namespaceCustomizationDiff.addedNamespaces = newList.filter((ns) => !oldList.includes(ns)); + namespaceCustomizationDiff.removedNamespaces = oldList.filter((ns) => !newList.includes(ns)); + updateAttrs.namespace_customization_enabled_for = newList; } + await savedObjectsClient.update( PACKAGES_SAVED_OBJECT_TYPE, installedPackage.id, @@ -57,7 +85,7 @@ export async function updatePackage( pkgVersion: installedPackage.attributes.version, }); - return packageInfo; + return { packageInfo, namespaceCustomizationDiff }; } export async function reviewUpgrade(options: { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/spaces/policy_namespaces.ts b/x-pack/platform/plugins/shared/fleet/server/services/spaces/policy_namespaces.ts index dae04aed32373..1d1a27f13b5c1 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/spaces/policy_namespaces.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/spaces/policy_namespaces.ts @@ -21,6 +21,38 @@ import { PackagePolicyNameExistsError } from '../../errors'; import { getSpaceSettings } from './space_settings'; +/** + * Returns the allowed namespace prefixes for the given Kibana space, or `null` + * if no restriction applies (space awareness disabled or no prefixes configured). + */ +export async function getAllowedNamespacePrefixesForSpace( + spaceId?: string +): Promise { + const experimentalFeature = appContextService.getExperimentalFeatures(); + if (!experimentalFeature.useSpaceAwareness) { + return null; + } + const settings = await getSpaceSettings(spaceId); + if (!settings.allowed_namespace_prefixes || settings.allowed_namespace_prefixes.length === 0) { + return null; + } + return settings.allowed_namespace_prefixes; +} + +/** + * Returns true if the namespace is permitted by the given prefix list. + * `null` means no restriction (anything is permitted). + */ +export function isNamespaceAllowedByPrefixes( + namespace: string, + prefixes: string[] | null +): boolean { + if (prefixes === null) { + return true; + } + return prefixes.some((prefix) => namespace.startsWith(prefix)); +} + export async function validatePolicyNamespaceForSpace({ namespace, spaceId, diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.test.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.test.ts new file mode 100644 index 0000000000000..f23f363b9069c --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; + +import { appContextService } from '../services'; +import { syncNamespaceTemplates } from '../services/epm/packages'; + +import { + registerSyncNamespaceTemplatesTask, + scheduleSyncNamespaceTemplatesTask, +} from './sync_namespace_templates_task'; + +jest.mock('../services'); +jest.mock('../services/epm/packages'); + +const mockedSyncNamespaceTemplates = syncNamespaceTemplates as jest.MockedFunction< + typeof syncNamespaceTemplates +>; + +const mockSoClient = { getCurrentNamespace: jest.fn().mockReturnValue('default') } as any; +const mockEsClient = {} as any; + +describe('syncNamespaceTemplatesTask', () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (appContextService.getLogger as jest.Mock).mockReturnValue(logger); + (appContextService.getInternalUserSOClientForSpaceId as jest.Mock).mockReturnValue( + mockSoClient + ); + (appContextService.getInternalUserESClient as jest.Mock).mockReturnValue(mockEsClient); + mockedSyncNamespaceTemplates.mockResolvedValue({ + packageName: 'nginx', + created: [], + removed: [], + skipped: false, + }); + }); + + describe('registerSyncNamespaceTemplatesTask', () => { + it('should register the task definition', () => { + const taskManager = taskManagerMock.createSetup(); + registerSyncNamespaceTemplatesTask(taskManager); + expect(taskManager.registerTaskDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + 'fleet:sync_namespace_templates': expect.objectContaining({ + title: 'Fleet Sync namespace templates', + timeout: '15m', + maxAttempts: 3, + }), + }) + ); + }); + }); + + describe('scheduleSyncNamespaceTemplatesTask', () => { + it('should schedule the task with correct parameters', async () => { + const taskManager = taskManagerMock.createStart(); + await scheduleSyncNamespaceTemplatesTask(taskManager, { + spaceId: 'default', + packageName: 'nginx', + addedNamespaces: ['production'], + removedNamespaces: [], + }); + expect(taskManager.ensureScheduled).toHaveBeenCalledWith( + expect.objectContaining({ + taskType: 'fleet:sync_namespace_templates', + scope: ['fleet'], + params: { + spaceId: 'default', + packageName: 'nginx', + addedNamespaces: ['production'], + removedNamespaces: [], + }, + state: {}, + }) + ); + }); + + it('should not schedule when there are no namespace changes', async () => { + const taskManager = taskManagerMock.createStart(); + await scheduleSyncNamespaceTemplatesTask(taskManager, { + spaceId: 'default', + packageName: 'nginx', + addedNamespaces: [], + removedNamespaces: [], + }); + expect(taskManager.ensureScheduled).not.toHaveBeenCalled(); + }); + }); + + describe('task runner', () => { + const createTaskRunner = (params: Record) => { + const taskManager = taskManagerMock.createSetup(); + registerSyncNamespaceTemplatesTask(taskManager); + const registeredDef = + taskManager.registerTaskDefinitions.mock.calls[0][0]['fleet:sync_namespace_templates']; + const abortController = new AbortController(); + const runner = registeredDef.createTaskRunner({ + taskInstance: { params } as any, + abortController, + }); + return { runner, abortController }; + }; + + it('should call syncNamespaceTemplates with correct parameters', async () => { + const { runner, abortController } = createTaskRunner({ + spaceId: 'my_space', + packageName: 'nginx', + addedNamespaces: ['production'], + removedNamespaces: ['staging'], + }); + + await runner.run(); + + expect(appContextService.getInternalUserSOClientForSpaceId).toHaveBeenCalledWith('my_space'); + expect(appContextService.getInternalUserESClient).toHaveBeenCalled(); + expect(mockedSyncNamespaceTemplates).toHaveBeenCalledWith({ + soClient: mockSoClient, + esClient: mockEsClient, + packageName: 'nginx', + addedNamespaces: ['production'], + removedNamespaces: ['staging'], + abortController, + }); + }); + + it('should be a no-op when there are no namespace changes', async () => { + const { runner } = createTaskRunner({ + spaceId: 'default', + packageName: 'nginx', + addedNamespaces: [], + removedNamespaces: [], + }); + + await runner.run(); + + expect(mockedSyncNamespaceTemplates).not.toHaveBeenCalled(); + }); + + it('should pass an aborted signal to syncNamespaceTemplates when the task is cancelled', async () => { + const { runner, abortController } = createTaskRunner({ + spaceId: 'default', + packageName: 'nginx', + addedNamespaces: ['production'], + removedNamespaces: [], + }); + + abortController.abort(); + await runner.run(); + + expect(mockedSyncNamespaceTemplates).toHaveBeenCalledWith( + expect.objectContaining({ abortController }) + ); + expect(abortController.signal.aborted).toBe(true); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.ts new file mode 100644 index 0000000000000..0da7e4e28b3af --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_namespace_templates_task.ts @@ -0,0 +1,95 @@ +/* + * 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 type { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '@kbn/task-manager-plugin/server'; +import { v4 as uuidv4 } from 'uuid'; + +import { appContextService } from '../services'; +import { syncNamespaceTemplates } from '../services/epm/packages'; + +const TASK_TYPE = 'fleet:sync_namespace_templates'; + +export interface SyncNamespaceTemplatesTaskParams { + spaceId: string; + packageName: string; + addedNamespaces: string[]; + removedNamespaces: string[]; +} + +export function registerSyncNamespaceTemplatesTask(taskManagerSetup: TaskManagerSetupContract) { + taskManagerSetup.registerTaskDefinitions({ + [TASK_TYPE]: { + title: 'Fleet Sync namespace templates', + // Most syncs finish in well under a minute, but a large integration with many + // data streams and existing backing indices can need longer due to mapping + // updates and rollovers. 15 minutes is a generous safety margin while still + // bounded; failures are retried up to maxAttempts. + timeout: '15m', + maxAttempts: 3, + createTaskRunner: ({ taskInstance, abortController }) => { + const { spaceId, packageName, addedNamespaces, removedNamespaces } = + taskInstance.params as SyncNamespaceTemplatesTaskParams; + return { + async run() { + if (addedNamespaces.length === 0 && removedNamespaces.length === 0) { + return; + } + + const logger = appContextService.getLogger(); + logger.debug( + `[syncNamespaceTemplatesTask] Running for package ${packageName} in space ${spaceId}: adding [${addedNamespaces}], removing [${removedNamespaces}]` + ); + + const soClient = appContextService.getInternalUserSOClientForSpaceId(spaceId); + const esClient = appContextService.getInternalUserESClient(); + + try { + await syncNamespaceTemplates({ + soClient, + esClient, + packageName, + addedNamespaces, + removedNamespaces, + abortController, + }); + } catch (err) { + logger.error( + `[syncNamespaceTemplatesTask] Failed for package ${packageName} in space ${spaceId}: ${ + err instanceof Error ? err.message : String(err) + }`, + { error: err } + ); + // Rethrow so task manager records the failure and retries up to maxAttempts. + throw err; + } + }, + }; + }, + }, + }); +} + +export async function scheduleSyncNamespaceTemplatesTask( + taskManagerStart: TaskManagerStartContract, + params: SyncNamespaceTemplatesTaskParams +) { + if (params.addedNamespaces.length === 0 && params.removedNamespaces.length === 0) { + return; + } + + await taskManagerStart.ensureScheduled({ + id: `${TASK_TYPE}:${uuidv4()}`, + scope: ['fleet'], + params, + taskType: TASK_TYPE, + runAt: new Date(Date.now() + 3 * 1000), + state: {}, + }); +} diff --git a/x-pack/platform/plugins/shared/fleet/server/types/models/epm_packages.ts b/x-pack/platform/plugins/shared/fleet/server/types/models/epm_packages.ts index 24cec4f485eff..944f079504c92 100644 --- a/x-pack/platform/plugins/shared/fleet/server/types/models/epm_packages.ts +++ b/x-pack/platform/plugins/shared/fleet/server/types/models/epm_packages.ts @@ -87,6 +87,9 @@ export const EpmPackagesSchemaV8 = EpmPackagesSchemaV7.extends({ }); export const EpmPackagesSchemaV9 = EpmPackagesSchemaV8.extends({ + namespace_customization_enabled_for: schema.maybe( + schema.arrayOf(schema.string(), { maxSize: 100 }) + ), previous_dependency_versions: schema.maybe( schema.nullable( schema.arrayOf( diff --git a/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/epm.ts b/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/epm.ts index 2d04f0933151b..5e1c721d2f688 100644 --- a/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/epm.ts +++ b/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/epm.ts @@ -681,7 +681,28 @@ export const GetBulkAssetsRequestSchema = { export const UpdatePackageRequestSchema = { params: PackageVersionRequestParamsSchema, body: schema.object({ - keepPoliciesUpToDate: schema.boolean(), + keepPoliciesUpToDate: schema.maybe(schema.boolean()), + namespace_customization_enabled_for: schema.maybe( + schema.arrayOf( + schema.string({ + validate: (v) => { + if (!v.length) { + return 'Must not be empty'; + } + if (!/^[a-z0-9_]+$/.test(v)) { + return 'Must only contain lowercase letters, numbers, and underscores'; + } + }, + }), + { + maxSize: 100, + meta: { + description: + 'Namespaces for which namespace-level customization is enabled on this package.', + }, + } + ) + ), }), }; @@ -690,6 +711,66 @@ export const UpdatePackageWithoutVersionRequestSchema = { body: UpdatePackageRequestSchema.body, }; +export const BulkNamespaceCustomizationRequestSchema = { + body: schema.object({ + packages: schema.arrayOf(schema.string(), { + minSize: 1, + maxSize: 1000, + meta: { + description: 'Package names to apply the customization changes to.', + }, + }), + enable: schema.maybe( + schema.arrayOf( + schema.string({ + validate: (v) => { + if (!v.length) { + return 'Must not be empty'; + } + if (!/^[a-z0-9_]+$/.test(v)) { + return 'Must only contain lowercase letters, numbers, and underscores'; + } + }, + }), + { + maxSize: 100, + meta: { + description: 'Namespaces to enable namespace-level customization for on each package.', + }, + } + ) + ), + disable: schema.maybe( + schema.arrayOf(schema.string(), { + maxSize: 100, + meta: { + description: 'Namespaces to disable namespace-level customization for on each package.', + }, + }) + ), + }), +}; + +export const BulkNamespaceCustomizationResponseSchema = schema.object({ + items: schema.arrayOf( + schema.object({ + name: schema.string(), + success: schema.boolean(), + namespace_customization_enabled_for: schema.maybe( + schema.arrayOf(schema.string(), { + maxSize: 100, + meta: { + description: + 'The opt-in list on the package. Returned whenever the package is installed: the new list on success, or the unchanged list when the request is rejected (for example, because of a namespace-prefix restriction).', + }, + }) + ), + error: schema.maybe(schema.string()), + }), + { maxSize: 1000 } + ), +}); + export const ReviewUpgradeRequestSchema = { params: schema.object({ pkgName: schema.string({ diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 74d24907111c9..c69b97ca04591 100644 --- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -214,6 +214,7 @@ export default function ({ getService }: FtrProviderContext) { 'fleet:setup', 'fleet:setup:upgrade_managed_package_policies', 'fleet:sync-integrations-task', + 'fleet:sync_namespace_templates', 'fleet:unenroll-inactive-agents-task', 'fleet:unenroll_action:retry', 'fleet:update_agent_tags:retry',