Skip to content

feat(backend): add Kafka message queue backend with franz-go - #166

Merged
matthyx merged 4 commits into
kubescape:mainfrom
harshitg927:feat/kafka-backend-phase2
Jul 21, 2026
Merged

feat(backend): add Kafka message queue backend with franz-go#166
matthyx merged 4 commits into
kubescape:mainfrom
harshitg927:feat/kafka-backend-phase2

Conversation

@harshitg927

@harshitg927 harshitg927 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Overview

Adds Kafka as a pluggable message-queue backend for the synchronizer server, building on the Phase 1 messaging abstraction. Kafka is fully opt-in and selected via config - Pulsar remains the default with zero behavior change for existing deployments.

Current behavior: the server bridges WebSocket-connected clusters to a Pulsar-only backend bus.

New behavior: setting backend.messageQueue.type: "kafka" routes the same message flow through Kafka instead. The choice is confined to the newFromConfig factory behind the existing MessageProducer/MessageReader interfaces, so the adapter, WebSocket handling, and reconciliation logic are unchanged regardless of backend.

Implemented with the pure-Go, cgo-free twmb/franz-go client (keeps cgo-free builds, per maintainer review).

Additional Information

Key design points (all from the design proposal):

  • Split topics - producer → .out (cluster→backend), reader → .in (backend→cluster). No self-loop filter needed.
  • Partition key {account}/{cluster} on .out - ordering-correctness requirement so a Put and a following Delete for the same resource land on one partition, in order.
  • Reader = at-most-once, replicating Pulsar Reader fan-out: unique per-pod group.id ({groupIdPrefix}-{hostname}), auto.offset.reset=latest, auto-commit disabled → every pod sees the whole .in topic (never a competing consumer group).
  • Message headers reuse the exact property keys Pulsar uses (via messaging.BuildProducerProperties); values are UTF-8, empty/absent optional metadata is omitted.
  • 64 MB sizing across client batch/fetch limits, with BrokerMax{Write,Read}Bytes auto-raised when maxMessageBytes exceeds franz-go's 100 MB default.
  • Shared handler, metrics (synchronizer_mq_producer_* with backend="kafka"), and worker-pool pattern reused unchanged.

Out of scope (later phases): SASL/TLS enforcement (config fields are parsed now; non-PLAINTEXT fails loudly - enforcement is an immediate follow-up), event-ingester-service Kafka support (Phase 3), and Helm chart plumbing (Phase 4).

Note: pulling in the Redpanda testcontainers module bumped testcontainers-go, which broke the pinned Pulsar/k3s test modules; all three testcontainers modules are pinned in lockstep at v0.35.0 to keep the suite green.

How to Test

Automated (needs Docker; spins up Redpanda per test):

go test ./adapters/backend/v1/... -run Kafka -v      # 6 Kafka integration tests
go test ./adapters/backend/v1/... -run Pulsar -v     # no Pulsar regression
go test -short ./...                                  # unit suite

Covers factory wiring, header + account/cluster key assertions, Put→Delete same-partition ordering, ~64 MB round-trip, reader→adapter dispatch, and multi-group fan-out.

Manual smoke:

  1. Start a local Redpanda and create armo.kubescape.synchronizer.{out,in} (each max.message.bytes=67108864).
  2. Set backend.messageQueue.type: "kafka" in configuration/server/config.json with bootstrapServers: ["localhost:9092"].
  3. LOGGER_LEVEL=debug go run ./cmd/server → confirm initializing kafka client / kafka message queue initialized and reader startup logs.
  4. rpk topic produce armo.kubescape.synchronizer.in -H event=PutObject -H account=a1 -H cluster=c1 → server logs show the message consumed (skipped as unrelated when no cluster is connected on the pod - expected at-most-once behavior).

Related issues/PRs

Checklist before requesting a review

  • My code follows the style guidelines of this project
  • I have commented on my code, particularly in hard-to-understand areas
  • I have performed a self-review of my code
  • If it is a core feature, I have added thorough tests.
  • New and existing unit tests pass locally with my changes

