diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index d763a7a18f5be..2f48a52ec2c96 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -24854,6 +24854,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -25969,6 +25971,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -26861,6 +26865,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -27732,6 +27738,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -28846,6 +28854,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -29830,6 +29840,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -32162,6 +32174,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -45952,6 +45966,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -46502,6 +46518,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -47074,6 +47092,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -47675,6 +47695,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -48325,6 +48347,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -48879,6 +48903,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -49447,6 +49473,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -50392,6 +50420,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -50900,6 +50930,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 5ece30ff28da5..81cf88f762925 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -27428,6 +27428,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -28543,6 +28545,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -29435,6 +29439,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -30306,6 +30312,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -31420,6 +31428,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -32404,6 +32414,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -34736,6 +34748,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -48526,6 +48540,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -49076,6 +49092,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -49648,6 +49666,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -50249,6 +50269,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -50899,6 +50921,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -51453,6 +51477,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -52021,6 +52047,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -52966,6 +52994,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: @@ -53474,6 +53504,8 @@ paths: type: boolean migrate_from: type: string + name: + type: string policy_template: type: string streams: diff --git a/x-pack/platform/plugins/shared/fleet/common/services/index.ts b/x-pack/platform/plugins/shared/fleet/common/services/index.ts index aabd814c731bf..2de7928bccd78 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/index.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/index.ts @@ -14,6 +14,8 @@ export { getStreamsForInputType, getRegistryStreamWithDataStreamForInputType, varsReducer, + getInputEffectiveName, + buildInputKey, } from './package_to_package_policy'; export { fullAgentPolicyToYaml } from './full_agent_policy_to_yaml'; export { isPackageLimited, doesAgentPolicyAlreadyIncludePackage } from './limited_package'; diff --git a/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.test.ts index 80278b98279d6..7e89703aff204 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.test.ts @@ -5,11 +5,48 @@ * 2.0. */ -import type { PackageInfo } from '../types'; +import type { NewPackagePolicyInput, PackageInfo } from '../types'; -import { packageToPackagePolicy, packageToPackagePolicyInputs } from './package_to_package_policy'; +import { + packageToPackagePolicy, + packageToPackagePolicyInputs, + getInputEffectiveName, +} from './package_to_package_policy'; import { AWS_PACKAGE } from './fixtures/aws_package'; +describe('getInputEffectiveName', () => { + it('returns name when present on a registry input', () => { + expect(getInputEffectiveName({ name: 'filelog_otel', type: 'otelcol' })).toBe('filelog_otel'); + }); + + it('falls back to type when name is absent on a registry input', () => { + expect(getInputEffectiveName({ type: 'logfile' })).toBe('logfile'); + }); + + it('returns name when present on a policy input, ignoring the instance id', () => { + expect( + getInputEffectiveName({ + id: 'otelcol-nginx-abc123', + name: 'filelog_otel', + type: 'otelcol', + enabled: true, + streams: [], + } as NewPackagePolicyInput) + ).toBe('filelog_otel'); + }); + + it('falls back to type when name is absent on a policy input', () => { + expect( + getInputEffectiveName({ + id: 'logfile-nginx-abc123', + type: 'logfile', + enabled: true, + streams: [], + } as NewPackagePolicyInput) + ).toBe('logfile'); + }); +}); + describe('Fleet - packageToPackagePolicy', () => { const mockPackage: PackageInfo = { name: 'mock-package', @@ -93,6 +130,119 @@ describe('Fleet - packageToPackagePolicy', () => { ]); }); + it('returns distinct inputs when multiple inputs share the same type but have different ids', () => { + const result = packageToPackagePolicyInputs({ + ...mockPackage, + policy_templates: [ + { + name: 'nginx', + inputs: [ + { name: 'filelog_otel', type: 'otelcol', title: 'Logs via filelog' }, + { name: 'nginx_otel', type: 'otelcol', title: 'Metrics via nginx receiver' }, + ], + }, + ], + } as unknown as PackageInfo); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + enabled: true, + }); + expect(result[1]).toMatchObject({ + type: 'otelcol', + name: 'nginx_otel', + policy_template: 'nginx', + enabled: true, + }); + }); + + it('does not set name when registry input has no id', () => { + const result = packageToPackagePolicyInputs({ + ...mockPackage, + policy_templates: [ + { + name: 'test_template', + inputs: [{ type: 'logfile' }], + }, + ], + } as unknown as PackageInfo); + + expect(result).toHaveLength(1); + expect(result[0].name).toBeUndefined(); + }); + + it('does not match streams that reference by type when inputs have explicit ids', () => { + const result = packageToPackagePolicyInputs({ + ...mockPackage, + data_streams: [ + { + type: 'logs', + dataset: 'nginx.access', + path: 'access', + streams: [{ input: 'otelcol', title: 'Access logs' }], + }, + ], + policy_templates: [ + { + name: 'nginx', + data_streams: ['access'], + inputs: [ + { name: 'filelog_otel', type: 'otelcol', title: 'Logs' }, + { name: 'nginx_otel', type: 'otelcol', title: 'Metrics' }, + ], + }, + ], + } as unknown as PackageInfo); + + expect(result).toHaveLength(2); + // Neither input should have matched the stream because + // 'otelcol' is the type, not an id + expect(result[0].streams).toHaveLength(0); + expect(result[1].streams).toHaveLength(0); + }); + + it('matches streams to inputs by id when inputs have ids', () => { + const result = packageToPackagePolicyInputs({ + ...mockPackage, + data_streams: [ + { + type: 'logs', + dataset: 'nginx.access', + path: 'access', + streams: [{ input: 'filelog_otel', title: 'Access logs' }], + }, + { + type: 'metrics', + dataset: 'nginx.stubstatus', + path: 'stubstatus', + streams: [{ input: 'nginx_otel', title: 'Stub status metrics' }], + }, + ], + policy_templates: [ + { + name: 'nginx', + data_streams: ['access', 'stubstatus'], + inputs: [ + { name: 'filelog_otel', type: 'otelcol', title: 'Logs' }, + { name: 'nginx_otel', type: 'otelcol', title: 'Metrics' }, + ], + }, + ], + } as unknown as PackageInfo); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('filelog_otel'); + expect(result[0].streams).toHaveLength(1); + expect(result[0].streams[0].data_stream.dataset).toBe('nginx.access'); + + expect(result[1].name).toBe('nginx_otel'); + expect(result[1].streams).toHaveLength(1); + expect(result[1].streams[0].data_stream.dataset).toBe('nginx.stubstatus'); + }); + it('returns inputs with streams for packages with streams', () => { expect( packageToPackagePolicyInputs({ diff --git a/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.ts b/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.ts index d1a68c81d39f5..9083cdecf3d3b 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.ts @@ -29,6 +29,29 @@ type PackagePolicyStream = RegistryStream & { data_stream: { type?: string; dataset: string }; }; +/** + * Returns the effective discriminator for an input, regardless of whether it comes from + * the registry (`RegistryInput`) or a stored package policy (`NewPackagePolicyInput`). + * + * Uses the explicit `name` field when present, falling back to `type`. This value is + * used as the keying and matching discriminator throughout Fleet so that multiple inputs + * of the same `type` within one policy template can be distinguished. + */ +export const getInputEffectiveName = (input: { name?: string; type: string }): string => + input.name ?? input.type; + +/** + * Builds the composite key used to index input validation results and var definitions. + * For packages with integrations (multiple policy templates), the key is prefixed with + * the policy template name to avoid collisions across templates. + */ +export const buildInputKey = ( + effectiveName: string, + policyTemplateName: string | undefined, + hasIntegrations: boolean +): string => + hasIntegrations && policyTemplateName ? `${policyTemplateName}-${effectiveName}` : effectiveName; + export const getStreamsForInputType = ( inputType: string, packageInfo: PackageInfo, @@ -115,7 +138,7 @@ export const packageToPackagePolicyInputs = ( packageInfo.policy_templates?.forEach((packagePolicyTemplate) => { const normalizedInputs = getNormalizedInputs(packagePolicyTemplate); normalizedInputs?.forEach((packageInput) => { - const inputKey = `${packagePolicyTemplate.name}-${packageInput.type}`; + const inputKey = `${packagePolicyTemplate.name}-${getInputEffectiveName(packageInput)}`; const input = { ...packageInput, ...(isIntegrationPolicyTemplate(packagePolicyTemplate) && packagePolicyTemplate.data_streams @@ -131,9 +154,13 @@ export const packageToPackagePolicyInputs = ( const streamsForInput: NewPackagePolicyInputStream[] = []; let varsForInput: PackagePolicyConfigRecord = {}; + // Use the input's id as the discriminator for stream matching when present, + // so that stream.input values reference the input id rather than the type. + const streamMatchKey = getInputEffectiveName(packageInput); + // Map each package input stream into package policy input stream const streams = getStreamsForInputType( - packageInput.type, + streamMatchKey, packageInfo, packageInput.data_streams ).map((packageStream) => { @@ -178,6 +205,7 @@ export const packageToPackagePolicyInputs = ( const input: NewPackagePolicyInput = { type: packageInput.type, + ...(packageInput.name ? { name: packageInput.name } : {}), policy_template: packageInput.policy_template, enabled: enableInput, streams: streamsForInput, diff --git a/x-pack/platform/plugins/shared/fleet/common/services/simplified_package_policy_helper.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/simplified_package_policy_helper.test.ts index 53d021c8a39c7..80791ea6a3b68 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/simplified_package_policy_helper.test.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/simplified_package_policy_helper.test.ts @@ -29,6 +29,42 @@ function getEnabledInputsAndStreams(newPackagePolicy: NewPackagePolicy) { }, {} as Record); } +describe('generateInputId', () => { + it('should use name instead of type when name is present', () => { + expect( + generateInputId({ + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + enabled: true, + streams: [], + }) + ).toBe('nginx-filelog_otel'); + }); + + it('should fall back to type when name is not present', () => { + expect( + generateInputId({ + type: 'logfile', + policy_template: 'nginx', + enabled: true, + streams: [], + }) + ).toBe('nginx-logfile'); + }); + + it('should use name without policy_template prefix when policy_template is not stored on the input (single-template packages)', () => { + expect( + generateInputId({ + type: 'otelcol', + name: 'filelog_otel', + enabled: true, + streams: [], + }) + ).toBe('filelog_otel'); + }); +}); + describe('toPackagePolicy', () => { describe('With nginx package', () => { it('should work', () => { diff --git a/x-pack/platform/plugins/shared/fleet/common/services/simplified_package_policy_helper.ts b/x-pack/platform/plugins/shared/fleet/common/services/simplified_package_policy_helper.ts index aceebbee05f45..7431c874a6a65 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/simplified_package_policy_helper.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/simplified_package_policy_helper.ts @@ -19,7 +19,7 @@ import { DATASET_VAR_NAME } from '../constants'; import { PackagePolicyValidationError } from '../errors'; -import { packageToPackagePolicy } from '.'; +import { packageToPackagePolicy, getInputEffectiveName } from '.'; import { isInputAllowedForDeploymentMode } from './agentless_policy_helper'; export type SimplifiedVars = Record< @@ -93,7 +93,9 @@ export function packagePolicyToSimplifiedPackagePolicy(packagePolicy: PackagePol } export function generateInputId(input: NewPackagePolicyInput) { - return `${input.policy_template ? `${input.policy_template}-` : ''}${input.type}`; + return `${input.policy_template ? `${input.policy_template}-` : ''}${getInputEffectiveName( + input + )}`; } export function formatInputs( diff --git a/x-pack/platform/plugins/shared/fleet/common/services/validate_package_policy.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/validate_package_policy.test.ts index 5b8b84e7ea4f3..76c71c993f0aa 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/validate_package_policy.test.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/validate_package_policy.test.ts @@ -656,6 +656,115 @@ describe('Fleet - validatePackagePolicy()', () => { ).toBe(false); }); }); + + describe('works for inputs with same type but different ids', () => { + const packageWithDuplicateTypeInputs: PackageInfo = { + name: 'nginx', + title: 'Nginx', + version: '1.0.0', + latestVersion: '1.0.0', + description: 'Nginx integration', + type: 'integration', + categories: [], + conditions: { kibana: { version: '' } }, + format_version: '', + download: '', + path: '', + assets: { kibana: {} as any, elasticsearch: {} as any }, + status: 'not_installed', + release: 'ga', + owner: { github: 'elastic/fleet' }, + policy_templates: [ + { + name: 'nginx', + title: 'Nginx', + description: 'Nginx logs and metrics', + data_streams: ['access', 'stubstatus'], + inputs: [ + { + name: 'filelog_otel', + type: 'otelcol', + title: 'Logs via filelog', + description: 'Collect logs', + vars: [{ name: 'log_path', type: 'text', required: true }], + }, + { + name: 'nginx_otel', + type: 'otelcol', + title: 'Metrics via nginx receiver', + description: 'Collect metrics', + vars: [{ name: 'endpoint', type: 'text', required: true }], + }, + ], + }, + ] as unknown as RegistryPolicyTemplate[], + data_streams: [ + { + type: 'logs', + dataset: 'nginx.access', + title: 'Access logs', + path: 'access', + release: 'ga', + package: 'nginx', + streams: [{ input: 'filelog_otel', title: 'Access logs', vars: [] }], + }, + { + type: 'metrics', + dataset: 'nginx.stubstatus', + title: 'Stub status', + path: 'stubstatus', + release: 'ga', + package: 'nginx', + streams: [{ input: 'nginx_otel', title: 'Stub status', vars: [] }], + }, + ] as any, + }; + + it('validates each input independently using name as key', () => { + const packagePolicy: NewPackagePolicy = { + name: 'nginx-1', + namespace: 'default', + policy_ids: ['policy1'], + enabled: true, + inputs: [ + { + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + enabled: true, + vars: { log_path: { value: '/var/log/nginx/access.log' } }, + streams: [ + { + enabled: true, + data_stream: { type: 'logs', dataset: 'nginx.access' }, + }, + ], + }, + { + type: 'otelcol', + name: 'nginx_otel', + policy_template: 'nginx', + enabled: true, + vars: { endpoint: { value: undefined } }, + streams: [ + { + enabled: true, + data_stream: { type: 'metrics', dataset: 'nginx.stubstatus' }, + }, + ], + }, + ], + }; + + const result = validatePackagePolicy(packagePolicy, packageWithDuplicateTypeInputs, load); + + // The first input (filelog_otel) has a valid var, so no error + // Single policy template packages use just the effectiveName as key (no template prefix) + expect(result.inputs?.filelog_otel?.vars?.log_path).toBeNull(); + // The second input (nginx_otel) has an empty required var, so it should error + expect(result.inputs?.nginx_otel?.vars?.endpoint).not.toBeNull(); + }); + }); }); describe('Fleet - validateConditionalRequiredVars()', () => { diff --git a/x-pack/platform/plugins/shared/fleet/common/services/validate_package_policy.ts b/x-pack/platform/plugins/shared/fleet/common/services/validate_package_policy.ts index f4b38f02300aa..f9da4aa5e1540 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/validate_package_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/validate_package_policy.ts @@ -29,6 +29,8 @@ import { doesPackageHaveIntegrations, getNormalizedInputs, getNormalizedDataStreams, + getInputEffectiveName, + buildInputKey, } from '.'; import { packageHasNoPolicyTemplates } from './policy_template'; import { isValidDataset } from './is_valid_namespace'; @@ -369,7 +371,11 @@ export const validatePackagePolicy = ( >((varDefs, policyTemplate) => { const inputs = getNormalizedInputs(policyTemplate); inputs.forEach((input) => { - const varDefKey = hasIntegrations ? `${policyTemplate.name}-${input.type}` : input.type; + const varDefKey = buildInputKey( + getInputEffectiveName(input), + policyTemplate.name, + hasIntegrations + ); if ((input.vars || []).length) { varDefs[varDefKey] = keyBy(input.vars || [], 'name'); @@ -382,9 +388,11 @@ export const validatePackagePolicy = ( >((reqVarDefs, policyTemplate) => { const inputs = getNormalizedInputs(policyTemplate); inputs.forEach((input) => { - const requiredVarDefKey = hasIntegrations - ? `${policyTemplate.name}-${input.type}` - : input.type; + const requiredVarDefKey = buildInputKey( + getInputEffectiveName(input), + policyTemplate.name, + hasIntegrations + ); if ((input.vars || []).length) { reqVarDefs[requiredVarDefKey] = input.required_vars; @@ -430,7 +438,11 @@ export const validatePackagePolicy = ( if (!input.vars && !input.streams) { return; } - const inputKey = hasIntegrations ? `${input.policy_template}-${input.type}` : input.type; + const inputKey = buildInputKey( + getInputEffectiveName(input), + input.policy_template, + hasIntegrations + ); const inputValidationResults: PackagePolicyInputValidationResults = { vars: undefined, required_vars: undefined, 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 3b058da24c7cf..68c36be9fb192 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 @@ -308,6 +308,7 @@ export type RegistryPolicyTemplate = | RegistryPolicyInputOnlyTemplate; export enum RegistryInputKeys { + name = 'name', type = 'type', title = 'title', description = 'description', @@ -328,6 +329,8 @@ export enum RegistryInputKeys { export type RegistryInputGroup = 'logs' | 'metrics'; export interface RegistryInput { + /** Optional unique name within the policy template. When present, used as the discriminator for stream matching and keying instead of `type`. */ + [RegistryInputKeys.name]?: string; [RegistryInputKeys.type]: string; [RegistryInputKeys.title]: string; [RegistryInputKeys.description]: string; diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/package_policy.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/package_policy.ts index 618e170faf5b9..62db821323b04 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/package_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/package_policy.ts @@ -60,7 +60,10 @@ export interface PackagePolicyInputStream extends NewPackagePolicyInputStream { } export interface NewPackagePolicyInput { + /** Auto-generated instance identifier for this input within a saved package policy (e.g. `otelcol-nginx-abc123`). Distinct from `name`, which comes from the registry manifest and is used to disambiguate inputs of the same type. */ id?: string; + /** The registry input's `name` field, when set. Used to disambiguate multiple inputs of the same `type` within a policy template. Falls back to `type` when absent. */ + name?: string; type: string; policy_template?: string; enabled: boolean; diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/package_policy_schema.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/package_policy_schema.ts index bc901924ef1fb..f0e46960179c5 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/package_policy_schema.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/package_policy_schema.ts @@ -95,6 +95,7 @@ const PackagePolicyStreamsSchema = { export const PackagePolicyInputsSchema = { id: schema.maybe(schema.string()), + name: schema.maybe(schema.string()), type: schema.string(), policy_template: schema.maybe(schema.string()), enabled: schema.boolean(), diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.test.tsx index 6ab7da5ee78a7..5cefbf34c988d 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.test.tsx @@ -418,6 +418,203 @@ describe('StepConfigurePackage', () => { }); }); +describe('StepConfigurePackage with multiple inputs of same type but different ids', () => { + let testRenderer: TestRenderer; + let renderResult: ReturnType; + const mockUpdatePackagePolicy = jest.fn(); + + const otelPackageInfo: PackageInfo = { + name: 'nginx', + title: 'Nginx', + version: '1.0.0', + release: 'ga', + description: 'Nginx integration with OTel inputs', + format_version: '', + owner: { github: '' }, + assets: {} as any, + policy_templates: [ + { + name: 'nginx', + title: 'Nginx logs and metrics', + description: 'Collect logs and metrics from Nginx', + data_streams: ['access', 'stubstatus'], + inputs: [ + { + name: 'filelog_otel', + type: 'otelcol', + title: 'Collect Nginx access logs via filelog OTel receiver', + description: 'Tail Nginx access log files', + }, + { + name: 'nginx_otel', + type: 'otelcol', + title: 'Collect Nginx stub status metrics via OTel receiver', + description: 'Scrape Nginx stub_status metrics', + }, + ], + multiple: true, + }, + ], + data_streams: [ + { + type: 'logs', + dataset: 'nginx.access', + title: 'Nginx access logs', + release: 'ga', + ingest_pipeline: 'default', + streams: [ + { + input: 'filelog_otel', + vars: [ + { + name: 'log_path', + type: 'text', + title: 'Log Path', + required: true, + show_user: true, + default: '/var/log/nginx/access.log', + }, + ], + template_path: 'stream.yml.hbs', + title: 'Nginx access logs', + description: 'Collect Nginx access logs', + enabled: true, + }, + ], + package: 'nginx', + path: 'access', + }, + { + type: 'metrics', + dataset: 'nginx.stubstatus', + title: 'Nginx stub status', + release: 'ga', + ingest_pipeline: 'default', + streams: [ + { + input: 'nginx_otel', + vars: [ + { + name: 'endpoint', + type: 'text', + title: 'Stub Status Endpoint', + required: true, + show_user: true, + default: 'http://localhost:8080/stub_status', + }, + ], + template_path: 'stream.yml.hbs', + title: 'Nginx stub status metrics', + description: 'Collect Nginx stub status metrics', + enabled: true, + }, + ], + package: 'nginx', + path: 'stubstatus', + }, + ], + latestVersion: '1.0.0', + keepPoliciesUpToDate: false, + status: 'not_installed', + }; + + const otelPackagePolicy: NewPackagePolicy = { + name: 'nginx-1', + description: '', + namespace: 'default', + policy_id: '', + policy_ids: [''], + enabled: true, + inputs: [ + { + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'logs', dataset: 'nginx.access' }, + vars: { + log_path: { value: '/var/log/nginx/access.log', type: 'text' }, + }, + }, + ], + }, + { + type: 'otelcol', + name: 'nginx_otel', + policy_template: 'nginx', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'metrics', dataset: 'nginx.stubstatus' }, + vars: { + endpoint: { value: 'http://localhost:8080/stub_status', type: 'text' }, + }, + }, + ], + }, + ], + }; + + beforeEach(() => { + testRenderer = createFleetTestRendererMock(); + mockUpdatePackagePolicy.mockClear(); + }); + + it('should render both input panels with their respective titles', async () => { + const validationResults = validatePackagePolicy(otelPackagePolicy, otelPackageInfo, load); + renderResult = testRenderer.render( + + ); + + await waitFor(async () => { + expect( + await renderResult.findByText('Collect Nginx access logs via filelog OTel receiver') + ).toBeInTheDocument(); + expect( + await renderResult.findByText('Collect Nginx stub status metrics via OTel receiver') + ).toBeInTheDocument(); + }); + }); + + it('should render two separate input panels with independent stream toggles, not mixed', async () => { + const validationResults = validatePackagePolicy(otelPackagePolicy, otelPackageInfo, load); + renderResult = testRenderer.render( + + ); + + await waitFor(async () => { + // Both input panel titles should be present + expect( + await renderResult.findByText('Collect Nginx access logs via filelog OTel receiver') + ).toBeInTheDocument(); + expect( + await renderResult.findByText('Collect Nginx stub status metrics via OTel receiver') + ).toBeInTheDocument(); + + // Each input panel has exactly one stream toggle switch (one per data stream) + // If inputs were mixed, the wrong streams would appear under each panel + const switches = renderResult.getAllByTestId('PackagePolicy.InputStreamConfig.Switch'); + expect(switches).toHaveLength(2); + }); + }); +}); + describe('isSingleInputAndStreams behavior', () => { let testRenderer: TestRenderer; let renderResult: ReturnType; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.tsx index c2f7ee6cbc6fe..b0f3bbfd45459 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.tsx @@ -22,6 +22,8 @@ import { getNormalizedInputs, isIntegrationPolicyTemplate, getRegistryStreamWithDataStreamForInputType, + getInputEffectiveName, + buildInputKey, } from '../../../../../../../../common/services'; import { isInputAllowedForDeploymentMode } from '../../../../../../../../common/services/agentless_policy_helper'; @@ -94,14 +96,15 @@ export const StepConfigurePackagePolicy: React.FunctionComponent<{ const inputsToRender = inputs .map((packageInput) => { + const registryEffectiveName = getInputEffectiveName(packageInput); const packagePolicyInput = packagePolicyInputs.find( (input) => - input.type === packageInput.type && + getInputEffectiveName(input) === registryEffectiveName && (hasIntegrations ? input.policy_template === policyTemplate.name : true) ); const packageInputStreams = getRegistryStreamWithDataStreamForInputType( - packageInput.type, + registryEffectiveName, packageInfo, hasIntegrations && isIntegrationPolicyTemplate(policyTemplate) ? policyTemplate.data_streams @@ -165,12 +168,13 @@ export const StepConfigurePackagePolicy: React.FunctionComponent<{ )} {inputsToRender.map(({ packageInput, packagePolicyInput, packageInputStreams }) => { + const policyInputEffectiveName = getInputEffectiveName(packagePolicyInput); const updatePackagePolicyInput = ( updatedInput: Partial ) => { const indexOfUpdatedInput = packagePolicyInputs.findIndex( (input) => - input.type === packageInput.type && + getInputEffectiveName(input) === policyInputEffectiveName && (hasIntegrations ? input.policy_template === policyTemplate.name : true) ); const newInputs = [...packagePolicyInputs]; @@ -184,7 +188,7 @@ export const StepConfigurePackagePolicy: React.FunctionComponent<{ }; return ( - + { + it('should use name instead of type when name is present', () => { + const id = getInputId( + { + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + enabled: true, + streams: [], + }, + 'pkg-policy-123' + ); + + expect(id).toBe('filelog_otel-nginx-pkg-policy-123'); + }); + + it('should fall back to type when name is not present', () => { + const id = getInputId( + { + type: 'logfile', + policy_template: 'nginx', + enabled: true, + streams: [], + }, + 'pkg-policy-123' + ); + + expect(id).toBe('logfile-nginx-pkg-policy-123'); + }); + + it('should produce unique ids for same-type inputs with different names', () => { + const id1 = getInputId( + { + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + enabled: true, + streams: [], + }, + 'pkg-policy-123' + ); + const id2 = getInputId( + { + type: 'otelcol', + name: 'nginx_otel', + policy_template: 'nginx', + enabled: true, + streams: [], + }, + 'pkg-policy-123' + ); + + expect(id1).not.toBe(id2); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/package_policies_to_agent_inputs.ts b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/package_policies_to_agent_inputs.ts index 64b7603fc9f7d..c2a7e09149775 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/package_policies_to_agent_inputs.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/package_policies_to_agent_inputs.ts @@ -35,7 +35,10 @@ import { _compilePackagePolicyInputs, getPackagePolicySavedObjectType } from '.. import { getAgentTemplateAssetsMap } from '../epm/packages/get'; import { appContextService } from '../app_context'; import { FleetError, PackagePolicyValidationError } from '../../errors'; -import { packagePolicyInputAllowsUndefinedDataStreamType } from '../../../common/services'; +import { + packagePolicyInputAllowsUndefinedDataStreamType, + getInputEffectiveName, +} from '../../../common/services'; const isPolicyEnabled = (packagePolicy: PackagePolicy) => { return packagePolicy.enabled && packagePolicy.inputs && packagePolicy.inputs.length; @@ -52,7 +55,7 @@ export function getInputId( return useSimplifiedId ? packagePolicyId || 'default' - : `${input.type}${input.policy_template ? `-${input.policy_template}` : ''}${ + : `${getInputEffectiveName(input)}${input.policy_template ? `-${input.policy_template}` : ''}${ packagePolicyId ? `-${packagePolicyId}` : '' }`; } diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policies/get_input_with_ids.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/get_input_with_ids.test.ts index acbbe553cd247..01adabdb7022d 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policies/get_input_with_ids.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/get_input_with_ids.test.ts @@ -90,4 +90,90 @@ describe('getInputsWithIds', () => { expect(inputs[0].streams[0].id).toBe('existing-input-id-1'); expect(inputs[0].streams[1].id).toBe('existing-input-id-2'); }); + + it('should use name instead of type when generating IDs for inputs with name', () => { + const inputs = getInputsWithIds( + { + name: 'test-policy', + namespace: 'default', + policy_ids: ['policy1'], + package: { name: 'nginx', title: 'Nginx', version: '1.0.0' }, + enabled: true, + inputs: [ + { + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'logs', dataset: 'nginx.access' }, + }, + ], + }, + { + type: 'otelcol', + name: 'nginx_otel', + policy_template: 'nginx', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'metrics', dataset: 'nginx.stubstatus' }, + }, + ], + }, + ], + }, + 'policy123', + false, + { + name: 'nginx', + title: 'Nginx', + version: '1.0.0', + policy_templates: [], + } as any + ); + + expect(inputs[0].id).toBe('filelog_otel-nginx-policy123'); + expect(inputs[0].streams[0].id).toBe('filelog_otel-nginx.access-policy123'); + expect(inputs[1].id).toBe('nginx_otel-nginx-policy123'); + expect(inputs[1].streams[0].id).toBe('nginx_otel-nginx.stubstatus-policy123'); + }); + + it('should fall back to type when name is not present', () => { + const inputs = getInputsWithIds( + { + name: 'test-policy', + namespace: 'default', + policy_ids: ['policy1'], + package: { name: 'test-package', title: 'Test Package', version: '1.0.0' }, + enabled: true, + inputs: [ + { + type: 'logfile', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'logs', dataset: 'test-dataset' }, + }, + ], + }, + ], + }, + 'policy123', + false, + { + name: 'test-package', + title: 'Test Package', + version: '1.0.0', + policy_templates: [], + } as any + ); + + expect(inputs[0].id).toBe('logfile-policy123'); + expect(inputs[0].streams[0].id).toBe('logfile-test-dataset-policy123'); + }); }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policies/get_input_with_ids.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/get_input_with_ids.ts index e4df5dabeafff..1afd5930e8a46 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policies/get_input_with_ids.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/get_input_with_ids.ts @@ -7,6 +7,7 @@ import type { NewPackagePolicy, PackageInfo, PackagePolicy } from '../../types'; import { getInputId } from '../agent_policies/package_policies_to_agent_inputs'; +import { getInputEffectiveName } from '../../../common/services'; /** * Populate the ids for inputs and streams of a package policy if they are not already set @@ -33,8 +34,8 @@ export function getInputsWithIds( id: stream?.id ? stream.id : packagePolicyId - ? `${input.type}-${stream.data_stream.dataset}-${packagePolicyId}` - : `${input.type}-${stream.data_stream.dataset}`, + ? `${getInputEffectiveName(input)}-${stream.data_stream.dataset}-${packagePolicyId}` + : `${getInputEffectiveName(input)}-${stream.data_stream.dataset}`, })), }; }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts index 4c14008380b25..355cfa72d0f97 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts @@ -9996,6 +9996,280 @@ describe('Package policy service', () => { }); }); + describe('when inputs have name for disambiguation', () => { + it('matches the correct original input by name during upgrade when multiple inputs share the same type', () => { + const basePolicy: NewPackagePolicy = { + name: 'nginx-policy', + description: '', + namespace: 'default', + enabled: true, + policy_id: 'xxxx', + policy_ids: ['xxxx'], + package: { name: 'nginx', title: 'Nginx', version: '1.0.0' }, + inputs: [ + { + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + enabled: true, + vars: { + log_path: { type: 'text', value: '/var/log/nginx/access.log' }, + }, + streams: [ + { + enabled: true, + data_stream: { dataset: 'nginx.access', type: 'logs' }, + }, + ], + }, + { + type: 'otelcol', + name: 'nginx_otel', + policy_template: 'nginx', + enabled: true, + vars: { + endpoint: { type: 'text', value: 'http://localhost:8080/stub_status' }, + }, + streams: [ + { + enabled: true, + data_stream: { dataset: 'nginx.stubstatus', type: 'metrics' }, + }, + ], + }, + ], + }; + + const packageInfoV2: PackageInfo = { + name: 'nginx', + title: 'Nginx', + version: '2.0.0', + latestVersion: '2.0.0', + description: 'Nginx integration', + type: 'integration', + format_version: '1.0.0', + owner: { github: 'elastic/fleet' }, + policy_templates: [ + { + name: 'nginx', + title: 'Nginx', + description: 'Nginx', + data_streams: ['access', 'stubstatus'], + inputs: [ + { + name: 'filelog_otel', + type: 'otelcol', + title: 'Logs via filelog', + description: 'Logs', + vars: [{ name: 'log_path', type: 'text' }], + }, + { + name: 'nginx_otel', + type: 'otelcol', + title: 'Metrics', + description: 'Metrics', + vars: [ + { name: 'endpoint', type: 'text' }, + { name: 'timeout', type: 'text' }, + ], + }, + ], + }, + ], + data_streams: [ + { + type: 'logs', + dataset: 'nginx.access', + title: 'Access logs', + path: 'access', + release: 'ga', + package: 'nginx', + streams: [{ input: 'filelog_otel', title: 'Access logs', vars: [] }], + }, + { + type: 'metrics', + dataset: 'nginx.stubstatus', + title: 'Stub status', + path: 'stubstatus', + release: 'ga', + package: 'nginx', + streams: [{ input: 'nginx_otel', title: 'Stub status', vars: [] }], + }, + ], + } as unknown as PackageInfo; + + const inputsOverride: InputsOverride[] = packageToPackagePolicyInputs( + packageInfoV2 + ) as InputsOverride[]; + + const result = updatePackageInputs(basePolicy, packageInfoV2, inputsOverride, false); + + const filelogInput = result.inputs.find((i) => i.name === 'filelog_otel'); + const nginxInput = result.inputs.find((i) => i.name === 'nginx_otel'); + + expect(filelogInput).toBeDefined(); + expect(nginxInput).toBeDefined(); + + // User-configured var should be preserved on the correct input + expect(filelogInput?.vars?.log_path?.value).toBe('/var/log/nginx/access.log'); + expect(nginxInput?.vars?.endpoint?.value).toBe('http://localhost:8080/stub_status'); + + // New var added in v2 should get default value + expect(nginxInput?.vars?.timeout).toBeDefined(); + }); + + it('uses name to determine which inputs still exist in the new package version', () => { + const basePolicy: NewPackagePolicy = { + name: 'nginx-policy', + description: '', + namespace: 'default', + enabled: true, + policy_id: 'xxxx', + policy_ids: ['xxxx'], + package: { name: 'nginx', title: 'Nginx', version: '1.0.0' }, + inputs: [ + { + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + enabled: true, + vars: {}, + streams: [], + }, + { + type: 'otelcol', + name: 'nginx_otel', + policy_template: 'nginx', + enabled: true, + vars: {}, + streams: [], + }, + ], + }; + + // New package version removes the nginx_otel input + const packageInfoV2: PackageInfo = { + name: 'nginx', + title: 'Nginx', + version: '2.0.0', + latestVersion: '2.0.0', + description: 'Nginx integration', + type: 'integration', + format_version: '1.0.0', + owner: { github: 'elastic/fleet' }, + policy_templates: [ + { + name: 'nginx', + title: 'Nginx', + description: 'Nginx', + inputs: [ + { + name: 'filelog_otel', + type: 'otelcol', + title: 'Logs', + description: 'Logs', + }, + ], + }, + ], + } as unknown as PackageInfo; + + const inputsOverride: InputsOverride[] = packageToPackagePolicyInputs( + packageInfoV2 + ) as InputsOverride[]; + + const result = updatePackageInputs(basePolicy, packageInfoV2, inputsOverride, false); + + // Only the filelog_otel input should remain + const otelInputs = result.inputs.filter((i) => i.type === 'otelcol'); + expect(otelInputs).toHaveLength(1); + expect(otelInputs[0].name).toBe('filelog_otel'); + }); + + it('supports migrate_from referencing an name', () => { + const basePolicy: NewPackagePolicy = { + name: 'nginx-policy', + description: '', + namespace: 'default', + enabled: true, + policy_id: 'xxxx', + policy_ids: ['xxxx'], + package: { name: 'nginx', title: 'Nginx', version: '1.0.0' }, + inputs: [ + { + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + enabled: true, + vars: { + log_path: { type: 'text', value: '/var/log/nginx/access.log' }, + }, + streams: [ + { + enabled: true, + data_stream: { dataset: 'nginx.access', type: 'logs' }, + }, + ], + }, + ], + }; + + // New package replaces filelog_otel with filelog_otel_v2 + const packageInfoV2: PackageInfo = { + name: 'nginx', + title: 'Nginx', + version: '2.0.0', + latestVersion: '2.0.0', + description: 'Nginx integration', + type: 'integration', + format_version: '1.0.0', + owner: { github: 'elastic/fleet' }, + policy_templates: [ + { + name: 'nginx', + title: 'Nginx', + description: 'Nginx', + data_streams: ['access'], + inputs: [ + { + name: 'filelog_otel_v2', + type: 'otelcol', + title: 'Logs v2', + description: 'Logs v2', + migrate_from: 'filelog_otel', + vars: [{ name: 'log_path', type: 'text' }], + }, + ], + }, + ], + data_streams: [ + { + type: 'logs', + dataset: 'nginx.access', + title: 'Access logs', + path: 'access', + release: 'ga', + package: 'nginx', + streams: [{ input: 'filelog_otel_v2', title: 'Access logs', vars: [] }], + }, + ], + } as unknown as PackageInfo; + + const inputsOverride: InputsOverride[] = packageToPackagePolicyInputs( + packageInfoV2 + ) as InputsOverride[]; + + const result = updatePackageInputs(basePolicy, packageInfoV2, inputsOverride, false); + + const v2Input = result.inputs.find((i) => i.name === 'filelog_otel_v2'); + expect(v2Input).toBeDefined(); + // Var should be migrated from the old filelog_otel input + expect(v2Input?.vars?.log_path?.value).toBe('/var/log/nginx/access.log'); + // Old input should be removed + expect(result.inputs.find((i) => i.name === 'filelog_otel')).toBeUndefined(); + }); + }); + describe('when re-upgrading to a package version that removes deprecated/migrate_from', () => { it('clears deprecated and migrate_from on an existing input when new package no longer declares them', () => { const basePackagePolicy: NewPackagePolicy = { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts index 252130ef96771..d747cd75b6c1d 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts @@ -52,6 +52,7 @@ import { isRootPrivilegesRequired, checkIntegrationFipsLooseCompatibility, hasMultipleEnabledPolicyTemplates, + getInputEffectiveName, } from '../../common/services'; import { SO_SEARCH_LIMIT, @@ -2619,9 +2620,10 @@ class PackagePolicyClientImpl implements PackagePolicyClient { ); if (newPP) { const inputs = newPolicy.inputs.map((input) => { + const effectiveName = getInputEffectiveName(input); const defaultInput = newPP.inputs.find( (i) => - i.type === input.type && + getInputEffectiveName(i) === effectiveName && (!input.policy_template || input.policy_template === i.policy_template) ); return { @@ -3869,7 +3871,9 @@ function enforceFrozenInputs( const resultInputs = [...newInputs]; for (const input of resultInputs) { - const oldInput = oldInputs.find((i) => i.type === input.type); + const oldInput = oldInputs.find( + (i) => getInputEffectiveName(i) === getInputEffectiveName(input) + ); if (oldInput?.keep_enabled) input.enabled = oldInput.enabled; if (input.vars && oldInput?.vars) { input.vars = _enforceFrozenVars(oldInput.vars, input.vars, force); @@ -3989,11 +3993,15 @@ export function updatePackageInputs( return false; } - // Ignore any inputs removed from this policy template in the new package version + // Ignore any inputs removed from this policy template in the new package version. + // Match by id ?? type on both sides so that inputs with explicit ids are correctly + // retained or pruned when the new package version changes its input list. + const policyInputEffectiveId = getInputEffectiveName(input); const policyTemplateStillIncludesInput = isInputOnlyPolicyTemplate(policyTemplate) ? policyTemplate.input === input.type : policyTemplate.inputs?.some( - (policyTemplateInput) => policyTemplateInput.type === input.type + (policyTemplateInput) => + getInputEffectiveName(policyTemplateInput) === policyInputEffectiveId ) ?? false; return policyTemplateStillIncludesInput; }), @@ -4004,21 +4012,28 @@ export function updatePackageInputs( if (update.policy_template) { // If the updated value defines a policy template, try to find an original input - // with the same policy template value + // with the same policy template value. Match by name ?? type on both sides + // so that inputs with explicit ids are correctly matched during upgrade. + const updateEffectiveId = getInputEffectiveName(update as NewPackagePolicyInput); const matchingInput = inputs.find( - (i) => i.type === update.type && i.policy_template === update.policy_template + (i) => + getInputEffectiveName(i) === updateEffectiveId && + i.policy_template === update.policy_template ); // If we didn't find an input with the same policy template, try to look for one - // with the same type, but with an undefined policy template. This ensures we catch - // cases where we're upgrading an older policy from before policy template was - // reliably define on package policy inputs. + // with the same effective id, but with an undefined policy template. This ensures + // we catch cases where we're upgrading an older policy from before policy template + // was reliably defined on package policy inputs. originalInput = - matchingInput || inputs.find((i) => i.type === update.type && !i.policy_template); + matchingInput || + inputs.find((i) => getInputEffectiveName(i) === updateEffectiveId && !i.policy_template); } else { // For inputs that don't specify a policy template, just grab the first input - // that matches its `type` - originalInput = inputs.find((i) => i.type === update.type); + // that matches its effective id + originalInput = inputs.find( + (i) => getInputEffectiveName(i) === getInputEffectiveName(update as NewPackagePolicyInput) + ); } // If there's no corresponding input on the original package policy, just @@ -4205,13 +4220,14 @@ export function preconfigurePackageInputs( for (const preconfiguredInput of preconfiguredInputs) { // Preconfiguration does not currently support multiple policy templates, so overrides will have an undefined // policy template, so we only match on `type` in that case. + const preconfiguredEffectiveId = getInputEffectiveName(preconfiguredInput); let originalInput = preconfiguredInput.policy_template ? inputs.find( (i) => - i.type === preconfiguredInput.type && + getInputEffectiveName(i) === preconfiguredEffectiveId && i.policy_template === preconfiguredInput.policy_template ) - : inputs.find((i) => i.type === preconfiguredInput.type); + : inputs.find((i) => getInputEffectiveName(i) === preconfiguredEffectiveId); // If the input do not exist skip if (originalInput === undefined) { @@ -4293,7 +4309,9 @@ export function _validateRestrictedFieldsNotModifiedOrThrow(opts: { if (inputs) { for (const input of inputs) { - const oldInput = oldPackagePolicy.inputs.find((i) => i.type === input.type); + const oldInput = oldPackagePolicy.inputs.find( + (i) => getInputEffectiveName(i) === getInputEffectiveName(input) + ); if (oldInput) { for (const stream of input.streams || []) { const oldStream = oldInput.streams.find( diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policy_migration_helpers.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policy_migration_helpers.test.ts new file mode 100644 index 0000000000000..f1cea572323f2 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policy_migration_helpers.test.ts @@ -0,0 +1,86 @@ +/* + * 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 { NewPackagePolicyInput } from '../../common/types'; + +import { findInputForMigration } from './package_policy_migration_helpers'; + +describe('findInputForMigration', () => { + const makeInput = ( + overrides: Partial & { type: string } + ): NewPackagePolicyInput => ({ + enabled: true, + streams: [], + ...overrides, + }); + + it('finds input by type when no name is set', () => { + const inputs = [ + makeInput({ type: 'logfile', policy_template: 'nginx' }), + makeInput({ type: 'httpjson', policy_template: 'nginx' }), + ]; + + const result = findInputForMigration(inputs, 'httpjson', 'nginx'); + expect(result?.type).toBe('httpjson'); + }); + + it('finds input by name when searching by id value', () => { + const inputs = [ + makeInput({ + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + }), + makeInput({ + type: 'otelcol', + name: 'nginx_otel', + policy_template: 'nginx', + }), + ]; + + const result = findInputForMigration(inputs, 'filelog_otel', 'nginx'); + expect(result?.name).toBe('filelog_otel'); + }); + + it('does not match the wrong input when multiple inputs share the same type', () => { + const inputs = [ + makeInput({ + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + }), + makeInput({ + type: 'otelcol', + name: 'nginx_otel', + policy_template: 'nginx', + }), + ]; + + const result = findInputForMigration(inputs, 'nginx_otel', 'nginx'); + expect(result?.name).toBe('nginx_otel'); + }); + + it('does not match by type when inputs have explicit names', () => { + const inputs = [ + makeInput({ + type: 'otelcol', + name: 'filelog_otel', + policy_template: 'nginx', + }), + makeInput({ + type: 'otelcol', + name: 'nginx_otel', + policy_template: 'nginx', + }), + ]; + + // Searching by type 'otelcol' is ambiguous when both inputs + // have explicit names -- should not match either + const result = findInputForMigration(inputs, 'otelcol', 'nginx'); + expect(result).toBeUndefined(); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policy_migration_helpers.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policy_migration_helpers.ts index 39950b984c2bf..08af3eff098dc 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policy_migration_helpers.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policy_migration_helpers.ts @@ -13,7 +13,7 @@ import type { InputsOverride, } from '../../common/types'; import type { NewPackagePolicy } from '../types'; -import { varsReducer } from '../../common/services'; +import { varsReducer, getInputEffectiveName } from '../../common/services'; /** * Finds an input in `inputs` matching `type`, preferring a match on `policyTemplate`. Falls back @@ -22,16 +22,21 @@ import { varsReducer } from '../../common/services'; */ export function findInputForMigration( inputs: NewPackagePolicyInput[], - type: string, + idOrType: string, policyTemplate: string | undefined ): NewPackagePolicyInput | undefined { + // Match by effective id (name when set, otherwise type). An input that has an + // explicit name must be referenced by that name -- matching by type alone is + // ambiguous when multiple inputs share the same type (e.g., two otelcol inputs). + const matches = (i: NewPackagePolicyInput) => getInputEffectiveName(i) === idOrType; + if (policyTemplate) { return ( - inputs.find((i) => i.type === type && i.policy_template === policyTemplate) ?? - inputs.find((i) => i.type === type && !i.policy_template) + inputs.find((i) => matches(i) && i.policy_template === policyTemplate) ?? + inputs.find((i) => matches(i) && !i.policy_template) ); } - return inputs.find((i) => i.type === type); + return inputs.find(matches); } /**