feat(operator): reconcile NATS event sinks - #1844
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## release/v3.0 #1844 +/- ##
================================================
- Coverage 76.79% 76.78% -0.02%
================================================
Files 472 472
Lines 50356 50362 +6
================================================
- Hits 38673 38672 -1
- Misses 8286 8288 +2
- Partials 3397 3402 +5
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
🛑 Changes requested — automated reviewA failed sink creation can leave a durable ownership reservation that later authorizes deletion of an externally created sink. |
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot posted 1 new inline finding.
Summary: #1844 (comment)
Arbiter review — PR #1844: feat(operator): reconcile NATS event sinksSummaryThe declarative NATS-sink reconciler is well structured: the ownership model ( Confirmed findingsMajor — Ownership can be permanently lost after a successful
|
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot posted 1 new inline finding.
Summary: #1844 (comment)
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot posted 1 new inline finding.
Summary: #1844 (comment)
Azorlogh
left a comment
There was a problem hiding this comment.
Reviewed the sinks logic closely and it holds up. I verified the parts most likely to be silently wrong:
- The
events list --jsonparser matches protojson exactly — I probedprotoJSONOptsand confirmed camelCase keys,batchDelayMsas a quoted string (henceprotoJSONInt64),eventTypesas enum names, and unset oneof members omitted solistedSinkKindworks. - CRD
batchSizemax100000==domain.MaxSinkBatchSize, so no CRD-valid value can be rejected by the FSM. - Reserve-ownership-before-create plus verify-after-failed-remove is idempotency-correct, and the deliberate asymmetry between the two is covered by tests.
ledgerctlCommandsingle-quotes every arg, so CRD strings can't reach/bin/sh.
One unrelated commit to split out and three smaller points inline.
One more, pre-existing but load-bearing for this PR: cmd/ledgerctl/events/add_sink.go advertises "If a sink with the same name already exists, it is replaced (upsert)" and aliases the command upsert, while processAddEventsSink returns ErrSinkAlreadyExists. That wrong help text is exactly why the two-pass remove/recreate exists here — worth correcting while you're in the area.
| endpoints = append(endpoints, buildEndpointEntry(ep)) | ||
| func desiredDNSEndpoints(ledger *ledgerv1alpha1.Cluster) []desiredDNSEndpoint { | ||
| if len(ledger.Spec.DNSEndpoints) == 0 { | ||
| legacy := ledger.Spec.DNSEndpoint //nolint:staticcheck // Reconcile the deprecated field for backwards compatibility. |
There was a problem hiding this comment.
This commit (704e274) doesn't belong in a NATS-sinks PR. It re-adds spec.dnsEndpoint, which #1617 deliberately replaced with dnsEndpoints; nothing in the repo consumes it, the PR body never mentions DNS, and the commit message has no body explaining what broke. Please split it out with a stated reason — and if this is only about CRs that already exist in a cluster, updating those CRs beats carrying a compat shim in an unreleased v3.
| }) | ||
| ctrl.LoggerFrom(ctx).Error(err, "event sink reconciliation failed", "cluster", cluster.Name) | ||
|
|
||
| return earlierRequeue(baseResult, ctrl.Result{RequeueAfter: sinkRequeueInterval}) |
There was a problem hiding this comment.
A name conflict can never self-heal, but it lands here and requeues at sinkRequeueInterval (5s) forever — roughly 17k ledgerctl events list execs per day into pod-0 for one misconfigured Cluster. Worth backing off to sinkDriftCheckInterval when the failure is a conflict rather than a transient exec error.
| // listedEventSinksResponse mirrors only the stable, non-secret fields emitted | ||
| // by `ledgerctl events list --json`. Other sink variants are retained as raw | ||
| // JSON solely so name conflicts are detected and never overwritten. | ||
| type listedEventSinksResponse struct { |
There was a problem hiding this comment.
The same response also carries sinkStatuses[].cursor and .error, which this struct drops. A Ledger image built without the nats tag stores the config fine and then fails at createSink with "unsupported events sink type: nats (not compiled in this build)" — the CR still reports SinksSynced=True and shows nothing. I know the docs scope delivery monitoring out, but the data is already in hand here, so surfacing it is close to free.
| echo "FAIL: JetStream persisted only ${MESSAGES:-0} messages" | ||
| exit 1 | ||
| } | ||
| echo "PROOF: JetStream LEDGER_EVENTS persisted $MESSAGES messages" |
There was a problem hiding this comment.
The scenario covers create-and-deliver only. Removal and the two-pass update are exercised solely against the fake exec, which is where the ErrSinkAlreadyExists / ErrSinkNotFound assumptions could actually diverge from real Ledger. A final step flipping to sinks: {} and asserting appliedSinks empties plus the sink disappearing from events list would close that.
|
I think this API would be more coherent as a dedicated One Kubernetes resource per sink would provide a natural identity and lifecycle, independent status/conditions, isolated conflicts, simpler GitOps/RBAC, and a cleaner path for future transports and For example: apiVersion: ledger.formance.com/v1alpha1
kind: EventSink
metadata:
name: primary
spec:
clusterRef:
name: sink-cluster
nats:
url: nats://nats:4222
topic: ledger.events
format: json
status:
conditions: []
cursor: 42The dedicated CRD alone would not fully solve the current ownership blocker, however. Ownership should also be represented in Ledger's Raft-replicated Since Ledger v3 is still unreleased and has no compatibility burden between development revisions, I recommend establishing this API/lifecycle now rather than carrying the name-only ownership protocol forward. |
Summary
Cluster.spec.sinks.natsAPI for Ledger v3 NATS JetStream sinksledgerctl eventsAPI, without restarting the StatefulSetnatsbuild tag in direct/PR Ledger images and the Operator E2E imageDeclarative behavior
spec.sinks: runtime sinks remain unmanagedspec.sinks(including{}): the Operator maintains the declared set and removes only sinks it previously createdSinksSynced=True: the Raft-replicated sink configuration matches the CR<topic>.>Validation
nix develop --command bash scripts/agent-check— PASSnix develop --command bash -c 'unset GOROOT; cd misc/operator && go test ./...'— PASSnix develop --command bash -c 'unset GOROOT; go test -tags nats ./internal/application/events ./cmd/ledgerctl/events'— PASSchainsaw test --config e2e/chainsaw-test.yaml --test-dir e2e/tests/event-sinks-nats --kube-context kind-ledger-e2e— PASSKind + NATS evidence
The permanent Chainsaw scenario built the local Ledger image with
BUILD_TAGS=s3,nats, installed this Operator into Kind, provisioned NATS JetStream, and configured the sink only by updating theClusterCR.appliedSinks: [primary]andSinksSynced=True160f13d7-e04f-4260-ada1-647524ac9465and spec hash remained3ce71741975bbad7f72580b58b22399ff8c1331f4aa24a52ff5be3ccd5c99f13CREATED_LEDGERandCOMMITTED_TRANSACTION, both identifyingoperator-sink-e2eledgerctl events list --jsonreported sinkprimary, cursor3, anderror: nullLEDGER_EVENTSreportedmessages: 2across two subjectsImages used for the proof:
nats:2.12.14-alpine@sha256:7cef1bd3fed6034e95cf6e6bc9c28c5afa6dc58e9fb778dd7924a1ac62569f2dnatsio/nats-box:0.19.2@sha256:8031d190c7ee24081f3f27cc939fb647a1eeb29ebb5c60fef9b5b6c7a846d6a2Notes for reviewers
Updating an operator-owned sink is intentionally a two-pass remove/recreate because Ledger currently rejects duplicate sink names. Ledger retains the per-name cursor, so committed events remain eligible for at-least-once delivery while the configuration converges.
SinksSyncedproves configuration convergence; the E2E scenario separately proves delivery and cursor advancement.