Summary by CodeRabbit

  • New Features
    • Added message-queue backend selection via a new messageQueue config block, with Kafka support and Pulsar fallback.
    • Introduced Kafka messaging with required record headers, partition-keying for per-key ordering, and support for large payloads.
    • Added Kafka configuration options (brokers/topics, consumer group prefix, compression/size limits, security settings with PLAINTEXT only honored).
  • Bug Fixes
    • Improved backend selection when message-queue settings are unset or explicitly set.
    • Added graceful WebSocket/HTTP shutdown with startup/read timeouts on SIGINT/SIGTERM.
  • Tests
    • Added Redpanda-based end-to-end Kafka integration tests (headers, ordering, large messages, dispatch, and fan-out).

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds configurable Kafka support alongside Pulsar, including franz-go producer and reader implementations, Redpanda integration tests, dependency updates, and graceful HTTP server shutdown.

Changes

Kafka backend

Layer / File(s) Summary
Configuration and backend routing
config/config.go, adapters/backend/v1/factory.go, adapters/backend/v1/factory_test.go
Adds Kafka settings, selects Kafka or Pulsar from messageQueue.type, retains the Pulsar fallback, and validates incomplete or unsupported Kafka configurations.
Producer construction and record encoding
adapters/backend/v1/kafka.go, adapters/backend/v1/kafka_integration_test.go, go.mod
Creates franz-go producers, configures compression and size limits, assigns partition keys, writes non-empty headers, and tests ordering, headers, large payloads, and Redpanda setup.
Reader lifecycle and dispatch
adapters/backend/v1/kafka.go, adapters/backend/v1/kafka_integration_test.go
Adds consumer groups, polling, worker dispatch, message conversion, adapter callbacks, lifecycle handling, and fan-out tests.

Server lifecycle

Layer / File(s) Summary
Graceful HTTP shutdown
cmd/server/main.go
Uses http.Server, handles SIGINT and SIGTERM, bounds shutdown with a 30-second timeout, and ignores the normal http.ErrServerClosed result.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant KafkaMessageReader
  participant Kafka
  participant messaging.MessageHandler
  participant adapters.Adapter
  KafkaMessageReader->>Kafka: Poll records
  Kafka-->>KafkaMessageReader: Return fetched records
  KafkaMessageReader->>messaging.MessageHandler: Dispatch IncomingMessage
  messaging.MessageHandler->>adapters.Adapter: Handle message callback
Loading

Possibly related PRs

Suggested reviewers: matthyx

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding an opt-in Kafka backend implemented with franz-go.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@harshitg927

Copy link
Copy Markdown
Contributor Author

@matthyx One scoping question on SASL/TLS.

SASL/TLS is required for any real deployment, so I want to get it right. In this PR I've laid the groundwork but deferred the enforcement: the kafkaConfig block already parses all the security fields (securityProtocol, saslMechanism, saslUsername/saslPassword, tlsEnabled, tlsCaCertPath), and anything other than PLAINTEXT currently fails loudly at startup rather than being silently ignored. So wiring the actual SASL/SCRAM + TLS dialer into the franz-go client is the only remaining step - franz-go supports it out of the box.

I kept it out of this PR to keep the core produce/consume + partition-key + 64 MB + fan-out changes focused and reviewable, with the integration tests running against a plaintext broker.

Would you prefer to, add SASL/TLS in a follow-up PR (this one lands as PLAINTEXT-only, security enforcement + a secured-broker integration test come next), or fold it into this PR so v1 ships complete?
Happy to go either way - just didn't wanted to assume.

@matthyx

matthyx commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

you can add that as a follow-up 👍

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
adapters/backend/v1/factory.go (1)

19-28: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Explicit type: "pulsar" is rejected instead of falling back.

Only an absent messageQueue.type falls back to Pulsar; an explicit "pulsar" value hits the default branch and errors as "unknown message queue type". Since "kafka" is a documented valid value, operators are likely to try "pulsar" as its counterpart.

♻️ Suggested fix
 		switch mq.Type {
 		case "kafka":
 			return newKafkaFromConfig(cfg)
+		case "pulsar":
+			return newPulsarFromConfig(cfg)
 		default:
 			return nil, fmt.Errorf("unknown message queue type %q", mq.Type)
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/backend/v1/factory.go` around lines 19 - 28, Update the message
queue type switch in the backend factory to explicitly accept `"pulsar"` and
return newPulsarFromConfig(cfg), while preserving the existing Kafka handling
and unknown-type error behavior. Ensure both an omitted type and an explicit
`"pulsar"` type select Pulsar.
adapters/backend/v1/kafka_integration_test.go (1)

260-262: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff

Fixed-sleep readiness waits are flake-prone but low priority for integration tests.

Both TestKafkaMessageReader_DispatchesToAdapter and TestKafkaMessageReader_FanOutAcrossGroups use a hardcoded time.Sleep(5 * time.Second) to wait for the reader(s) to join their consumer group before producing. On a slow CI runner this could occasionally under-wait and fail. Not urgent given the 60s downstream select timeout provides some cushion, but a readiness poll (e.g., checking group members via kadm) would be more robust.

Also applies to: 308-309

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/backend/v1/kafka_integration_test.go` around lines 260 - 262,
Replace the fixed 5-second sleeps in TestKafkaMessageReader_DispatchesToAdapter
and TestKafkaMessageReader_FanOutAcrossGroups with readiness polling that
verifies the reader consumer group members through the existing Kafka admin
mechanism before producing messages. Wait until all expected readers have
joined, while retaining a bounded timeout and the existing downstream message
assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@adapters/backend/v1/kafka_integration_test.go`:
- Around line 343-354: Update pollRecords to explicitly detect pollCtx
expiration after PollFetches returns without completing the requested record
count, and fail the test with a clear timeout message instead of continuing the
loop. Preserve existing fetch-error handling and successful accumulation
behavior.

In `@adapters/backend/v1/kafka.go`:
- Around line 38-49: Update newKafkaFromConfig to validate
kafkaCfg.ProducerTopic and kafkaCfg.ConsumerTopic after BootstrapServers
validation, returning clear errors when either is empty before constructing
messaging components. Preserve the existing securityProtocol validation and
ensure both topics are required so producer and consumer paths receive valid
defaults.
- Around line 279-353: Move ownership of closing messageChannel to readerLoop,
ensuring it closes only after the fetch loop and any in-progress batch delivery
finish, and remove the worker-supervisor close after wg.Wait. Keep
listenOnMessageChannel’s !ok handling so workers exit when the writer closes the
channel, while retaining context cancellation as a secondary exit path.

In `@config/config.go`:
- Around line 50-51: Correct the flow-direction comments for ProducerTopic and
ConsumerTopic in the configuration definitions: ProducerTopic is used by
NewKafkaMessageProducer for backend → cluster (.out), while ConsumerTopic is
used by NewKafkaMessageReader for cluster → backend (.in). Leave the field names
and mapstructure tags unchanged.

---

Nitpick comments:
In `@adapters/backend/v1/factory.go`:
- Around line 19-28: Update the message queue type switch in the backend factory
to explicitly accept `"pulsar"` and return newPulsarFromConfig(cfg), while
preserving the existing Kafka handling and unknown-type error behavior. Ensure
both an omitted type and an explicit `"pulsar"` type select Pulsar.

In `@adapters/backend/v1/kafka_integration_test.go`:
- Around line 260-262: Replace the fixed 5-second sleeps in
TestKafkaMessageReader_DispatchesToAdapter and
TestKafkaMessageReader_FanOutAcrossGroups with readiness polling that verifies
the reader consumer group members through the existing Kafka admin mechanism
before producing messages. Wait until all expected readers have joined, while
retaining a bounded timeout and the existing downstream message assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 655667a3-f83f-4aeb-867f-03a672ab9396

📥 Commits

Reviewing files that changed from the base of the PR and between 5edc675 and 4e77a4c.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (5)
  • adapters/backend/v1/factory.go
  • adapters/backend/v1/kafka.go
  • adapters/backend/v1/kafka_integration_test.go
  • config/config.go
  • go.mod

Comment on lines +343 to +354
func pollRecords(t *testing.T, ctx context.Context, consumer *kgo.Client, n int) []*kgo.Record {
t.Helper()
pollCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
records := make([]*kgo.Record, 0, n)
for len(records) < n {
fetches := consumer.PollFetches(pollCtx)
require.NoError(t, fetches.Err())
records = append(records, fetches.Records()...)
}
return records
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

pollRecords can busy-loop with an unhelpful failure once pollCtx expires.

The 30s pollCtx is created once before the loop; once it expires, PollFetches(pollCtx) should return immediately, but if it does so without records or an error, for len(records) < n keeps looping tightly rather than failing with a clear message, until the outer test-suite timeout eventually intervenes.

✅ Suggested fix
 	for len(records) < n {
 		fetches := consumer.PollFetches(pollCtx)
 		require.NoError(t, fetches.Err())
 		records = append(records, fetches.Records()...)
+		if pollCtx.Err() != nil && len(records) < n {
+			t.Fatalf("timed out waiting for %d records, got %d", n, len(records))
+		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func pollRecords(t *testing.T, ctx context.Context, consumer *kgo.Client, n int) []*kgo.Record {
t.Helper()
pollCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
records := make([]*kgo.Record, 0, n)
for len(records) < n {
fetches := consumer.PollFetches(pollCtx)
require.NoError(t, fetches.Err())
records = append(records, fetches.Records()...)
}
return records
}
func pollRecords(t *testing.T, ctx context.Context, consumer *kgo.Client, n int) []*kgo.Record {
t.Helper()
pollCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
records := make([]*kgo.Record, 0, n)
for len(records) < n {
fetches := consumer.PollFetches(pollCtx)
require.NoError(t, fetches.Err())
records = append(records, fetches.Records()...)
if pollCtx.Err() != nil && len(records) < n {
t.Fatalf("timed out waiting for %d records, got %d", n, len(records))
}
}
return records
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/backend/v1/kafka_integration_test.go` around lines 343 - 354, Update
pollRecords to explicitly detect pollCtx expiration after PollFetches returns
without completing the requested record count, and fail the test with a clear
timeout message instead of continuing the loop. Preserve existing fetch-error
handling and successful accumulation behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think this is an issue in practice. franz-go injects a fake fetch with ctx.Err() when the poll context is canceled (it's even mentioned in the PollRecords docstring: "If the context is canceled, a fake fetch will be injected with ctx.Err().").

So once the 30s deadline is reached, fetches.Err() returns context.DeadlineExceeded, and the require.NoError(t, fetches.Err()) on the very next line fails the test immediately. It never ends up busy-looping or waiting for the outer suite timeout.

Also, I think the suggested guard would never actually be hit, since require.NoError fails first. I'd prefer to leave this as-is, though I'm happy to replace the assertion with a more descriptive failure message if that's clearer than just context deadline exceeded.

@matthyx , what do you think?

Comment thread adapters/backend/v1/kafka.go
Comment thread adapters/backend/v1/kafka.go
Comment thread config/config.go Outdated
@harshitg927

Copy link
Copy Markdown
Contributor Author

Sure @matthyx , I will address the coderabbit's review first

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
adapters/backend/v1/factory_test.go (2)

36-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the specific error message to avoid false positives.

Using require.Error confirms that an error occurred, but it might pass for the wrong reason (e.g., an unexpected panic or a different validation error). Consider asserting the exact error string to make the test more robust.

♻️ Proposed refactor
 func TestNewFromConfig_UnknownType(t *testing.T) {
 	_, err := newFromConfig(config.Config{
 		Backend: config.Backend{
 			MessageQueue: &config.MessageQueueConfig{Type: "rabbitmq"},
 		},
 	})
-	require.Error(t, err)
+	require.EqualError(t, err, `unknown message queue type "rabbitmq"`)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/backend/v1/factory_test.go` around lines 36 - 43, Strengthen
TestNewFromConfig_UnknownType by asserting the returned error’s exact message,
not only that an error exists. Preserve the rabbitmq configuration and verify
the message corresponds specifically to the unsupported backend type.

45-65: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider verifying the specific validation error messages.

Similar to the unknown type test, table-driven validation tests can yield false positives if they fail due to an unrelated error rather than the specific validation logic being tested. Adding an expected error string to the test cases and using require.ErrorContains ensures each case fails for the correct reason.

♻️ Proposed refactor (example with placeholder error messages)
 func TestNewFromConfig_KafkaValidation(t *testing.T) {
 	tests := []struct {
-		name  string
-		kafka *config.KafkaConfig
+		name        string
+		kafka       *config.KafkaConfig
+		errContains string
 	}{
-		{name: "missing kafkaConfig", kafka: nil},
-		{name: "missing bootstrapServers", kafka: &config.KafkaConfig{ProducerTopic: "out", ConsumerTopic: "in"}},
-		{name: "missing producerTopic", kafka: &config.KafkaConfig{BootstrapServers: []string{"localhost:9092"}, ConsumerTopic: "in"}},
-		{name: "missing consumerTopic", kafka: &config.KafkaConfig{BootstrapServers: []string{"localhost:9092"}, ProducerTopic: "out"}},
+		{name: "missing kafkaConfig", kafka: nil, errContains: "kafka config is required"},
+		{name: "missing bootstrapServers", kafka: &config.KafkaConfig{ProducerTopic: "out", ConsumerTopic: "in"}, errContains: "bootstrapServers"},
+		{name: "missing producerTopic", kafka: &config.KafkaConfig{BootstrapServers: []string{"localhost:9092"}, ConsumerTopic: "in"}, errContains: "producerTopic"},
+		{name: "missing consumerTopic", kafka: &config.KafkaConfig{BootstrapServers: []string{"localhost:9092"}, ProducerTopic: "out"}, errContains: "consumerTopic"},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			_, err := newFromConfig(config.Config{
 				Backend: config.Backend{
 					MessageQueue: &config.MessageQueueConfig{Type: "kafka", KafkaConfig: tt.kafka},
 				},
 			})
-			require.Error(t, err)
+			require.ErrorContains(t, err, tt.errContains)
 		})
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/backend/v1/factory_test.go` around lines 45 - 65, Update
TestNewFromConfig_KafkaValidation to include the expected validation message for
each table case, then replace require.Error with require.ErrorContains using
that expected message. Keep the existing cases and newFromConfig invocation
unchanged so each test verifies the specific Kafka validation branch rather than
merely any error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@adapters/backend/v1/factory_test.go`:
- Around line 36-43: Strengthen TestNewFromConfig_UnknownType by asserting the
returned error’s exact message, not only that an error exists. Preserve the
rabbitmq configuration and verify the message corresponds specifically to the
unsupported backend type.
- Around line 45-65: Update TestNewFromConfig_KafkaValidation to include the
expected validation message for each table case, then replace require.Error with
require.ErrorContains using that expected message. Keep the existing cases and
newFromConfig invocation unchanged so each test verifies the specific Kafka
validation branch rather than merely any error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dd56e6b1-1780-41b2-975f-c7e64b58c68e

📥 Commits

Reviewing files that changed from the base of the PR and between 4e77a4c and 56390f1.

📒 Files selected for processing (5)
  • adapters/backend/v1/factory.go
  • adapters/backend/v1/factory_test.go
  • adapters/backend/v1/kafka.go
  • adapters/backend/v1/kafka_integration_test.go
  • config/config.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • adapters/backend/v1/factory.go
  • config/config.go
  • adapters/backend/v1/kafka_integration_test.go
  • adapters/backend/v1/kafka.go

@matthyx matthyx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — Kafka backend (Phase 2)

Solid, well-scoped PR. The messaging-abstraction seam is respected, Pulsar stays the untouched default, and the async/worker patterns mirror the existing Pulsar reader. I verified the franz-go behaviors the design leans on (keyed sticky partitioner + default idempotent producer preserve per-partition order on .out; consumer-group ConsumeResetOffset(AtEnd) with auto-commit disabled gives the intended per-pod at-most-once fan-out on .in). No hard functional blockers — the earlier bot findings (topic validation, send-on-closed-channel ownership, explicit type: "pulsar") are already handled in the current head.

One item I'd fix before merge, plus two minor notes.

🔴 Should-fix (security): TLS/SASL config is silently ignored, not "failed loudly"

newKafkaFromConfig only guards SecurityProtocol (kafka.go:137-140). The other security fields — TLSEnabled, TLSCaCertPath, SASLMechanism, SASLUsername, SASLPassword — are parsed into KafkaConfig but never passed to kgo.NewClient, and are not validated. So a config like:

securityProtocol: ""        # or "PLAINTEXT"
saslMechanism: "SCRAM-SHA-256"
saslUsername: "svc"
saslPassword: "..."
tlsEnabled: true

passes validation and connects in plaintext with no auth, silently. An operator who sets credentials but forgets/omits securityProtocol believes the connection is secured when it is not — which is exactly the "silently ignoring security config" failure the securityProtocol guard was added to prevent. Suggest rejecting up front if any SASL/TLS field is populated while enforcement is unimplemented, e.g.:

if kafkaCfg.TLSEnabled || kafkaCfg.SASLMechanism != "" || kafkaCfg.SASLUsername != "" {
    return nil, fmt.Errorf("kafkaConfig TLS/SASL settings are not yet enforced (only PLAINTEXT); SASL/TLS ships in a follow-up")
}

🟡 Minor: buffered .out records can be dropped on shutdown

KafkaMessageProducer.Close() calls client.Close() with no preceding Flush(). Per franz-go, Close() does not flush — buffered records are dropped. Unlike the Pulsar producer (DisableBatching: true, so little is buffered), this client batches (ProducerBatchCompression + 64 MB ProducerBatchMaxBytes), so more in-flight .out (cluster→backend) data is at risk. Compounding it: cmd/server/main.go has no signal handling and http.ListenAndServe blocks, so defer mq.Close() effectively never runs on SIGTERM — the process is killed with records still buffered. A p.client.Flush(ctx) before Close() (and, longer term, graceful shutdown on SIGTERM) would make the .out path — which is not at-most-once by design — actually reliable. Pre-existing for the shutdown wiring, but the batching change makes it more visible here.

🟡 Minor: ProducerBatchMaxBytes(int32(maxMessageBytes)) is unclamped

kafkaBrokerByteLimit clamps the broker limit to [100 MB, 1 GB], but the batch-max value is a bare int32(maxMessageBytes). A maxMessageBytes > 2 GiB config overflows int32 to a negative value (broker limit stays clamped at 1 GB, so they also diverge). Unrealistic today, but a small min() guard would make the sizing math robust.

Nice work overall — the design rationale in the comments made the ordering/fan-out semantics easy to verify.

@harshitg927

Copy link
Copy Markdown
Contributor Author

@matthyx thanks, all three fixed.

TLS/SASL - you're right, guarding only securityProtocol left the exact hole it was meant to close. Now rejects any populated TLS/SASL field too, with tests per field.

Flush - confirmed in the franz-go source that Close() fails buffered records rather than flushing, so added a bounded Flush() first. Also wired up SIGTERM handling in main.go, since without it the deferred Close() never runs and the flush does nothing. That part touches the shared serving path (helps Pulsar too) - happy to split it out if you'd rather keep this PR Kafka-only.

int32 clamp - done, and applied to FetchMaxBytes as well since it had the same problem.

Build, vet, and the Kafka + Pulsar suites all pass. I will open a follow-up PR with SASL/TLS enforcement when this one is merged.

Signed-off-by: Harshit Gandhi <gandhiharshit716@gmail.com>
Signed-off-by: Harshit Gandhi <gandhiharshit716@gmail.com>
…utdown

Signed-off-by: Harshit Gandhi <gandhiharshit716@gmail.com>
@harshitg927
harshitg927 force-pushed the feat/kafka-backend-phase2 branch from 56390f1 to 3af32ab Compare July 20, 2026 10:03

@matthyx matthyx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — all three points addressed ✅

Verified the follow-up commit (fix(backend): reject unenforced kafka TLS/SASL config and flush on shutdown):

  • 🔴 Security (was the blocking one): newKafkaFromConfig now rejects any populated TLSEnabled / TLSCaCertPath / SASLMechanism / SASLUsername / SASLPassword with a clear "not yet enforced" error, alongside the existing securityProtocol guard — no more silent plaintext. Covered by the six new table cases in factory_test.go. Resolved.
  • 🟡 Flush on shutdown: KafkaMessageProducer.Close() now Flush()es under a bounded 30s context before client.Close(), logging on failure. Correct pattern for franz-go. Resolved.
  • 🟡 int32 overflow: new kafkaRecordByteLimit clamps to kafkaMaxBrokerBytes (1 GiB) before the int32 cast, and it's applied consistently to both ProducerBatchMaxBytes and the reader's FetchMaxBytes. Resolved.

No further blockers from my side. LGTM. (The graceful-shutdown / SIGTERM wiring in cmd/server/main.go remains a separate, pre-existing item — out of scope for this PR.)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/server/main.go`:
- Around line 124-140: Synchronize the signal-handling goroutine with main so
the process waits for srv.Shutdown to return before exiting and running deferred
cleanup; add a completion channel around the existing shutdown goroutine and
await it after ListenAndServe returns. In the ListenAndServe error path, return
immediately for real errors instead of waiting for a signal that cannot arrive,
while preserving normal shutdown handling for http.ErrServerClosed.
- Around line 85-87: Update the http.Server initialization in main to set a
finite ReadHeaderTimeout appropriate for the internet-facing service, while
preserving the existing Addr and Handler configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f13924df-ff34-4888-aab0-087fe9185199

📥 Commits

Reviewing files that changed from the base of the PR and between 56390f1 and 3af32ab.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (7)
  • adapters/backend/v1/factory.go
  • adapters/backend/v1/factory_test.go
  • adapters/backend/v1/kafka.go
  • adapters/backend/v1/kafka_integration_test.go
  • cmd/server/main.go
  • config/config.go
  • go.mod
🚧 Files skipped from review as they are similar to previous changes (5)
  • adapters/backend/v1/factory_test.go
  • config/config.go
  • adapters/backend/v1/factory.go
  • go.mod
  • adapters/backend/v1/kafka_integration_test.go

Comment thread cmd/server/main.go
Comment thread cmd/server/main.go
@matthyx matthyx moved this to Needs Reviewer in KS PRs tracking Jul 20, 2026

@matthyx matthyx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Triage of the bot findings on the new main.go shutdown path

I re-verified the outstanding automated comments. One is a real blocker, one is worth doing with a caveat, and one earlier finding was a false positive that was correctly rejected.

🔴 Confirmed blocker — main.go: nothing waits for srv.Shutdown to return

The Critical finding on the shutdown race is correct, and it matters more here than the generic version of this advice, because it defeats the Flush()-on-close fix added in this same commit:

  1. Signal arrives → the goroutine calls srv.Shutdown(shutdownCtx).
  2. Shutdown makes srv.ListenAndServe() in main return http.ErrServerClosed immediately.
  3. main falls out of the if, returns, and runs defer mq.Close() / defer cancel().
  4. Process exits — while the shutdown goroutine is still draining, and while websocket handlers may still be producing records.

So mq.Close() can flush while connections are still live and producing, and the 30s shutdownTimeout is never actually honoured. This is still an improvement over the old _ = http.ListenAndServe(...) (the deferred close now runs at all), but the drain is cut short. Needs a done-channel so main blocks until Shutdown returns — and, as the bot notes, an early return if ListenAndServe fails with a real startup error (e.g. port in use), otherwise you deadlock waiting for a signal that never comes.

Ordering worth getting right while you're in there: drain HTTP → then mq.Close() (flush). Right now the defer ordering makes that racy rather than sequential.

🟡 ReadHeaderTimeout — worth adding, but only that one

Reasonable hardening for a listener clusters dial into from outside. One caveat, since it's easy to over-apply: add ReadHeaderTimeout only — do not add ReadTimeout or WriteTimeout to this server. Those bound the whole request lifetime and would tear down the long-lived websocket connections this service exists to hold open. ReadHeaderTimeout is the websocket-safe one because it bounds header reads only.

✅ Correctly rejected — the config.go topic-direction comment

For the record, the earlier suggestion to flip the ProducerTopic/ConsumerTopic flow comments was wrong, and not applying it was the right call. The synchronizer server sits between the clusters and the backend, so when it produces it is forwarding cluster→backend (.out); when it reads it is receiving backend→cluster (.in). The suggested diff also contradicted the design proposal by relabelling .in as cluster→backend. The current wording ("server produces here…", "server consumes here…") removes the ambiguity that tripped the bot without inverting the meaning — good resolution.

The pollRecords nit was also fairly rebutted: franz-go injects a fake fetch carrying ctx.Err() on poll-context cancellation, so require.NoError(t, fetches.Err()) fails the test with a clear error rather than busy-looping.

@matthyx matthyx moved this from Needs Reviewer to Waiting on Author in KS PRs tracking Jul 20, 2026
…aderTimeout

Signed-off-by: Harshit Gandhi <gandhiharshit716@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cmd/server/main.go (1)

129-150: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Track WebSocket sessions during shutdown. srv.Shutdown does not wait for hijacked connections, so the detached WebSocket goroutines can keep using the adapter/message queue after shutdownDone unblocks. Track active sessions and close/cancel them before waiting for shutdown.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/server/main.go` around lines 129 - 150, Update the shutdown flow around
srv.Shutdown and shutdownDone to track active WebSocket sessions, then close or
cancel every tracked session before shutdown completion is signaled. Ensure
detached WebSocket goroutines have stopped using the adapter/message queue
before the existing `<-shutdownDone` wait returns, integrating the tracking with
the session lifecycle rather than only HTTP server shutdown.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@cmd/server/main.go`:
- Around line 129-150: Update the shutdown flow around srv.Shutdown and
shutdownDone to track active WebSocket sessions, then close or cancel every
tracked session before shutdown completion is signaled. Ensure detached
WebSocket goroutines have stopped using the adapter/message queue before the
existing `<-shutdownDone` wait returns, integrating the tracking with the
session lifecycle rather than only HTTP server shutdown.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bcfd3009-7561-4c60-9344-457f529f3063

📥 Commits

Reviewing files that changed from the base of the PR and between 3af32ab and 75c258e.

📒 Files selected for processing (1)
  • cmd/server/main.go

@matthyx matthyx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — shutdown race resolved ✅

Verified fix(server): block on graceful shutdown before cleanup and add ReadHeaderTimeout by tracing both exit paths and building the branch locally.

Shutdown race — fixed correctly. Both paths check out:

  • Signal path: signal → srv.Shutdown()ListenAndServe() returns ErrServerClosed → the if does not fire, so main falls through to <-shutdownDone and blocks until the goroutine's Shutdown actually returns. The 30s shutdownTimeout is now genuinely honoured.
  • Startup-failure path: a real ListenAndServe error logs and returns before <-shutdownDone, so there's no deadlock waiting on a signal that never arrives. That was the easy trap here and it's handled.

Cleanup ordering is right too — worth calling out since it's what the whole thread was about. defer cancel() is registered before defer mq.Close(), so LIFO gives: drain HTTP → mq.Close() (flush buffered producer records) → cancel(). The flush now happens after connections are drained and before the reader context is torn down, which is the sequence that makes the earlier Flush() fix actually meaningful.

ReadHeaderTimeout — 10s, header-only, and correctly scoped: ReadTimeout/WriteTimeout were not added, so long-lived websocket connections are unaffected. The code comment says exactly that, which should stop someone "helpfully" adding the other two later.

Local verification on the PR head:

go build ./...                                        # ok
go vet ./cmd/... ./adapters/backend/... ./config/...   # ok
go test -short ./adapters/backend/... ./config/...     # ok

No blockers remaining from my side. LGTM — nice iteration on this.

@harshitg927

Copy link
Copy Markdown
Contributor Author

@matthyx thanks, I will open a follow up PR adding SASL/TLS once this one is merged.

@matthyx
matthyx merged commit 2d16302 into kubescape:main Jul 21, 2026
7 of 24 checks passed
@matthyx matthyx moved this from Waiting on Author to To Archive in KS PRs tracking Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants