Skip to content

Handle otlp output - #7821

Draft
MichelLosier wants to merge 9 commits into
elastic:mainfrom
MichelLosier:handle-otlp-output
Draft

MichelLosier wants to merge 9 commits into
elastic:mainfrom
MichelLosier:handle-otlp-output

Conversation

@MichelLosier

Copy link
Copy Markdown
Contributor

What is the problem this PR solves?

// Please do not just reference an issue. Explain WHAT the problem this PR solves here.

How does this PR solve the problem?

// Explain HOW you solved the problem in your code. It is possible that during PR reviews this changes and then this section should be updated.

How to test this PR locally

Design Checklist

  • I have ensured my design is stateless and will work when multiple fleet-server instances are behind a load balancer.
  • I have or intend to scale test my changes, ensuring it will work reliably with 100K+ agents connected.
  • I have included fail safe mechanisms to limit the load on fleet-server: rate limiting, circuit breakers, caching, load shedding, etc.

Checklist

  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have made corresponding change to the default configuration files
  • I have added tests that prove my fix is effective or that my feature works
  • I have added an entry in ./changelog/fragments using the changelog tool

Related issues

Closes: https://github.com/elastic/ingest-dev/issues/8739

Copilot AI lite review requested due to automatic review settings September 14, 2026 21:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate findings remain in OTLP cleanup, credential isolation, API-key lifecycle handling, and test expectations.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds managed and external OTLP output support, including API-key lifecycle handling and exporter authentication.

Changes:

  • Adds OTLP output preparation, secret resolution, and API-key management.
  • Injects credentials into OTLP/OTLPHTTP exporters and filters OTLP outputs from delivered policies.
  • Adds unit, API, and integration test coverage.
