fix: gracefully handle missing secrets in policy monitor - #7571
Conversation
A 404 from GET /_fleet/secret/{id} was treated as a fatal error,
causing the policy monitor to abort and fleet-server to crash-loop —
taking all agents offline when a single integration credential was
missing from the Fleet secrets store.
Add ErrSecretNotFound sentinel to ExtendedAPI.Read so callers can
distinguish "this secret doesn't exist" from network/auth errors.
Change ReadSecrets to log a warning and skip missing secrets rather
than aborting, so the policy loads without the missing value and
only the affected integration degrades.
Closes elastic#7536
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
This pull request does not have a backport label. Could you fix it @ycombinator? 🙏
|
There was a problem hiding this comment.
Pull request overview
This PR adds a defense-in-depth safeguard to prevent Fleet Server from crash-looping when an agent policy references a secret that no longer exists in the Fleet secrets store. It introduces a typed “not found” error from the secrets read path and updates policy secret resolution to treat missing secrets as non-fatal, keeping Fleet Server available while allowing only the affected integration to degrade.
Changes:
- Introduce an exported
ErrSecretNotFoundsentinel returned onGET /_fleet/secret/{id}404s. - Update
Bulker.ReadSecretsto warn and continue when a secret is missing instead of aborting policy loading. - Add unit tests validating 404 behavior and successful secret decoding, plus a changelog fragment.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| internal/pkg/bulk/secret.go | Adds ErrSecretNotFound and returns it on 404 responses from the Fleet secrets API. |
| internal/pkg/bulk/secret_test.go | Adds tests for 404 sentinel behavior, generic server errors, and successful reads. |
| internal/pkg/bulk/engine.go | Skips missing secrets (warn + continue) in ReadSecrets while preserving fatal behavior for other errors. |
| changelog/fragments/1786053515-graceful-missing-secrets.yaml | Documents the behavior change as a bug fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Michel Losier <mikelosier@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
internal/pkg/bulk/engine.go:341
ReadSecretsnow toleratesErrSecretNotFoundby skipping the missing secret. There are unit tests forExtendedAPI.Readreturning the sentinel, but there isn't a test that assertsReadSecretsactually continues and returns a partial result map (and does not return an error) when one ID is missing. Adding a focused unit test would help prevent regressions in this crash-loop fix.
// read secrets one by one as there is no bulk API yet to read them in one request
func (b *Bulker) ReadSecrets(ctx context.Context, secretIds []string) (map[string]string, error) {
result := make(map[string]string)
esClient := b.Client()
for _, id := range secretIds {
val, err := ReadSecret(ctx, esClient, id)
if err != nil {
if errors.Is(err, ErrSecretNotFound) {
zerolog.Ctx(ctx).Warn().Str("secret_id", id).Msg("secret not found; policy will load without it")
continue
}
return nil, err
}
result[id] = val
}
return result, nil
internal/pkg/bulk/engine.go:336
- The warning message here is policy-specific ("policy will load without it"), but
Bulker.ReadSecretsis also used for output API key resolution (internal/pkg/policy/policy_output.go:427). In that path, a missing secret is fatal, so this log line can be misleading during incidents. Consider making the message generic (or moving logging to the policy-monitor call site) so it remains accurate for all callers.
if errors.Is(err, ErrSecretNotFound) {
zerolog.Ctx(ctx).Warn().Str("secret_id", id).Msg("secret not found; policy will load without it")
continue
TL;DRThe failing Remediation
Investigation detailsRoot Cause
The failing job log excerpt includes:
PR #7571 changes are confined to Evidence
Verification
Follow-up
What is this? | From workflow: PR Buildkite Detective Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not. |
|
@Mergifyio backport 9.5 9.4 8.19 |
✅ Backports have been createdDetails
Cherry-pick of 5ece0ba has failed: To fix up this pull request, you can check it out locally. See documentation: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally
Cherry-pick of 5ece0ba has failed: To fix up this pull request, you can check it out locally. See documentation: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally
Cherry-pick of 5ece0ba has failed: To fix up this pull request, you can check it out locally. See documentation: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally |
Cherry-pick left unresolved conflict markers in secret.go for the
imports ("errors", "fmt", "io") and the status-code check blocks.
Take both incoming additions as intended by the original fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cherry-pick left unresolved conflict markers in secret.go for the
imports ("errors", "fmt", "io") and the status-code check blocks.
Take both incoming additions as intended by the original fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cherry-pick left unresolved conflict markers in secret.go for the
imports ("errors", "fmt", "io") and the status-code check blocks.
Take both incoming additions as intended by the original fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The >= 400 check was introduced on main in #7416 but never backported to these branches. The cherry-pick dragged it in as a side-effect. Remove it so non-404 errors fall through as they did before, keeping the backport minimal and matching the intent of #7571. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The >= 400 check was introduced on main in #7416 but never backported to these branches. The cherry-pick dragged it in as a side-effect. Remove it so non-404 errors fall through as they did before, keeping the backport minimal and matching the intent of #7571. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The >= 400 check was introduced on main in #7416 but never backported to these branches. The cherry-pick dragged it in as a side-effect. Remove it so non-404 errors fall through as they did before, keeping the backport minimal and matching the intent of #7571. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
What is the problem this PR solves?
Fleet Server crash-loops when any secret referenced in an agent policy's
secret_referencescannot be found in the Fleet secrets store. A single missing secret — from a deleted or orphaned integration credential — causes the entire policy monitor to abort with:Fleet Server then restarts, re-reads the same stale policy document from the policy monitor (the checkpoint hasn't advanced), encounters the same 404, and crashes again — indefinitely. Every agent on that fleet-server instance goes offline for the duration.
This is a disproportionate blast radius: one deleted integration credential (unrelated to fleet-server's own connectivity) can take the entire Fleet Server offline.
The root causes that produce missing secrets are tracked in Kibana (elastic/kibana#282911 and elastic/kibana#282912) and in fleet-server (#7533, #7534). This PR is a defense-in-depth fix: even after those root causes are resolved, fleet-server should not treat a single missing package-policy credential as a reason to crash-loop and take all agents offline.
How does this PR solve the problem?
Two changes:
bulk/secret.go—ExtendedAPI.Read: Returns a typedErrSecretNotFoundsentinel when the Fleet secrets API responds with 404. This lets callers distinguish "this secret document doesn't exist" from network/auth failures (403, 500, etc.), which should still be fatal.bulk/engine.go—ReadSecrets: WhenReadSecretreturnsErrSecretNotFound, logs a warning with the secret ID and skips it (continues the loop) rather than aborting. The returned map contains only the secrets that were successfully fetched.With this change, a missing secret causes the policy to load without that secret value — the raw
$co.elastic.secret{id}placeholder remains unsubstituted in the affected integration's config. That integration's credentials won't resolve (it will fail to authenticate), but fleet-server itself stays up and continues serving all other agents.Callers that need hard failure for missing secrets (e.g. output API key resolution, where an empty key would silently break agent→ES connectivity) can check whether the expected ID is present in the returned map — as the second commit in #7416 already does.
How to test this PR locally
DELETE /_fleet/secret/<id>WARN secret not found; policy will load without it secret_id=<id>$co.elastic.secret{id}placeholder rather than the secret valueDesign Checklist
ReadSecretsindependently per policy load; skipping is per-request with no shared state.)Checklist
./changelog/fragmentsusing the changelog toolRelated issues
agentPolicyService.delete()can leave orphaned.fleet-policiesdocuments when the cleanup call after package-policy deletion fails silentlydeleteSecretsIfNotReferencedonly checks live package-policy saved objects, not.fleet-policies, so secrets are deleted even when an orphaned policy document still references them>= 400check inExtendedAPI.Readwas introduced in Store output API key secrets in .fleet-secrets instead of .fleet-agents #7416. That PR's own second commit described the original tolerant design —ReadSecretswas not expected to fail on a missing secret; hard failure for the output API key was added at the caller level. This PR restores that layered design by making 404 non-fatal inReadSecretswhile preserving fatal behavior for other 4xx/5xx responses.