Stop denying Secrets Manager access when CDot is unavailable
What does this MR do and why?
- Fixes https://gitlab.com/gitlab-org/gitlab/-/work_items/622853
- When CustomersDot was unreachable, entitlement resolution failed closed to
:ineligible, so an entitled customer's CI jobs lost their secrets and failed with an opaque runner system failure - On self-managed the entitlement is install-wide, so a single failure hits every pipeline on the install
- This MR serves CustomersDot's last answer for up to 24h instead, adds a timeout to every request-path caller, and adds a counter plus one searchable log line so the next outage is diagnosable
- A denial still needs an explicit CustomersDot answer, so this never grants access CustomersDot did not already grant. Not fail-open — @vitallium approved the bounded version in https://gitlab.com/gitlab-org/gitlab/-/work_items/623420#note_3780077140
- Prerequisite for shipping the paid experience to self-managed in 19.4
Everything here is still behind secrets_manager_paid_experience, which stays default-off. The fallback branch and the default timeout are also behind secrets_manager_entitlement_lkg_fallback, an ops flag that's default-enabled — an off switch back to today's behaviour on .com, and one a self-managed admin can flip too.
Choices a reviewer will probably ask about
- The slot holds CustomersDot's two raw responses, not the merged entitlement. The merge derives the subscription grace window from the clock, so replaying the inputs re-evaluates it against today and a grace window that lapsed mid-outage stops being extended.
trial.stateis the exception, since the client computes it at parse time — an expiring trial keeps serving:trialfor the rest of the window. That is deliberate: deriving it locally would deny a customer who has since converted to paid. Gitlab::Redis::SharedState, notRails.cache. The cache instance is eviction-eligible, and an evicted slot is indistinguishable from an expired one — it would go missing under memory pressure, which is exactly when this is needed.- Only a transport failure triggers the fallback, discriminated by the exception's
cause. I narrowedGitlab::HTTP::HTTP_ERRORSdown toTRANSPORT_ERRORS, dropping eight classes: our own SSRF, redirect, size, header-injection and invalid-response guards, plusOpenSSL::OpenSSLErrorandNet::HTTPBadResponse. A wrongsubscription_portal_urlis not among them — a bad hostname raisesSocketErrorand a bad portErrno::ECONNREFUSED, both of which still earn the fallback. - The 2s timeout is a default on
.forrather than something each caller passes, so all eight untimed request-path callers are covered and a new one inherits it.Net::HTTPretries a read timeout once on a GET, so the effective ceiling is roughly twice that..for!keeps no default — those callers pass their own budget or are conversion mutations that must reach CustomersDot. - 24h is the conservative end of the 24–48h range agreed with Fulfillment.
- No circuit breaker. It is the right mechanism for the fleet-wide cost of a long outage, but
Gitlab::CircuitBreakersends a synchronous Sentry event per failure in the request thread, which is whatSecretsManagement::ThrottledErrorTrackingexists to avoid — so using it properly means a bespoke breaker class plus its own metrics and flag, as Gitaly did. Deferred rather than overlooked. - Each
for!caller's existing timeout is now also its replay threshold.EE::Ci::RegisterJobService(0.25s) andSecretsManagement::BillableEvents::SecretsReadEmitter(ENTITLEMENT_TIMEOUT_SECONDS = 0.25, nocache_ttl) are both low enough that any CustomersDot reply slower than 250ms becomes a transport failure and anlkg_staleserve, so the new counter will be non-zero at baseline on those two paths. ForSecretsReadEmitterthat changes what a billable event means: before this MR a timeout dropped the entitlement keys from the event, now a usable slot stamps it with the last-known-good state. The client never caches failures, so a persistently slow CustomersDot means every secret read makes a live call and logs onelkg_staleline.SecretsManagement::BillableEvents::SecretsStoredEmitterpasses no timeout at all, so it runs on theGitlab::HTTPdefaults — a separate gap, not introduced here. No correctness impact on any of the three; all fail open. Whether to raise the two0.25sbudgets is a follow-up.
References
- Issue - https://gitlab.com/gitlab-org/gitlab/-/work_items/622853
- Incident and design discussion - https://gitlab.com/gitlab-org/gitlab/-/work_items/623420
- CustomersDot
Cache-Controlon/trials(still open, so the 120s fallback is live) - https://gitlab.com/gitlab-org/customers-gitlab-com/-/merge_requests/17034 - Long-term direction, Budget Authorization + Cloud Connector V2 - https://gitlab.com/gitlab-org/gitlab/-/work_items/605932
How to set up and validate locally
-
Enable the flag and pick a top-level group:
Feature.enable(:secrets_manager_paid_experience) -
Resolve once against a reachable CustomersDot so the slot gets written, then confirm it exists:
group = Group.find_by_full_path('<your-root-group>') SecretsManagement::Entitlement.for(group).state SecretsManagement::Entitlement::LastKnownGoodStore.read( SecretsManagement::Entitlement::Resolver.cache_key_for(group) ) -
Point
subscription_portal_urlat an address that black-holes, or stub the client to raise aNet::ReadTimeout-caused error, then resolve again. It should return the previous state rather than:ineligible, and logsource: lkg_stalewith anlkg_age_s. -
Travel past 24h (or
LastKnownGoodStore.delete) and resolve again — now it fails closed to:ineligiblewithsource: fail_closed.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.