File summaries
File Summary Final review findings
internal/pkg/policy/policy_output.go OTLP preparation and API-key management Moderate (3 votes): Cleanup is skipped when changing managed OTLP to external.
Critical (3 votes): OTLP permission changes do not clean stale roles, keys, or secrets.
Critical (1 vote): OTLP retirement records are not consumed by Elasticsearch-only cleanup.
Critical (1 vote): OTLP keys may be routed through the wrong bulker during inactive-agent cleanup.
internal/pkg/policy/policy_output_test.go Unit tests Critical (1 vote): Mock expectations use incorrect MockBulk call arguments.
internal/pkg/policy/policy_output_integration_test.go Integration coverage No final comments.
internal/pkg/api/handleCheckin.go Exporter authorization and output filtering Moderate (2 votes): Dropping OTLP outputs leaves stale secret paths.
Critical (1 vote): Shallow-cloned exporter configs can cause concurrent credential leakage and map access.
Critical (1 vote): Case-variant authorization headers can leave conflicting credentials.
internal/pkg/api/handleCheckin_test.go Policy delivery tests No final comments.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 8
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/pkg/api/handleCheckin.go
Comment thread internal/pkg/api/handleCheckin.go
Comment thread internal/pkg/policy/policy_output.go Outdated
Comment on lines +506 to +510
if toRetireAPIKeys != nil {
fields := map[string]any{
dl.FieldPolicyOutputToRetireAPIKeyIDs: *toRetireAPIKeys,
}
body, err := renderUpdatePainlessScript(p.Name, fields)
Comment thread internal/pkg/policy/policy_output.go Outdated
Comment on lines +554 to +560
newRoles, err := mergeRoles(zlog, currentRoles, p.Role)
if err != nil {
zlog.Error().Str("apiKeyID", output.APIKeyID).Err(err).Msg("fail merging roles for OTLP key")
return err
}

if err = bulker.APIKeyUpdate(ctx, output.APIKeyID, newRoles.Sha2, newRoles.Raw); err != nil {
Comment thread internal/pkg/policy/policy_output.go Outdated
Comment on lines +645 to +648
output.Type = OutputTypeOTLP
output.APIKey = apiKeyRef
output.APIKeyID = outputAPIKey.ID
output.PermissionsHash = p.Role.Sha2
Comment on lines +486 to +488
b.On("APIKeyRead", mock.Anything, oldKeyID, mock.Anything).
Return(&bulk.APIKeyMetadata{ID: oldKeyID, RoleDescriptors: TestPayload}, nil).Once()
b.On("APIKeyUpdate", mock.Anything, oldKeyID, mock.Anything, mock.Anything).Return(nil).Once()
Comment on lines +1129 to +1134
for name, out := range pp.Outputs {
if out.Type != policy.OutputTypeOTLP {
continue
}
delete(data.Outputs, name)
}
Comment thread internal/pkg/policy/policy_output.go
@github-actions

This comment has been minimized.

Copilot AI review requested due to automatic review settings September 14, 2026 22:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved findings affect OTLP key cleanup, output typing, invalidation routing, secret paths, and test correctness.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (5)

internal/pkg/api/handleCheckin.go:1203

  • The OTLP output is removed after pp.SecretKeys has already been populated, so any secret in the output leaves an outputs.<name>.* entry in secret_paths even though that path is absent from the delivered policy. Filter the deleted output's secret paths here, as the code already does for stripped remote service tokens.
	for name, out := range pp.Outputs {
		if out.Type != policy.OutputTypeOTLP {
			continue
		}
		delete(pp.Policy.Data.Outputs, name)

internal/pkg/policy/policy_output.go:461

  • Returning before touching agent.Outputs means changing a managed OTLP output to an external OTLP output (or switching to an external OTLP policy after removing another managed output) leaves the old output record, secret reference, and API key active. The external path should skip minting/resolution but still retire and remove stale managed-output entries.
	if p.Role == nil {
		zlog.Debug().Msg("no output permissions for OTLP output; skipping API key management")
		return nil

internal/pkg/policy/policy_output.go:611

  • Because type is persisted only when !foundOutput, reusing an existing agent output name for a managed OTLP output never writes outputs.<name>.type = "otlp" to Elasticsearch. The agent document then continues to advertise the old output type, so subsequent check-in/ack processing classifies this OTLP key incorrectly. Persist the OTLP type for existing outputs too.
		if !foundOutput {
			fields[dl.FiledType] = OutputTypeOTLP
		}

internal/pkg/policy/policy_output.go:648

  • Marking the record as otlp makes it invisible to the existing API-key finalization paths: handleAck.go and handleCheckin.go only call updateAPIKey for OutputTypeElasticsearch. Permission changes therefore leave -rdstale roles installed, and retired OTLP keys/secrets are not finalized. Include local OTLP outputs in the same cleanup flow.
		output.Type = OutputTypeOTLP
		output.APIKey = apiKeyRef
		output.APIKeyID = outputAPIKey.ID
		output.PermissionsHash = p.Role.Sha2

internal/pkg/policy/policy_output_test.go:488

  • MockBulk.APIKeyRead and MockBulk.APIKeyUpdate record only (ctx, id) (internal/pkg/testing/bulk.go:148-168), but these expectations pass three and four arguments. The hash-changed subtest will therefore fail with an unexpected mock call before it reaches the assertions.
				b.On("APIKeyRead", mock.Anything, oldKeyID, mock.Anything).
					Return(&bulk.APIKeyMetadata{ID: oldKeyID, RoleDescriptors: TestPayload}, nil).Once()
				b.On("APIKeyUpdate", mock.Anything, oldKeyID, mock.Anything, mock.Anything).Return(nil).Once()
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread internal/pkg/policy/policy_output.go Outdated
@mergify

mergify Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

This pull request does not have a backport label. Could you fix it @MichelLosier? 🙏
To fixup this pull request, you need to add the backport labels for the needed
branches, such as:

  • backport-./d./d is the label to automatically backport to the 8./d branch. /d is the digit
  • backport-active-all is the label that automatically backports to all active branches.
  • backport-active-8 is the label that automatically backports to all active minor branches for the 8 major.
  • backport-active-9 is the label that automatically backports to all active minor branches for the 9 major.

@github-actions

Copy link
Copy Markdown
Contributor

TL;DR

The failing :gcloud: Cloud e2e FIPS Test log artifact for build 16890 only contains cleanup/teardown and a final exit status 1, so the actual failing command output is missing from the provided log. Immediate action: rerun this Buildkite step and capture the first failing command output (before teardown) so root cause can be identified.

Remediation

  • Re-run .buildkite/scripts/cloud_e2e_test.sh and preserve the full step log from first failure through teardown (the current artifact starts in teardown output only).
  • If rerun reproduces, extract the first non-zero command from the section before --- Deployment detected, running cleanup. and map it to code/test/config; if rerun passes, classify as transient infrastructure.
Investigation details

Root Cause

From the available artifact (/tmp/gh-aw/buildkite-logs/fleet-server-gcloud-cloud-e2e-fips-test.txt), there is insufficient failure context to attribute this to a code bug, test assertion, dependency drift, or config error. The log starts in Terraform destroy output and ends with a generic process failure.

Given .buildkite/scripts/cloud_e2e_test.sh uses set -euo pipefail with an EXIT trap that always runs cleanup, this pattern indicates an earlier command failed and then cleanup executed; however, that earlier command output is not present in the captured artifact.

Evidence

Plan: 0 to add, 0 to change, 1 to destroy.
...
Destroy complete! Resources: 1 destroyed.
🚨 Error: The command exited with status 1
user command error: exit status 1
  • Relevant script behavior:
    • .buildkite/scripts/cloud_e2e_test.sh:3set -euo pipefail
    • .buildkite/scripts/cloud_e2e_test.sh:28trap cleanup EXIT INT TERM
    • .buildkite/scripts/cloud_e2e_test.sh:22 cleanup runs mage test:cloudE2EDown

Verification

  • Local targeted tests for changed packages passed:
    • go test ./internal/pkg/api ./internal/pkg/policy
  • No open flaky-test issue found matching cloud e2e fips in this repository.

Follow-up

Once the pre-teardown failure output is available, I can classify precisely (code/test/dependency/config/infrastructure) and provide a concrete patch-level remediation.


What is this? | From workflow: PR Buildkite Detective

Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.

@github-actions github-actions Bot mentioned this pull request Sep 15, 2026
Copilot AI review requested due to automatic review settings September 15, 2026 15:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical OTLP API-key routing and cleanup issues remain, along with a failing mock expectation.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (7)

internal/pkg/api/handleAck.go:423

  • Adding OTLP here passes outputName into updateAPIKey, which calls bulk.GetBulker(outputName) when a bulker exists. If this name previously belonged to a remote ES output, the cached remote bulker is used for the primary-cluster OTLP key; the role read/cleanup fails and merged -rdstale permissions can remain indefinitely. Force the primary bulker for OTLP or make updateAPIKey type-aware; the check-in path needs the same fix.
		if output.Type != policy.OutputTypeElasticsearch && output.Type != policy.OutputTypeOTLP {
			continue
		}

internal/pkg/policy/policy_output.go:487

  • On an mOTLP→external transition, the preceding update appends the retirement item to outputs[p.Name], but this update immediately removes that same entry. After both requests succeed, no agent output retains to_retire_api_key_ids, so the next ack cannot invalidate the old primary-cluster key and it remains active. Preserve the retirement item on a surviving output or retire the key explicitly before removing the entry.
			body, err = renderRemoveOutputPainlessScript(p.Name)
			if err != nil {
				return fmt.Errorf("could not render remove-output script for mOTLP→external transition: %w", err)
			}
			if err = bulker.Update(ctx, dl.FleetAgents, agent.Id, body, bulk.WithRefresh(), bulk.WithRetryOnConflict(3)); err != nil {

internal/pkg/policy/policy_output.go:527

  • When a removed output is OTLP, this sets Output to its name. invalidateAPIKeys treats a non-empty Output as a remote-ES key and routes it through GetBulker(agentOutputName), so the primary-cluster OTLP key is not invalidated; the subsequent secret deletion can leave an active key without its stored secret. Keep Output empty for retired OTLP keys.
			retiring := model.ToRetireAPIKeyIdsItems{
				ID:        agentOutput.APIKeyID,
				RetiredAt: time.Now().UTC().Format(time.RFC3339),
				Output:    agentOutputName,
			}

internal/pkg/policy/policy_output.go:564

  • This reuses any existing key, even when the same output name previously referred to Elasticsearch or remote Elasticsearch. A remote output's key is not valid on the primary cluster: if the hashes match, its credential is sent as the OTLP Authorization header; if they differ, fetchAPIKeyRoles reads the ID from the wrong cluster and preparation fails. Treat an output-type transition as requiring a new primary-cluster key.
	case output.APIKey == "":
		zlog.Debug().Msg("must generate OTLP API key as it is not present")
		needNewKey = true

internal/pkg/policy/policy_output.go:642

  • When an existing output is converted to managed OTLP, foundOutput is true, so this condition skips persisting type: otlp in Elasticsearch. The in-memory pointer is changed later, but the next checkin reloads the old type; after rotating the key for the conversion it can mint another key on every policy delivery and the ACK/checkin paths keep classifying it as Elasticsearch. Persist the OTLP type whenever this conversion path writes the new key.
		if !foundOutput {
			fields[dl.FiledType] = OutputTypeOTLP
		}

internal/pkg/policy/policy_output.go:650

  • The retirement path correctly leaves Output empty because OTLP keys are created on the primary cluster, but the active-key path still treats every non-default output as remote in model.Agent.APIKeyIDs (internal/pkg/model/ext.go:62-71). A named OTLP key will therefore be sent through remote-bulker invalidation during unenrollment or inactive-agent cleanup, where no OTLP remote bulker can exist, and remain valid. Update the active-key classification so OTLP keys also use an empty Output.
			retiring := model.ToRetireAPIKeyIdsItems{
				ID:        output.APIKeyID,
				RetiredAt: time.Now().UTC().Format(time.RFC3339),
			}

internal/pkg/policy/policy_output_test.go:488

  • MockBulk.APIKeyRead and APIKeyUpdate forward only (ctx, id) to testify (internal/pkg/testing/bulk.go:148-167), but these expectations register three and four arguments. The hash-changed subtest will therefore fail on an unexpected mock call before exercising the OTLP update path. Register the two arguments passed by the mock, or update the mock and all expectations consistently.
				b.On("APIKeyRead", mock.Anything, oldKeyID, mock.Anything).
					Return(&bulk.APIKeyMetadata{ID: oldKeyID, RoleDescriptors: TestPayload}, nil).Once()
				b.On("APIKeyUpdate", mock.Anything, oldKeyID, mock.Anything, mock.Anything).Return(nil).Once()
  • Files reviewed: 6/6 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread internal/pkg/api/handleAck.go
Comment on lines +1604 to 1606
if output.Type != policy.OutputTypeElasticsearch && output.Type != policy.OutputTypeOTLP {
continue
}
Comment on lines +457 to +464
// External OTLP output — Kibana embeds auth credentials directly in the exporter config.
// Only mOTLP outputs carry an output_permissions block and need API key management.
if p.Role == nil {
zlog.Debug().Msg("no output permissions for OTLP output; skipping API key management")
// If this output previously had a managed API key (mOTLP → external transition),
// retire it now. Without this the old key stays valid indefinitely and its
// .fleet-secrets doc is pinned alive by outputSecretIsReferenced.
if prev, ok := agent.Outputs[p.Name]; ok && prev.APIKeyID != "" {
Copilot AI review requested due to automatic review settings September 16, 2026 15:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Address stale-output retirement and ensure OTLP API-key maintenance uses the primary bulker.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (7)

internal/pkg/api/handleAck.go:421

  • Adding OTLP to this loop routes it through updateAPIKey, which prefers bulk.GetBulker(outputName) (see handleAck.go:462). Remote bulkers are retained when an output changes type, so an OTLP key under a reused remote-output name can be read or cleaned on the remote cluster instead of the primary cluster. Make API-key maintenance choose the primary bulker for OTLP outputs.
	for outputName, output := range agent.Outputs {
		if output.Type != policy.OutputTypeElasticsearch && output.Type != policy.OutputTypeOTLP {

internal/pkg/api/handleCheckin.go:1604

  • This new OTLP branch has the same routing problem in the policy-detail/check-in path: updateAPIKey can select a stale remote bulker solely from the output name, even though managed OTLP keys are created on the primary cluster. A remote-to-OTLP name reuse can therefore leave the primary key's roles stale or make this check-in fail. Route OTLP maintenance through the primary bulker.
		for outputName, output := range agent.Outputs {
			if output.Type != policy.OutputTypeElasticsearch && output.Type != policy.OutputTypeOTLP {

internal/pkg/policy/policy_output.go:300

  • This assumes the previous key is an mOTLP key on the primary cluster, but a reused output name can previously refer to remote_elasticsearch (those entries are persisted with the Elasticsearch type as well). In that transition bulker.APIKeyInvalidate targets the primary cluster, leaving the remote key active after the output entry is removed. Resolve the owning output bulker before invalidating the previous key.
		if prev, ok := agent.Outputs[p.Name]; ok && prev.APIKeyID != "" {

internal/pkg/policy/policy_output.go:304

  • When invalidation fails, this path continues to delete the secret and remove the output entry below. The old API key can therefore remain active while its only agent-document/retirement tracking is discarded, leaving an orphaned credential that later acknowledgement cleanup cannot revoke. Keep the tracking state and retry or fail the transition until invalidation succeeds.
			if err := bulker.APIKeyInvalidate(ctx, prev.APIKeyID); err != nil {
				zlog.Warn().Err(err).Str(ecs.APIKeyID, prev.APIKeyID).Str(ecs.PolicyOutputName, p.Name).
					Msg("failed to invalidate mOTLP API key during transition to external OTLP")
			}

internal/pkg/policy/policy_output.go:315

  • The key and secret are destroyed before the replacement policy is delivered or acknowledged. If the update at lines 315-317, exporter preparation, or action delivery fails, the agent can remain on the old managed OTLP policy while its credential is already invalid; even on success there is a pre-ack window with the same failure. Defer invalidation and secret deletion through the existing ACK/retirement flow until the transition is confirmed.
			body, err := renderRemoveOutputPainlessScript(p.Name)
			if err != nil {
				return fmt.Errorf("could not render remove-output script for mOTLP→external transition: %w", err)
			}
			if err = bulker.Update(ctx, dl.FleetAgents, agent.Id, body, bulk.WithRefresh(), bulk.WithRetryOnConflict(3)); err != nil {

internal/pkg/policy/policy_output.go:309

  • DeleteSecret failures are only logged, but the output entry is then removed. After a transient deletion failure there is no agent reference, retirement record, or OutputSecretCandidate left to trigger a retry, so the Fleet secret can be leaked indefinitely. Preserve or enqueue the cleanup before dropping the entry.
			if secretID, ok := secret.ParseSecretReference(prev.APIKey); ok {
				if err := bulker.DeleteSecret(ctx, secretID); err != nil {
					zlog.Warn().Err(err).Str("secret.id", secretID).Str(ecs.PolicyOutputName, p.Name).
						Msg("failed to delete mOTLP API key secret during transition to external OTLP")
				}

internal/pkg/policy/policy_output.go:350

  • Key reuse is based only on an empty APIKey or a matching permissions hash; it does not ensure that the stored key belongs to OTLP on the primary cluster. Reusing an entry from a prior remote_elasticsearch output with the same name can update a remote key through the primary bulker, or inject that remote key into OTLP when the hash happens to match. Force a new primary key when the existing output is not already an OTLP output.
	switch {
	case output.APIKey == "":
		zlog.Debug().Msg("must generate OTLP API key as it is not present")
		needNewKey = true
	case p.Role.Sha2 != output.PermissionsHash:
		zlog.Debug().Msg("must update OTLP API key as policy output permissions changed")
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +294 to +295
if p.Role == nil {
zlog.Debug().Msg("no output permissions for OTLP output; skipping API key management")
Copilot AI review requested due to automatic review settings September 17, 2026 15:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved API-key lifecycle issues and integration-test failures require fixes before approval.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (7)

internal/pkg/api/handleAck.go:422

  • Adding OTLP outputs to this loop routes their keys through updateAPIKey, which unconditionally prefers bulk.GetBulker(outputName). Remote bulkers are retained by name and are not removed when an output changes type, so an OTLP key whose name previously belonged to a remote Elasticsearch output can be read, updated, or retired against the stale remote cluster instead of the primary cluster. Select the bulker using the current output type or clear stale remote bulkers on type changes.
	for outputName, output := range agent.Outputs {
		if output.Type != policy.OutputTypeElasticsearch && output.Type != policy.OutputTypeOTLP {
			continue

internal/pkg/policy/policy_output.go:298

  • When the policy contains only an external OTLP output, this early return never calls retireRemovedOutput. If the previous agent document had a different managed output (for example, a default Elasticsearch output), there is no other Prepare call that scans the removed entries, so that API key and secret remain active indefinitely; the same-name cleanup below does not cover them. Please retire/invalidate all removed managed entries, or provide a managed surviving holder, before returning.
	if p.Role == nil {
		zlog.Debug().Msg("no output permissions for OTLP output; skipping API key management")
		// If this output previously had a managed API key (mOTLP → external transition),
		// invalidate it directly. A retirement record cannot be used here: there is no surviving
		// output entry to park it on, so any record written would be deleted by

internal/pkg/policy/policy_output.go:34

  • This PR introduces a new OTLP output type, API-key lifecycle, and policy-delivery behavior, but no changelog fragment was added under changelog/fragments. The repository requires fragments for notable changes (AGENTS.md:203 and .github/PULL_REQUEST_TEMPLATE.md:46); please add one or apply the documented skip-changelog exception if this change is intentionally excluded.
	OutputTypeOTLP                = "otlp"

internal/pkg/policy/policy_output.go:640

  • After the second update succeeds, agent.Outputs still contains removedOutputName. With multiple surviving Elasticsearch/OTLP outputs, each later Prepare call scans that stale in-memory entry and appends the same retirement record to another survivor, causing duplicate invalidation and secret-deletion attempts on ack. Remove the entry from the in-memory map after the successful remove update.
	if err = bulker.Update(ctx, dl.FleetAgents, agent.Id, body, bulk.WithRefresh(), bulk.WithRetryOnConflict(3)); err != nil {
		zlog.Error().Err(err).Msg("fail update agent record")
		return fmt.Errorf("fail update agent record: %w", err)
	}
	return nil

internal/pkg/policy/policy_output.go:613

  • The scan stops after the first output missing from outputMap, so a single policy revision that removes two managed OTLP outputs retires only one key. The other agent-document entry and its API key/secret remain active until some later policy change happens to trigger another scan. Process all removed managed outputs (or otherwise persist a retryable record for each) in the same revision.
		if !found {
			zlog.Info().Str(ecs.APIKeyID, agentOutput.APIKeyID).Str(ecs.PolicyOutputName, agentOutputName).Msg("Output removed, will retire API key")
			retiring := model.ToRetireAPIKeyIdsItems{
				ID:        agentOutput.APIKeyID,
				RetiredAt: time.Now().UTC().Format(time.RFC3339),
				Output:    agentOutputName,
			}
			if secretID, ok := secret.ParseSecretReference(agentOutput.APIKey); ok {
				retiring.SecretID = secretID
			}
			toRetire = &retiring
			removedOutputName = agentOutputName
			break

internal/pkg/policy/policy_output_integration_test.go:39

  • ELASTICSEARCH_HOSTS is used by the test configuration as a host URL and may already include a scheme (for example https://elasticsearch:9200). Prefixing it with http://elastic:changeme@ then produces an invalid URL such as http://elastic:changeme@https://elasticsearch:9200, so this invalidation check fails outside the host:port-only setup. Parse the first host and add credentials while preserving its scheme.
	host := strings.SplitN(hosts, ",", 2)[0]
	return "http://elastic:changeme@" + host

internal/pkg/server/otlp_output_integration_test.go:41

  • ELASTICSEARCH_HOSTS is used by the test configuration as a host URL and may already include a scheme (for example https://elasticsearch:9200). Prefixing it with http://elastic:changeme@ then produces an invalid URL such as http://elastic:changeme@https://elasticsearch:9200, so these integration checks fail outside the host:port-only setup. Parse the first host and add credentials while preserving its scheme.
	host := strings.SplitN(hosts, ",", 2)[0]
	return "http://elastic:changeme@" + host
  • Files reviewed: 11/11 changed files
  • Comments generated: 4
  • Review effort level: Lite

Comment on lines +300 to +304
if prev, ok := agent.Outputs[p.Name]; ok && prev.APIKeyID != "" {
if err := bulker.APIKeyInvalidate(ctx, prev.APIKeyID); err != nil {
zlog.Warn().Err(err).Str(ecs.APIKeyID, prev.APIKeyID).Str(ecs.PolicyOutputName, p.Name).
Msg("failed to invalidate mOTLP API key during transition to external OTLP")
}
Comment on lines +301 to +305
if err := bulker.APIKeyInvalidate(ctx, prev.APIKeyID); err != nil {
zlog.Warn().Err(err).Str(ecs.APIKeyID, prev.APIKeyID).Str(ecs.PolicyOutputName, p.Name).
Msg("failed to invalidate mOTLP API key during transition to external OTLP")
}
if secretID, ok := secret.ParseSecretReference(prev.APIKey); ok {
Comment on lines +346 to +351
case output.APIKey == "":
zlog.Debug().Msg("must generate OTLP API key as it is not present")
needNewKey = true
case p.Role.Sha2 != output.PermissionsHash:
zlog.Debug().Msg("must update OTLP API key as policy output permissions changed")
needUpdateKey = true
Comment thread internal/pkg/server/otel_policy_secrets_integration_test.go Outdated
Copilot AI review requested due to automatic review settings September 17, 2026 17:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate issues affect API-key routing, retirement, cleanup, and integration-test reliability.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (8)

Previously missed (3) — in code that hasn't changed since the last review.

internal/pkg/policy/policy_output.go:640

  • retireRemovedOutput is called once for every surviving Elasticsearch/OTLP output from processPolicy, but this function never removes removedOutputName from agent.Outputs. With two surviving managed outputs, each call sees the same stale entry and appends duplicate retirement records to different outputs (and repeats the remove update). Mark the entry as processed in the in-memory agent after the second update succeeds.
    internal/pkg/policy/policy_output_integration_test.go:39
  • This helper has the same scheme-handling bug: ELASTICSEARCH_HOSTS may already be https://host:port, but the code unconditionally prefixes http://elastic:changeme@, producing an invalid URL and making the invalidation test fail in that supported configuration. Parse the host URL and set its userinfo instead of concatenating a second scheme.
    internal/pkg/server/otlp_output_integration_test.go:41
  • ELASTICSEARCH_HOSTS is documented/configured elsewhere as a scheme://host:port value, but this helper always prepends http://elastic:changeme@. With a valid value such as https://localhost:9200, it constructs http://elastic:changeme@https://localhost:9200, so the API-key assertions cannot connect. Preserve an existing scheme (and only add credentials to the parsed URL) instead of unconditionally prefixing it.

internal/pkg/api/handleAck.go:423

  • Adding OTLP to this loop routes its key through updateAPIKey, which prefers bulk.GetBulker(outputName). If an output changes from remote_elasticsearch to OTLP with the same name, the old remote bulker remains registered, so the primary-cluster OTLP key is read/updated against the remote cluster and the ack can fail or leave retirements unprocessed. Select the bulker from the current output type or clear stale remote bulkers on type changes.
		if output.Type != policy.OutputTypeElasticsearch && output.Type != policy.OutputTypeOTLP {
			continue
		}

internal/pkg/api/handleCheckin.go:1607

  • This new check-in path has the same output-name routing problem as the ack path: updateAPIKey prefers a cached remote bulker even when the current OTLP output's key was created on the primary cluster. After a remote-ES→OTLP rename using the same output name, policy processing can read/update the OTLP key on the wrong cluster and repeatedly fail to complete retirement. Make this routing type-aware or remove the stale remote bulker during the transition.
			if output.Type != policy.OutputTypeElasticsearch && output.Type != policy.OutputTypeOTLP {
				continue
			}
			if err := updateAPIKey(ctx, zlog, ct.bulker, agent.Id, output.APIKeyID, output.PermissionsHash, output.ToRetireAPIKeyIds, outputName); err != nil {

internal/pkg/policy/policy_output.go:298

  • When a policy consists only of external OTLP outputs, this early return is the only preparation path for the current outputs. It never retires entries in agent.Outputs that belong to removed managed outputs (for example, replacing a managed default output with an external OTLP output under a new name), so those API keys remain active and their secrets are not cleaned up. Cleanup must also cover outputs absent from the new policy when there is no managed output available to carry a retirement record.
	if p.Role == nil {
		zlog.Debug().Msg("no output permissions for OTLP output; skipping API key management")
		// If this output previously had a managed API key (mOTLP → external transition),
		// invalidate it directly. A retirement record cannot be used here: there is no surviving
		// output entry to park it on, so any record written would be deleted by

internal/pkg/policy/policy_output.go:305

  • If APIKeyInvalidate fails transiently here, the code still deletes the secret and later removes the agent entry while returning nil. That loses the key ID and secret reference needed for a retry, leaving the managed API key potentially active indefinitely; retain a retirement record or fail the preparation until invalidation succeeds.
			if err := bulker.APIKeyInvalidate(ctx, prev.APIKeyID); err != nil {
				zlog.Warn().Err(err).Str(ecs.APIKeyID, prev.APIKeyID).Str(ecs.PolicyOutputName, p.Name).
					Msg("failed to invalidate mOTLP API key during transition to external OTLP")
			}
			if secretID, ok := secret.ParseSecretReference(prev.APIKey); ok {

internal/pkg/policy/policy_output.go:309

  • If secret deletion fails, the code still removes the output entry below, losing the only reference needed to retry cleanup and potentially orphaning the API-key secret. Do not discard the entry until this cleanup succeeds, or preserve a retirement record for retry.
			if secretID, ok := secret.ParseSecretReference(prev.APIKey); ok {
				if err := bulker.DeleteSecret(ctx, secretID); err != nil {
					zlog.Warn().Err(err).Str("secret.id", secretID).Str(ecs.PolicyOutputName, p.Name).
						Msg("failed to delete mOTLP API key secret during transition to external OTLP")
				}
  • Files reviewed: 12/12 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines +769 to +773
if outputBulk == nil {
outputBulk = bulk
}
if err := outputBulk.APIKeyInvalidate(ctx, outputIds...); err != nil {
zlog.Info().Err(err).Strs("ids", outputIds).Str(ecs.PolicyOutputName, outputName).Msg("Failed to invalidate API keys")
Comment on lines +300 to +304
if prev, ok := agent.Outputs[p.Name]; ok && prev.APIKeyID != "" {
if err := bulker.APIKeyInvalidate(ctx, prev.APIKeyID); err != nil {
zlog.Warn().Err(err).Str(ecs.APIKeyID, prev.APIKeyID).Str(ecs.PolicyOutputName, p.Name).
Msg("failed to invalidate mOTLP API key during transition to external OTLP")
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants