Skip to content

fix(certificatee): stop the legacy-duplicate delete/recreate thrash loop - #43

Merged
ananthb merged 1 commit into
masterfrom
certificatee-legacy-cleanup-fix
Aug 13, 2026
Merged

fix(certificatee): stop the legacy-duplicate delete/recreate thrash loop#43
ananthb merged 1 commit into
masterfrom
certificatee-legacy-cleanup-fix

Conversation

@ananthb

@ananthb ananthb commented Aug 13, 2026

Copy link
Copy Markdown
Member
AI description, click for details

Summary

v0.5.5 (#42) shipped, deployed to staging + canary, and ran for ~18h - but the cleanup never actually converged. It logged Removed legacy duplicate certificate X every ~10-minute cycle, for the same certs, forever, and the duplicates were still present in HAProxy's live cert store the whole time.

Root cause, confirmed directly against a real Data Plane API (haproxy-fra1, part of the shared_lb_fra_staging fleet - same staging_mainnet pool certificatee-staging manages):

  1. ListCertificateRefs/GetCertificateDetail/UpdateCertificate all read HAProxy's runtime in-memory cert table, populated only at the last actual reload.
  2. DeleteCertificate only hits the storage (on-disk) delete endpoint with skip_reload=true - confirmed via direct curl: the delete succeeds (204) and the file is genuinely gone from disk (404 on a follow-up GET), but the runtime listing keeps reporting the exact same stale content afterward, completely unaffected.
  3. Tried the alternative - DELETE /v3/services/haproxy/runtime/ssl_certs/{name} (the runtime-socket delete) - and it 500s: certificate ... in use, can't be deleted!. Every file loaded from a directory crt-store (crt certs/) counts as "in use" regardless of whether it's the one actually selected for a live SNI match, so this isn't a viable path either.
  4. Next cycle, ListCertificateRefs() rediscovers the same stale runtime-listed ref, and the ordinary per-ref sync loop calls EnsureStorageCertificate on it - which 404s against the now-missing file and recreates it. Confirmed this exact 404-then-recreate step with a direct PUT against the deleted file. Cleanup deletes it again at the end of that cycle. Repeat forever.

Fix

Restructure processHAProxyEndpoint into three phases:

  1. Fetch every ref's live metadata first (no writes yet).
  2. Classify legacy duplicates from that complete picture (same SAN-grouping + naming heuristic as before, unchanged).
  3. Sync everything that isn't a confirmed duplicate normally (syncOneCertificate, extracted from the old per-ref loop body - identical logic). Confirmed duplicates are excluded from the normal sync path entirely once their keeper is confirmed stable this cycle, so there's nothing left to recreate them. If the keeper isn't stable yet, the duplicate falls back to being synced normally (old behavior) so nothing goes unrenewed while cleanup waits.

Removal is still delete-only, skip_reload=true, never forces a reload. The duplicate's storage file gets deleted once and stays deleted; HAProxy's live listing will keep reporting it until some other, unrelated reload happens on that node - at which point it drops out for good on its own. Repeating the delete attempt every cycle after that is now a harmless no-op (404), not a recreate loop.

Test plan

  • New regression test TestCleanupLegacyDuplicateCertificatesDoesNotThrashAcrossCycles: runs processHAProxyEndpoint twice against a mock that keeps listing both duplicate names every cycle (matching real HAProxy behavior) and asserts the legacy name is never written to storage again after being deleted
  • Updated existing tests for the new behavior: the keeper-unstable case now asserts the duplicate falls back to a normal sync (both files persisted) instead of being silently skipped
  • classifyLegacyDuplicates ambiguous-group cases now unit-tested directly (no HTTP mocking needed)
  • go test ./pkg/haproxy/... ./cmd/certificatee/... ./pkg/certmetrics/..., go vet, gofmt all clean

No Linear ticket - follow-up fix from watching #42 run live on staging/canary.

🤖 Generated with Claude Code

v0.5.5 (#42) deleted a legacy duplicate's storage file every cycle, but
HAProxy's runtime cert listing never drops it without a reload, so
ListCertificateRefs kept rediscovering the same stale ref and the
ordinary per-ref sync recreated it via EnsureStorageCertificate's
404-fallback-to-create. Confirmed directly against a real Data Plane
API (shared_lb_fra_staging / haproxy-fra1): storage delete succeeds
and the file is gone from disk, but the runtime listing keeps
reporting the old content until an unrelated reload happens; a
runtime-level delete is not an option either, since HAProxy refuses to
remove a certificate still referenced by a bind (500 "in use").

Restructure processHAProxyEndpoint into three phases: fetch every
ref's live metadata first, classify legacy duplicates from that
complete picture, then sync everything that isn't a confirmed
duplicate. Confirmed duplicates are deleted once (never recreated,
since they no longer go through the normal per-ref sync at all) as
long as their keeper stays stable; if the keeper isn't stable yet, the
duplicate falls back to being synced normally so nothing goes
unrenewed while cleanup waits for a calmer cycle.
@github-actions

Copy link
Copy Markdown

Code Coverage Report

Total Coverage: total: (statements) 53.3%

Coverage by function
github.com/vinted/certificator/cmd/certificatee/health.go:27:		newCertificateeHealthChecker		100.0%
github.com/vinted/certificator/cmd/certificatee/health.go:38:		certificateeHealthMaxSyncAge		75.0%
github.com/vinted/certificator/cmd/certificatee/health.go:46:		Check					87.5%
github.com/vinted/certificator/cmd/certificatee/health.go:78:		MarkEndpointSyncSuccess			80.0%
github.com/vinted/certificator/cmd/certificatee/health.go:88:		lastSync				100.0%
github.com/vinted/certificator/cmd/certificatee/helpers.go:9:		createHAProxyClients			0.0%
github.com/vinted/certificator/cmd/certificatee/main.go:52:		isStable				100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:72:		Error					100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:76:		Unwrap					100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:80:		main					0.0%
github.com/vinted/certificator/cmd/certificatee/main.go:132:		maybeUpdateCertificates			0.0%
github.com/vinted/certificator/cmd/certificatee/main.go:148:		processHAProxyEndpoint			84.3%
github.com/vinted/certificator/cmd/certificatee/main.go:248:		syncOneCertificate			71.9%
github.com/vinted/certificator/cmd/certificatee/main.go:320:		isLegacyCertificateName			100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:330:		classifyLegacyDuplicates		96.6%
github.com/vinted/certificator/cmd/certificatee/main.go:391:		removeLegacyDuplicateCertificate	44.4%
github.com/vinted/certificator/cmd/certificatee/main.go:406:		setDataPlaneAPIVersion			100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:416:		domainFromCertificateName		100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:421:		domainsForVault				91.7%
github.com/vinted/certificator/cmd/certificatee/main.go:442:		certificateDomains			66.7%
github.com/vinted/certificator/cmd/certificatee/main.go:450:		parseCertificateDomainList		77.8%
github.com/vinted/certificator/cmd/certificatee/main.go:482:		shouldUpdateForLiveExpiry		100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:500:		shouldUpdateForSerialMismatch		100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:510:		validateVaultCertificateForUpdate	100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:514:		validateVaultCertificateForUpdateAt	91.7%
github.com/vinted/certificator/cmd/certificatee/main.go:539:		expectedVaultCertificateDomains		83.3%
github.com/vinted/certificator/cmd/certificatee/main.go:552:		vaultCertificateMatchesDomains		100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:565:		parseVaultLeafCertificate		100.0%
github.com/vinted/certificator/cmd/certificatee/main.go:574:		readVaultCertificateBundle		85.7%
github.com/vinted/certificator/cmd/certificatee/main.go:588:		readValidVaultCertificateBundle		92.3%
github.com/vinted/certificator/cmd/certificatee/main.go:616:		firstDomain				66.7%
github.com/vinted/certificator/cmd/certificatee/main.go:624:		syncCertificate				85.7%
github.com/vinted/certificator/cmd/certificatee/main.go:673:		buildPEMBundle				70.0%
github.com/vinted/certificator/cmd/certificatee/main.go:699:		endsWith				66.7%
github.com/vinted/certificator/cmd/certificator/main.go:28:		main					0.0%
github.com/vinted/certificator/cmd/certificator/main.go:112:		deleteExpiredVaultCertificate		0.0%
github.com/vinted/certificator/cmd/certificator/main.go:116:		deleteExpiredVaultCertificateAt		100.0%
github.com/vinted/certificator/pkg/acme/acme.go:27:			GetEmail				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:32:			GetRegistration				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:37:			GetPrivateKey				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:42:			NewClient				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:61:			setupClient				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:80:			setupAccount				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:118:			newAccount				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:130:			getAccountKey				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:159:			registerAccount				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:177:			recoverAccount				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:211:			saveAccount				0.0%
github.com/vinted/certificator/pkg/acme/acme.go:221:			saveKey					0.0%
github.com/vinted/certificator/pkg/certificate/certificate.go:18:	ObtainCertificate			0.0%
github.com/vinted/certificator/pkg/certificate/certificate.go:50:	GetCertificate				0.0%
github.com/vinted/certificator/pkg/certificate/certificate.go:63:	ParsePEMCertificate			0.0%
github.com/vinted/certificator/pkg/certificate/certificate.go:80:	DeleteCertificate			0.0%
github.com/vinted/certificator/pkg/certificate/certificate.go:85:	IsExpired				0.0%
github.com/vinted/certificator/pkg/certificate/certificate.go:91:	NeedsReissuing				0.0%
github.com/vinted/certificator/pkg/certificate/certificate.go:119:	arraysEqual				0.0%
github.com/vinted/certificator/pkg/certificate/certificate.go:133:	arrayContains				0.0%
github.com/vinted/certificator/pkg/certificate/certificate.go:142:	VaultCertLocation			0.0%
github.com/vinted/certificator/pkg/certificate/certificate.go:146:	storeCertificateInVault			0.0%
github.com/vinted/certificator/pkg/certmetrics/metrics.go:87:		StartMetricsServer			0.0%
github.com/vinted/certificator/pkg/certmetrics/metrics.go:112:		newHandler				100.0%
github.com/vinted/certificator/pkg/certmetrics/metrics.go:136:		PushMetrics				0.0%
github.com/vinted/certificator/pkg/config/config.go:73:			LoadConfig				0.0%
github.com/vinted/certificator/pkg/haproxy/client.go:57:		Error					0.0%
github.com/vinted/certificator/pkg/haproxy/client.go:63:		IsHTTPStatus				100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:68:		unexpectedStatusError			100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:77:		NewClient				100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:112:		NewClients				92.3%
github.com/vinted/certificator/pkg/haproxy/client.go:137:		Endpoint				100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:142:		doRequest				88.9%
github.com/vinted/certificator/pkg/haproxy/client.go:160:		parseAPITime				66.7%
github.com/vinted/certificator/pkg/haproxy/client.go:215:		ListCertificates			100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:229:		ListCertificateRefs			90.9%
github.com/vinted/certificator/pkg/haproxy/client.go:271:		GetCertificateDetail			68.4%
github.com/vinted/certificator/pkg/haproxy/client.go:315:		UpdateCertificate			80.0%
github.com/vinted/certificator/pkg/haproxy/client.go:349:		UpdateStorageCertificate		90.9%
github.com/vinted/certificator/pkg/haproxy/client.go:368:		CreateCertificate			78.9%
github.com/vinted/certificator/pkg/haproxy/client.go:401:		EnsureStorageCertificate		50.0%
github.com/vinted/certificator/pkg/haproxy/client.go:422:		DeleteCertificate			100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:441:		ExtractDomainFromPath			100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:461:		NormalizeDomainForVault			0.0%
github.com/vinted/certificator/pkg/haproxy/client.go:468:		StorageCertificateName			100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:474:		IsExpiring				100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:486:		NormalizeSerial				100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:519:		Error					100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:523:		Info					0.0%
github.com/vinted/certificator/pkg/haproxy/client.go:527:		Debug					100.0%
github.com/vinted/certificator/pkg/haproxy/client.go:531:		Warn					0.0%
github.com/vinted/certificator/pkg/haproxy/client.go:536:		toLogrusFields				85.7%
github.com/vinted/certificator/pkg/vault/vault.go:18:			NewVaultClient				0.0%
github.com/vinted/certificator/pkg/vault/vault.go:27:			TokenLookupSelf				0.0%
github.com/vinted/certificator/pkg/vault/vault.go:39:			KVWrite					0.0%
github.com/vinted/certificator/pkg/vault/vault.go:53:			KVDelete				0.0%
github.com/vinted/certificator/pkg/vault/vault.go:66:			KVRead					0.0%
github.com/vinted/certificator/pkg/vault/vault.go:87:			vaultFullPath				0.0%
total:									(statements)				53.3%

@ananthb
ananthb merged commit 598360d into master Aug 13, 2026
1 check passed
@ananthb
ananthb deleted the certificatee-legacy-cleanup-fix branch August 13, 2026 11:47
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.

1 participant