From f07f2f1c77904f2c6ae05818e75ad0346a8dcf6d Mon Sep 17 00:00:00 2001 From: v-byte-cpu <65545655+v-byte-cpu@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:01:35 +0400 Subject: [PATCH] feat: add shared service infrastructure modules Add reusable build metadata, retry, gRPC, Kafka, transactional outbox, JWT authentication, OIDC, and Redis session modules. Extend OpenAPI authentication and PostgreSQL transaction inspection, then register all modules in workspace, CI, and release automation. --- .gitattributes | 1 + .github/scripts/compute_release.py | 14 + .github/scripts/tests/test_compute_release.py | 24 + .github/workflows/ci.yml | 91 +++- .github/workflows/preview-release.yml | 10 +- .github/workflows/release.yml | 7 +- .gitignore | 2 + README.md | 33 +- RELEASING.md | 13 +- buildinfo/buildinfo.go | 62 +++ buildinfo/buildinfo_test.go | 37 ++ buildinfo/doc.go | 6 + buildinfo/example_test.go | 20 + buildinfo/go.mod | 11 + buildinfo/go.sum | 10 + buildinfo/mapping_test.go | 76 +++ go.work | 21 + go.work.sum | 71 ++- grpcclient/README.md | 23 + grpcclient/client.go | 185 +++++++ grpcclient/client_test.go | 177 +++++++ grpcclient/doc.go | 2 + grpcclient/go.mod | 29 ++ grpcclient/go.sum | 60 +++ grpcserver/README.md | 31 ++ grpcserver/doc.go | 2 + grpcserver/go.mod | 45 ++ grpcserver/go.sum | 94 ++++ grpcserver/mocks/panic_observer.gen.go | 54 +++ grpcserver/panic.go | 70 +++ grpcserver/server.go | 231 +++++++++ grpcserver/server_test.go | 301 ++++++++++++ grpcserver/telemetry.go | 68 +++ grpcserver/tls.go | 36 ++ grpczap/README.md | 23 + grpczap/adapter.go | 172 +++++++ grpczap/adapter_test.go | 226 +++++++++ grpczap/doc.go | 2 + grpczap/go.mod | 39 ++ grpczap/go.sum | 94 ++++ healthgrpc/README.md | 22 + healthgrpc/doc.go | 2 + healthgrpc/go.mod | 20 + healthgrpc/go.sum | 50 ++ healthgrpc/server.go | 222 +++++++++ healthgrpc/server_test.go | 154 ++++++ healthgrpc/watch_test.go | 123 +++++ kafka/README.md | 173 +++++++ kafka/batch.go | 83 ++++ kafka/batch_test.go | 51 ++ kafka/codec.go | 61 +++ kafka/codec_test.go | 67 +++ kafka/config.go | 170 +++++++ kafka/config_test.go | 111 +++++ kafka/consumer.go | 362 ++++++++++++++ kafka/consumer_client.gen_test.go | 238 +++++++++ kafka/consumer_client.go | 22 + kafka/consumer_test.go | 459 ++++++++++++++++++ kafka/contracts.gen_test.go | 205 ++++++++ kafka/dlq.go | 79 +++ kafka/doc.go | 2 + kafka/errors.go | 39 ++ kafka/go.mod | 36 ++ kafka/go.sum | 66 +++ kafka/handler.go | 54 +++ kafka/integration_test.go | 48 ++ kafka/message.go | 22 + kafka/observer.go | 180 +++++++ kafka/observer_test.go | 46 ++ kafka/otel.go | 228 +++++++++ kafka/otel_test.go | 57 +++ kafka/partitioned_consumer.go | 281 +++++++++++ kafka/partitioned_consumer_test.go | 114 +++++ kafka/producer.go | 236 +++++++++ kafka/producer_client.gen_test.go | 121 +++++ kafka/producer_client.go | 16 + kafka/producer_test.go | 92 ++++ kafka/real_kafka_integration_test.go | 41 ++ kafkaoutbox/README.md | 168 +++++++ kafkaoutbox/cdc_integration_test.go | 126 +++++ kafkaoutbox/cdc_store.go | 49 ++ kafkaoutbox/contracts.gen_test.go | 385 +++++++++++++++ kafkaoutbox/contracts.go | 31 ++ kafkaoutbox/encoder.gen_test.go | 80 +++ kafkaoutbox/enqueuer.go | 95 ++++ kafkaoutbox/enqueuer_test.go | 156 ++++++ kafkaoutbox/go.mod | 85 ++++ kafkaoutbox/go.sum | 188 +++++++ kafkaoutbox/migrations.go | 27 ++ .../migrations/cdc/0001_events.down.sql | 2 + kafkaoutbox/migrations/cdc/0001_events.up.sql | 13 + .../migrations/polling/0001_events.down.sql | 2 + .../migrations/polling/0001_events.up.sql | 16 + .../migrations/polling/0002_topology.down.sql | 3 + .../migrations/polling/0002_topology.up.sql | 24 + kafkaoutbox/observer.go | 168 +++++++ kafkaoutbox/otel.go | 240 +++++++++ kafkaoutbox/otel_test.go | 94 ++++ kafkaoutbox/partition_manager.go | 164 +++++++ kafkaoutbox/store.go | 67 +++ kafkaoutbox/store_integration_test.go | 155 ++++++ kafkaoutbox/testdata/debezium-postgres.json | 20 + kafkaoutbox/topology_integration_test.go | 96 ++++ kafkaoutbox/trace_context.go | 37 ++ kafkaoutbox/worker.go | 385 +++++++++++++++ kafkaoutbox/worker_integration_test.go | 441 +++++++++++++++++ kafkaoutbox/worker_store.go | 377 ++++++++++++++ kafkaoutboxzap/go.mod | 43 ++ kafkaoutboxzap/go.sum | 165 +++++++ kafkaoutboxzap/observer.go | 75 +++ kafkaoutboxzap/observer_test.go | 48 ++ kafkaproto/codec.go | 49 ++ kafkaproto/codec_test.go | 45 ++ kafkaproto/go.mod | 28 ++ kafkaproto/go.sum | 60 +++ kafkazap/go.mod | 29 ++ kafkazap/go.sum | 66 +++ kafkazap/observer.go | 64 +++ kafkazap/observer_test.go | 49 ++ mise.toml | 16 +- oapivalidator/authentication.go | 3 + oapivalidator/authentication_test.go | 39 +- oapivalidator/failure.go | 38 +- oapivalidator/normalize.go | 7 +- oapivalidatorjwt/README.md | 38 ++ oapivalidatorjwt/authenticator.go | 230 +++++++++ oapivalidatorjwt/authenticator_test.go | 272 +++++++++++ oapivalidatorjwt/csrf.go | 82 ++++ oapivalidatorjwt/doc.go | 2 + oapivalidatorjwt/go.mod | 24 + oapivalidatorjwt/go.sum | 51 ++ oidcsession/README.md | 30 ++ oidcsession/callback_test.go | 141 ++++++ oidcsession/contracts.go | 68 +++ oidcsession/doc.go | 6 + oidcsession/encryptor.go | 135 ++++++ oidcsession/encryptor_test.go | 72 +++ oidcsession/errors.go | 14 + oidcsession/go.mod | 24 + oidcsession/go.sum | 28 ++ oidcsession/handler_adapter_test.go | 148 ++++++ oidcsession/handlers.go | 350 +++++++++++++ oidcsession/handlers_test.go | 150 ++++++ oidcsession/internal_contracts.gen_test.go | 337 +++++++++++++ oidcsession/internal_contracts.go | 29 ++ oidcsession/internal_dependencies.gen_test.go | 298 ++++++++++++ oidcsession/mocks/interfaces.gen.go | 208 ++++++++ oidcsession/observer.go | 46 ++ oidcsession/provider.go | 238 +++++++++ oidcsession/provider_flow.go | 82 ++++ oidcsession/provider_test.go | 93 ++++ oidcsession/service.go | 168 +++++++ oidcsession/service_test.go | 84 ++++ oidcsession/state.go | 126 +++++ oidcsession/test_helpers_test.go | 22 + oidcsessionredis/README.md | 18 + oidcsessionredis/backend.go | 267 ++++++++++ oidcsessionredis/backend_service_test.go | 106 ++++ oidcsessionredis/backend_test.go | 34 ++ oidcsessionredis/codec.go | 43 ++ oidcsessionredis/credential.go | 32 ++ oidcsessionredis/doc.go | 6 + oidcsessionredis/go.mod | 70 +++ oidcsessionredis/go.sum | 157 ++++++ oidcsessionredis/integration_test.go | 221 +++++++++ oidcsessionredis/redis_store.go | 138 ++++++ oidcsessionredis/scripts.go | 51 ++ oidcsessionredis/store.gen_test.go | 275 +++++++++++ oidcsessionredis/store.go | 59 +++ postgresdb/endpoint.go | 7 + postgresdb/integration_test.go | 3 + retry/doc.go | 2 + retry/go.mod | 11 + retry/go.sum | 10 + retry/retry.go | 201 ++++++++ retry/retry_test.go | 111 +++++ 176 files changed, 16683 insertions(+), 79 deletions(-) create mode 100644 buildinfo/buildinfo.go create mode 100644 buildinfo/buildinfo_test.go create mode 100644 buildinfo/doc.go create mode 100644 buildinfo/example_test.go create mode 100644 buildinfo/go.mod create mode 100644 buildinfo/go.sum create mode 100644 buildinfo/mapping_test.go create mode 100644 grpcclient/README.md create mode 100644 grpcclient/client.go create mode 100644 grpcclient/client_test.go create mode 100644 grpcclient/doc.go create mode 100644 grpcclient/go.mod create mode 100644 grpcclient/go.sum create mode 100644 grpcserver/README.md create mode 100644 grpcserver/doc.go create mode 100644 grpcserver/go.mod create mode 100644 grpcserver/go.sum create mode 100644 grpcserver/mocks/panic_observer.gen.go create mode 100644 grpcserver/panic.go create mode 100644 grpcserver/server.go create mode 100644 grpcserver/server_test.go create mode 100644 grpcserver/telemetry.go create mode 100644 grpcserver/tls.go create mode 100644 grpczap/README.md create mode 100644 grpczap/adapter.go create mode 100644 grpczap/adapter_test.go create mode 100644 grpczap/doc.go create mode 100644 grpczap/go.mod create mode 100644 grpczap/go.sum create mode 100644 healthgrpc/README.md create mode 100644 healthgrpc/doc.go create mode 100644 healthgrpc/go.mod create mode 100644 healthgrpc/go.sum create mode 100644 healthgrpc/server.go create mode 100644 healthgrpc/server_test.go create mode 100644 healthgrpc/watch_test.go create mode 100644 kafka/README.md create mode 100644 kafka/batch.go create mode 100644 kafka/batch_test.go create mode 100644 kafka/codec.go create mode 100644 kafka/codec_test.go create mode 100644 kafka/config.go create mode 100644 kafka/config_test.go create mode 100644 kafka/consumer.go create mode 100644 kafka/consumer_client.gen_test.go create mode 100644 kafka/consumer_client.go create mode 100644 kafka/consumer_test.go create mode 100644 kafka/contracts.gen_test.go create mode 100644 kafka/dlq.go create mode 100644 kafka/doc.go create mode 100644 kafka/errors.go create mode 100644 kafka/go.mod create mode 100644 kafka/go.sum create mode 100644 kafka/handler.go create mode 100644 kafka/integration_test.go create mode 100644 kafka/message.go create mode 100644 kafka/observer.go create mode 100644 kafka/observer_test.go create mode 100644 kafka/otel.go create mode 100644 kafka/otel_test.go create mode 100644 kafka/partitioned_consumer.go create mode 100644 kafka/partitioned_consumer_test.go create mode 100644 kafka/producer.go create mode 100644 kafka/producer_client.gen_test.go create mode 100644 kafka/producer_client.go create mode 100644 kafka/producer_test.go create mode 100644 kafka/real_kafka_integration_test.go create mode 100644 kafkaoutbox/README.md create mode 100644 kafkaoutbox/cdc_integration_test.go create mode 100644 kafkaoutbox/cdc_store.go create mode 100644 kafkaoutbox/contracts.gen_test.go create mode 100644 kafkaoutbox/contracts.go create mode 100644 kafkaoutbox/encoder.gen_test.go create mode 100644 kafkaoutbox/enqueuer.go create mode 100644 kafkaoutbox/enqueuer_test.go create mode 100644 kafkaoutbox/go.mod create mode 100644 kafkaoutbox/go.sum create mode 100644 kafkaoutbox/migrations.go create mode 100644 kafkaoutbox/migrations/cdc/0001_events.down.sql create mode 100644 kafkaoutbox/migrations/cdc/0001_events.up.sql create mode 100644 kafkaoutbox/migrations/polling/0001_events.down.sql create mode 100644 kafkaoutbox/migrations/polling/0001_events.up.sql create mode 100644 kafkaoutbox/migrations/polling/0002_topology.down.sql create mode 100644 kafkaoutbox/migrations/polling/0002_topology.up.sql create mode 100644 kafkaoutbox/observer.go create mode 100644 kafkaoutbox/otel.go create mode 100644 kafkaoutbox/otel_test.go create mode 100644 kafkaoutbox/partition_manager.go create mode 100644 kafkaoutbox/store.go create mode 100644 kafkaoutbox/store_integration_test.go create mode 100644 kafkaoutbox/testdata/debezium-postgres.json create mode 100644 kafkaoutbox/topology_integration_test.go create mode 100644 kafkaoutbox/trace_context.go create mode 100644 kafkaoutbox/worker.go create mode 100644 kafkaoutbox/worker_integration_test.go create mode 100644 kafkaoutbox/worker_store.go create mode 100644 kafkaoutboxzap/go.mod create mode 100644 kafkaoutboxzap/go.sum create mode 100644 kafkaoutboxzap/observer.go create mode 100644 kafkaoutboxzap/observer_test.go create mode 100644 kafkaproto/codec.go create mode 100644 kafkaproto/codec_test.go create mode 100644 kafkaproto/go.mod create mode 100644 kafkaproto/go.sum create mode 100644 kafkazap/go.mod create mode 100644 kafkazap/go.sum create mode 100644 kafkazap/observer.go create mode 100644 kafkazap/observer_test.go create mode 100644 oapivalidatorjwt/README.md create mode 100644 oapivalidatorjwt/authenticator.go create mode 100644 oapivalidatorjwt/authenticator_test.go create mode 100644 oapivalidatorjwt/csrf.go create mode 100644 oapivalidatorjwt/doc.go create mode 100644 oapivalidatorjwt/go.mod create mode 100644 oapivalidatorjwt/go.sum create mode 100644 oidcsession/README.md create mode 100644 oidcsession/callback_test.go create mode 100644 oidcsession/contracts.go create mode 100644 oidcsession/doc.go create mode 100644 oidcsession/encryptor.go create mode 100644 oidcsession/encryptor_test.go create mode 100644 oidcsession/errors.go create mode 100644 oidcsession/go.mod create mode 100644 oidcsession/go.sum create mode 100644 oidcsession/handler_adapter_test.go create mode 100644 oidcsession/handlers.go create mode 100644 oidcsession/handlers_test.go create mode 100644 oidcsession/internal_contracts.gen_test.go create mode 100644 oidcsession/internal_contracts.go create mode 100644 oidcsession/internal_dependencies.gen_test.go create mode 100644 oidcsession/mocks/interfaces.gen.go create mode 100644 oidcsession/observer.go create mode 100644 oidcsession/provider.go create mode 100644 oidcsession/provider_flow.go create mode 100644 oidcsession/provider_test.go create mode 100644 oidcsession/service.go create mode 100644 oidcsession/service_test.go create mode 100644 oidcsession/state.go create mode 100644 oidcsession/test_helpers_test.go create mode 100644 oidcsessionredis/README.md create mode 100644 oidcsessionredis/backend.go create mode 100644 oidcsessionredis/backend_service_test.go create mode 100644 oidcsessionredis/backend_test.go create mode 100644 oidcsessionredis/codec.go create mode 100644 oidcsessionredis/credential.go create mode 100644 oidcsessionredis/doc.go create mode 100644 oidcsessionredis/go.mod create mode 100644 oidcsessionredis/go.sum create mode 100644 oidcsessionredis/integration_test.go create mode 100644 oidcsessionredis/redis_store.go create mode 100644 oidcsessionredis/scripts.go create mode 100644 oidcsessionredis/store.gen_test.go create mode 100644 oidcsessionredis/store.go create mode 100644 retry/doc.go create mode 100644 retry/go.mod create mode 100644 retry/go.sum create mode 100644 retry/retry.go create mode 100644 retry/retry_test.go diff --git a/.gitattributes b/.gitattributes index 5f7036b..be365ff 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ *.gen.go -diff linguist-generated=true +*.gen_test.go -diff linguist-generated=true *.gen.json -diff linguist-generated=true **/mocks/** -diff linguist-generated=true diff --git a/.github/scripts/compute_release.py b/.github/scripts/compute_release.py index b3a63c2..b6a067f 100644 --- a/.github/scripts/compute_release.py +++ b/.github/scripts/compute_release.py @@ -13,19 +13,33 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[2] MODULES = ( + "buildinfo", "codexapp", "config", "debugserver", "di", "filesystem", + "grpcclient", + "grpcserver", + "grpczap", "health", + "healthgrpc", "healthotel", "healthserver", "healthzap", + "kafka", + "kafkaproto", + "kafkazap", + "kafkaoutbox", + "kafkaoutboxzap", "lifecycle", "log", "oapivalidator", + "oapivalidatorjwt", + "oidcsession", + "oidcsessionredis", "postgresdb", + "retry", "sqlitedb", "telemetry", "txmanager", diff --git a/.github/scripts/tests/test_compute_release.py b/.github/scripts/tests/test_compute_release.py index ecce0ff..46a2e3d 100644 --- a/.github/scripts/tests/test_compute_release.py +++ b/.github/scripts/tests/test_compute_release.py @@ -11,6 +11,30 @@ def completed(stdout: str = "", returncode: int = 0, stderr: str = "") -> subpro class ReleaseVersionTests(unittest.TestCase): + def test_supports_buildinfo_module(self) -> None: + previous, next_tag = compute_release("buildinfo", "minor", lambda _: completed()) + self.assertEqual("", previous) + self.assertEqual("buildinfo/v0.1.0", next_tag) + + def test_supports_oidc_session_redis_module(self) -> None: + previous, next_tag = compute_release("oidcsessionredis", "minor", lambda _: completed()) + self.assertEqual("", previous) + self.assertEqual("oidcsessionredis/v0.1.0", next_tag) + + def test_supports_grpc_modules(self) -> None: + for module in ("grpcclient", "grpcserver", "grpczap", "healthgrpc"): + with self.subTest(module=module): + previous, next_tag = compute_release(module, "minor", lambda _: completed()) + self.assertEqual("", previous) + self.assertEqual(f"{module}/v0.1.0", next_tag) + + def test_supports_kafka_modules(self) -> None: + for module in ("kafka", "kafkaproto", "kafkazap", "kafkaoutbox", "kafkaoutboxzap"): + with self.subTest(module=module): + previous, next_tag = compute_release(module, "minor", lambda _: completed()) + self.assertEqual("", previous) + self.assertEqual(f"{module}/v0.1.0", next_tag) + def test_initial_minor_release_is_v0_1_0(self) -> None: previous, next_tag = compute_release("health", "minor", lambda _: completed()) self.assertEqual("", previous) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f5573f..eda7e13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,14 @@ on: description: Run PostgreSQL integration tests regardless of changed paths. type: boolean default: false + run_redis_integration: + description: Run Redis session integration tests regardless of changed paths. + type: boolean + default: false + run_kafkaoutbox_integration: + description: Run Kafka outbox integration tests regardless of changed paths. + type: boolean + default: false concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -30,11 +38,13 @@ jobs: outputs: go_ci: ${{ steps.filter.outputs.go_ci }} postgres: ${{ steps.filter.outputs.postgres }} + redis: ${{ steps.filter.outputs.redis }} + kafkaoutbox: ${{ steps.filter.outputs.kafkaoutbox }} steps: - uses: actions/checkout@v6 - name: Detect changed paths id: filter - if: ${{ !inputs.force_go_ci && !inputs.run_postgres_integration }} + if: ${{ !inputs.force_go_ci && !inputs.run_postgres_integration && !inputs.run_redis_integration && !inputs.run_kafkaoutbox_integration }} uses: dorny/paths-filter@v4 with: filters: | @@ -55,6 +65,21 @@ jobs: - 'go.work' - 'go.work.sum' - '.github/workflows/ci.yml' + redis: + - 'oidcsession/**' + - 'oidcsessionredis/**' + - 'go.work' + - 'go.work.sum' + - '.github/workflows/ci.yml' + kafkaoutbox: + - 'kafkaoutbox/**' + - 'kafka/**' + - 'postgresdb/**' + - 'txmanager/**' + - 'retry/**' + - 'go.work' + - 'go.work.sum' + - '.github/workflows/ci.yml' quality: needs: changes @@ -117,9 +142,67 @@ jobs: - name: Run PostgreSQL integration tests run: mise run postgresdb:test-integration + redis-integration: + needs: changes + if: inputs.run_redis_integration || needs.changes.outputs.redis == 'true' + runs-on: ubuntu-latest + env: + MISE_TASK_RUN_AUTO_INSTALL: "false" + steps: + - uses: actions/checkout@v6 + - uses: jdx/mise-action@v4 + with: + install_args: go + - name: Resolve Go cache + id: go-cache + run: | + echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" + echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + echo "version=$(go env GOVERSION)" >> "$GITHUB_OUTPUT" + - name: Restore Go caches + uses: actions/cache@v6 + with: + path: | + ${{ steps.go-cache.outputs.build }} + ${{ steps.go-cache.outputs.modules }} + key: redis-go-${{ runner.os }}-${{ runner.arch }}-${{ steps.go-cache.outputs.version }}-${{ hashFiles('go.work', 'go.work.sum', '**/go.mod', '**/go.sum') }} + restore-keys: | + redis-go-${{ runner.os }}-${{ runner.arch }}-${{ steps.go-cache.outputs.version }}- + - name: Run Redis session integration tests + run: mise run oidcsessionredis:test-integration + + kafkaoutbox-integration: + needs: changes + if: inputs.run_kafkaoutbox_integration || needs.changes.outputs.kafkaoutbox == 'true' + runs-on: ubuntu-latest + env: + MISE_TASK_RUN_AUTO_INSTALL: "false" + steps: + - uses: actions/checkout@v6 + - uses: jdx/mise-action@v4 + with: + install_args: go + - name: Resolve Go cache + id: go-cache + run: | + echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" + echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + echo "version=$(go env GOVERSION)" >> "$GITHUB_OUTPUT" + - name: Restore Go caches + uses: actions/cache@v6 + with: + path: | + ${{ steps.go-cache.outputs.build }} + ${{ steps.go-cache.outputs.modules }} + key: kafkaoutbox-go-${{ runner.os }}-${{ runner.arch }}-${{ steps.go-cache.outputs.version }}-${{ hashFiles('go.work', 'go.work.sum', '**/go.mod', '**/go.sum') }} + restore-keys: | + kafkaoutbox-go-${{ runner.os }}-${{ runner.arch }}-${{ steps.go-cache.outputs.version }}- + - name: Run Kafka outbox integration tests + run: mise run kafkaoutbox:test-integration + gate: if: always() - needs: [changes, quality, postgres-integration] + needs: [changes, quality, postgres-integration, redis-integration, kafkaoutbox-integration] runs-on: ubuntu-latest steps: - name: Verify CI result @@ -127,8 +210,10 @@ jobs: CHANGES_RESULT: ${{ needs.changes.result }} QUALITY_RESULT: ${{ needs.quality.result }} POSTGRES_RESULT: ${{ needs.postgres-integration.result }} + REDIS_RESULT: ${{ needs.redis-integration.result }} + KAFKAOUTBOX_RESULT: ${{ needs.kafkaoutbox-integration.result }} run: | - for result in "$CHANGES_RESULT" "$QUALITY_RESULT" "$POSTGRES_RESULT"; do + for result in "$CHANGES_RESULT" "$QUALITY_RESULT" "$POSTGRES_RESULT" "$REDIS_RESULT" "$KAFKAOUTBOX_RESULT"; do case "$result" in success|skipped) ;; *) exit 1 ;; diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml index 167c736..e9f36b2 100644 --- a/.github/workflows/preview-release.yml +++ b/.github/workflows/preview-release.yml @@ -7,7 +7,7 @@ on: description: Module to preview. required: true type: choice - options: [codexapp, config, debugserver, di, filesystem, health, healthotel, healthserver, healthzap, lifecycle, log, oapivalidator, postgresdb, sqlitedb, telemetry, txmanager] + options: [buildinfo, codexapp, config, debugserver, di, filesystem, grpcclient, grpcserver, grpczap, health, healthgrpc, healthotel, healthserver, healthzap, kafka, kafkaproto, kafkazap, kafkaoutbox, kafkaoutboxzap, lifecycle, log, oapivalidator, oapivalidatorjwt, oidcsession, oidcsessionredis, postgresdb, retry, sqlitedb, telemetry, txmanager] bump: description: Stable semantic-version increment. required: true @@ -31,6 +31,14 @@ jobs: fetch-depth: 0 fetch-tags: true - uses: jdx/mise-action@v4 + - name: Verify module outside the workspace + run: | + set -euo pipefail + cd "$MODULE" + GOWORK=off go mod tidy -diff + GOWORK=off go mod download + GOWORK=off go test -race ./... + git diff --exit-code -- go.mod go.sum - name: Compute release id: release run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f00003..769c1bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: description: Module to release. required: true type: choice - options: [codexapp, config, debugserver, di, filesystem, health, healthotel, healthserver, healthzap, lifecycle, log, oapivalidator, postgresdb, sqlitedb, telemetry, txmanager] + options: [buildinfo, codexapp, config, debugserver, di, filesystem, grpcclient, grpcserver, grpczap, health, healthgrpc, healthotel, healthserver, healthzap, kafka, kafkaproto, kafkazap, kafkaoutbox, kafkaoutboxzap, lifecycle, log, oapivalidator, oapivalidatorjwt, oidcsession, oidcsessionredis, postgresdb, retry, sqlitedb, telemetry, txmanager] bump: description: Stable semantic-version increment. required: true @@ -39,6 +39,8 @@ jobs: with: force_go_ci: true run_postgres_integration: ${{ inputs.module == 'postgresdb' || inputs.module == 'txmanager' }} + run_redis_integration: ${{ inputs.module == 'oidcsessionredis' }} + run_kafkaoutbox_integration: ${{ inputs.module == 'kafkaoutbox' }} publish: needs: validate @@ -63,9 +65,12 @@ jobs: echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" - name: Verify module outside the workspace run: | + set -euo pipefail cd "$MODULE" + GOWORK=off go mod tidy -diff GOWORK=off go mod download GOWORK=off go test -race ./... + git diff --exit-code -- go.mod go.sum - name: Generate release notes env: NEXT_TAG: ${{ steps.release.outputs.next_tag }} diff --git a/.gitignore b/.gitignore index 2077952..b2bef69 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ bin/ *.so *.dylib +.idea/ + # Go test and coverage artifacts *.test *.out diff --git a/README.md b/README.md index 159f493..dd8fe58 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,13 @@ go get github.com/devctllabs/go-libs/@latest | [`config`](config) | Ordered, composable configuration loaders for defaults, files, dotenv data, and environment variables. | | [`di`](di) | A small, type-safe dependency container with explicit resource ownership and shutdown. | | [`lifecycle`](lifecycle) | Coordination for long-running tasks and graceful shutdown. | +| [`retry`](retry) | Explicit context-aware retry loops and capped exponential backoff. | ### Operations and observability | Module | Description | | --- | --- | +| [`buildinfo`](buildinfo) | Build metadata embedded in the current Go executable. | | [`debugserver`](debugserver) | A standalone HTTP server for Go pprof endpoints. | | [`log`](log) | Production JSON zap logger construction without global logger state. | | [`telemetry`](telemetry) | Instance-owned OpenTelemetry trace and metric providers for Go services. | @@ -37,20 +39,47 @@ go get github.com/devctllabs/go-libs/@latest | Module | Description | | --- | --- | | [`health`](health) | Transport-neutral liveness and readiness probes. | +| [`healthgrpc`](healthgrpc) | Standard gRPC Health service backed by transport-neutral probes. | | [`healthotel`](healthotel) | OpenTelemetry metrics for health check observations. | | [`healthserver`](healthserver) | OpenAPI-generated Echo endpoints for liveness and readiness probes. | | [`healthzap`](healthzap) | Structured zap logging for health check failures and recoveries. | +### gRPC + +| Module | Description | +| --- | --- | +| [`grpcclient`](grpcclient) | Explicit gRPC client connections with file-backed TLS, interceptor chains, and opt-in OpenTelemetry. | +| [`grpcserver`](grpcserver) | Application-owned gRPC runtime with validation, panic recovery, reflection, TLS, and graceful shutdown. | +| [`grpczap`](grpczap) | Completion and recovered-panic logging adapters for zap. | + +### Messaging + +| Module | Description | +| --- | --- | +| [`kafka`](kafka) | Typed franz-go producer and batching consumer runtimes with JSON codecs, retry, reject/DLQ policy, and OpenTelemetry. | +| [`kafkaproto`](kafkaproto) | Protobuf encoders and fresh-message decoders for Kafka values. | +| [`kafkazap`](kafkazap) | Structured zap logging for Kafka consumer retries and dispositions. | +| [`kafkaoutbox`](kafkaoutbox) | PostgreSQL transactional outbox with polling virtual shards or Debezium CDC delivery. | +| [`kafkaoutboxzap`](kafkaoutboxzap) | Structured zap logging for outbox retries, fencing, and topology changes. | + ### Data and infrastructure | Module | Description | | --- | --- | | [`filesystem`](filesystem) | Rooted filesystem operations that compose with the standard `io/fs` package. | -| [`oapivalidator`](oapivalidator) | OpenAPI request validation middleware for Echo. | | [`postgresdb`](postgresdb) | Instrumented pgx reader and writer pools for PostgreSQL. | | [`sqlitedb`](sqlitedb) | Instrumented SQLite reader and writer endpoints. | | [`txmanager`](txmanager) | Shared transaction boundaries for services and database adapters. | +### Identity and API security + +| Module | Description | +| --- | --- | +| [`oapivalidator`](oapivalidator) | OpenAPI request validation and authentication middleware for Echo. | +| [`oapivalidatorjwt`](oapivalidatorjwt) | JWT bearer and cookie authenticator for `oapivalidator`. | +| [`oidcsession`](oidcsession) | OIDC provider lifecycle, encrypted login state, and browser session HTTP flows. | +| [`oidcsessionredis`](oidcsessionredis) | Redis-backed opaque refresh sessions with encrypted provider tokens. | + ### Codex integration | Module | Description | @@ -72,6 +101,8 @@ Run the PostgreSQL integration suite separately when changing `postgresdb` or `t ```sh mise run postgresdb:test-integration +mise run oidcsessionredis:test-integration +mise run kafkaoutbox:test-integration ``` ## Releases diff --git a/RELEASING.md b/RELEASING.md index d21d006..ce8027d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,12 +8,19 @@ Protect `main`, require the `CI / gate` and `Commit checks / commitlint` checks, ## Bootstrap order -Release `health` and `txmanager` before the modules that depend on them: +Release shared modules before the modules that depend on them: -- `health` before `healthotel`, `healthserver`, and `healthzap`. +- `grpcserver` before `grpczap`. +- `health` before `healthgrpc`, `healthotel`, `healthserver`, and `healthzap`. - `txmanager` before `postgresdb` and `sqlitedb`. +- `oapivalidator` before `oapivalidatorjwt`. +- `retry` before `oidcsession`, and `oidcsession` before `oidcsessionredis`. +- `retry` before `kafka`, and `kafka` before `kafkaproto` and `kafkazap`. +- `kafka`, `postgresdb`, and `retry` before `kafkaoutbox`, then `kafkaoutbox` before `kafkaoutboxzap`. -Workspace replacements make local monorepo development possible, but the release workflow tests the selected module with `GOWORK=off`. Therefore a dependent module cannot be published until its declared internal dependency exists publicly. +The workspace `use` directives provide local package sources. Versioned internal requirements that do not have public tags yet are centralized as `go.work` replacements; publishable `go.mod` files do not contain local paths. Preview and release both run `go mod tidy -diff`, download dependencies, and run race-enabled tests with `GOWORK=off`, then verify that `go.mod` and `go.sum` stayed unchanged. + +Changes to a base module and its dependants may land in one pull request because normal CI uses the workspace. Release them in the order above. A dependent preview or release intentionally fails until the base tag exists and its checksum updates have been committed. ## Preview and publish diff --git a/buildinfo/buildinfo.go b/buildinfo/buildinfo.go new file mode 100644 index 0000000..bd4b8cb --- /dev/null +++ b/buildinfo/buildinfo.go @@ -0,0 +1,62 @@ +package buildinfo + +import ( + "runtime/debug" + "time" +) + +const ( + revisionSetting = "vcs.revision" + revisionTimeSetting = "vcs.time" +) + +// Info identifies the main module and source revision of the current executable. +// Fields contain zero values when the corresponding metadata was not embedded. +// RevisionTime is the time associated with Revision, not the build time. +type Info struct { + ModulePath string + Version string + Revision string + RevisionTime time.Time + GoVersion string +} + +// Read returns build metadata embedded in the current executable. +// +// Read performs no filesystem, environment, network, or VCS access. Version is empty when Go +// reports no useful main-module version or "(devel)". Revision is returned without shortening; +// version suffixes such as "+dirty" are preserved. +func Read() Info { + raw, ok := debug.ReadBuildInfo() + if !ok { + return Info{} + } + return fromBuildInfo(raw) +} + +func fromBuildInfo(raw *debug.BuildInfo) Info { + if raw == nil { + return Info{} + } + + info := Info{ + ModulePath: raw.Main.Path, + Version: raw.Main.Version, + GoVersion: raw.GoVersion, + } + if info.Version == "(devel)" { + info.Version = "" + } + + for _, setting := range raw.Settings { + switch setting.Key { + case revisionSetting: + info.Revision = setting.Value + case revisionTimeSetting: + if revisionTime, err := time.Parse(time.RFC3339, setting.Value); err == nil { + info.RevisionTime = revisionTime + } + } + } + return info +} diff --git a/buildinfo/buildinfo_test.go b/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..23ff258 --- /dev/null +++ b/buildinfo/buildinfo_test.go @@ -0,0 +1,37 @@ +package buildinfo_test + +import ( + "runtime/debug" + "testing" + "time" + + "github.com/devctllabs/go-libs/buildinfo" + "github.com/stretchr/testify/require" +) + +func TestReadReturnsEmbeddedBuildInformation(t *testing.T) { + t.Parallel() + raw, ok := debug.ReadBuildInfo() + require.True(t, ok) + + want := buildinfo.Info{ + ModulePath: raw.Main.Path, + Version: raw.Main.Version, + GoVersion: raw.GoVersion, + } + if want.Version == "(devel)" { + want.Version = "" + } + for _, setting := range raw.Settings { + switch setting.Key { + case "vcs.revision": + want.Revision = setting.Value + case "vcs.time": + revisionTime, err := time.Parse(time.RFC3339, setting.Value) + require.NoError(t, err) + want.RevisionTime = revisionTime + } + } + + require.Equal(t, want, buildinfo.Read()) +} diff --git a/buildinfo/doc.go b/buildinfo/doc.go new file mode 100644 index 0000000..c67af1d --- /dev/null +++ b/buildinfo/doc.go @@ -0,0 +1,6 @@ +// Package buildinfo reads build metadata embedded in the current Go executable. +// +// The package reports the main module version, source revision and Go toolchain version without +// reading the environment, filesystem, network or version control system. Applications decide how +// to attach the returned values to logs, telemetry, error reports or management endpoints. +package buildinfo diff --git a/buildinfo/example_test.go b/buildinfo/example_test.go new file mode 100644 index 0000000..8993a07 --- /dev/null +++ b/buildinfo/example_test.go @@ -0,0 +1,20 @@ +package buildinfo_test + +import ( + "log" + "os" + + "github.com/devctllabs/go-libs/buildinfo" +) + +func ExampleRead() { + info := buildinfo.Read() + logger := log.New(os.Stdout, "", 0) + logger.Printf( + "module=%s version=%s revision=%s go_version=%s", + info.ModulePath, + info.Version, + info.Revision, + info.GoVersion, + ) +} diff --git a/buildinfo/go.mod b/buildinfo/go.mod new file mode 100644 index 0000000..5d7f859 --- /dev/null +++ b/buildinfo/go.mod @@ -0,0 +1,11 @@ +module github.com/devctllabs/go-libs/buildinfo + +go 1.25.0 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/buildinfo/go.sum b/buildinfo/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/buildinfo/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/buildinfo/mapping_test.go b/buildinfo/mapping_test.go new file mode 100644 index 0000000..6acdf47 --- /dev/null +++ b/buildinfo/mapping_test.go @@ -0,0 +1,76 @@ +package buildinfo + +import ( + "runtime/debug" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFromBuildInfoReturnsZeroForMissingMetadata(t *testing.T) { + t.Parallel() + + require.Equal(t, Info{}, fromBuildInfo(nil)) +} + +func TestFromBuildInfoMapsModuleAndVCSMetadata(t *testing.T) { + t.Parallel() + wantTime := time.Date(2026, time.August, 20, 11, 20, 30, 123456789, time.UTC) + raw := &debug.BuildInfo{ + GoVersion: "go1.25.0", + Main: debug.Module{ + Path: "example.com/orders", + Version: "v1.4.0+dirty", + }, + Settings: []debug.BuildSetting{ + {Key: revisionSetting, Value: "8f31c2a76f1234567890"}, + {Key: revisionTimeSetting, Value: "2026-08-20T11:20:30.123456789Z"}, + {Key: "unrelated", Value: "ignored"}, + }, + } + + require.Equal(t, Info{ + ModulePath: "example.com/orders", + Version: "v1.4.0+dirty", + Revision: "8f31c2a76f1234567890", + RevisionTime: wantTime, + GoVersion: "go1.25.0", + }, fromBuildInfo(raw)) +} + +func TestFromBuildInfoNormalizesVersion(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw string + want string + }{ + {name: "empty", raw: "", want: ""}, + {name: "development sentinel", raw: "(devel)", want: ""}, + {name: "tag", raw: "v1.4.0", want: "v1.4.0"}, + {name: "pseudo-version", raw: "v1.4.1-0.20260820112030-8f31c2a76f12", want: "v1.4.1-0.20260820112030-8f31c2a76f12"}, + {name: "dirty suffix", raw: "v1.4.0+dirty", want: "v1.4.0+dirty"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + raw := &debug.BuildInfo{Main: debug.Module{Version: test.raw}} + + require.Equal(t, test.want, fromBuildInfo(raw).Version) + }) + } +} + +func TestFromBuildInfoIgnoresInvalidRevisionTime(t *testing.T) { + t.Parallel() + raw := &debug.BuildInfo{Settings: []debug.BuildSetting{ + {Key: revisionSetting, Value: "8f31c2a76f1234567890"}, + {Key: revisionTimeSetting, Value: "not-a-time"}, + }} + + info := fromBuildInfo(raw) + require.Equal(t, "8f31c2a76f1234567890", info.Revision) + require.True(t, info.RevisionTime.IsZero()) +} diff --git a/go.work b/go.work index 208fce8..ed38334 100644 --- a/go.work +++ b/go.work @@ -1,25 +1,46 @@ go 1.25.0 use ( + ./buildinfo ./codexapp ./config ./debugserver ./di ./filesystem + ./grpcclient + ./grpcserver + ./grpczap ./health + ./healthgrpc ./healthotel ./healthserver ./healthzap + ./kafka + ./kafkaoutbox + ./kafkaoutboxzap + ./kafkaproto + ./kafkazap ./lifecycle ./log ./oapivalidator + ./oapivalidatorjwt + ./oidcsession + ./oidcsessionredis ./postgresdb + ./retry ./sqlitedb ./telemetry ./txmanager ) replace ( + github.com/devctllabs/go-libs/grpcserver v0.1.0 => ./grpcserver github.com/devctllabs/go-libs/health v0.1.0 => ./health + github.com/devctllabs/go-libs/kafka v0.1.0 => ./kafka + github.com/devctllabs/go-libs/kafkaoutbox v0.1.0 => ./kafkaoutbox + github.com/devctllabs/go-libs/oapivalidator v0.1.0 => ./oapivalidator + github.com/devctllabs/go-libs/oidcsession v0.1.0 => ./oidcsession + github.com/devctllabs/go-libs/postgresdb v0.1.0 => ./postgresdb + github.com/devctllabs/go-libs/retry v0.1.0 => ./retry github.com/devctllabs/go-libs/txmanager v0.1.0 => ./txmanager ) diff --git a/go.work.sum b/go.work.sum index 0022038..3a27612 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,24 +1,28 @@ -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU= +codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw= +codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= +git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/blackwell-systems/gcf-go v1.2.2/go.mod h1:E4fW1kxdrIoWxlI4iwZL8mh7BvdLTkE88NyijtGGcZc= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/devctllabs/go-libs/oapivalidator v0.1.0/go.mod h1:TTOxHs1/Zrrb/DR+LoEiAW8IR9L5cAkQpoUlWY98EzI= +github.com/devctllabs/go-libs/postgresdb v0.1.0/go.mod h1:q0cD+nVZz/S+CTzVVAJIx1XSnuIqtXbDcapd3SHhdPs= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= @@ -28,21 +32,19 @@ github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+ github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccmack/gocc v1.0.2/go.mod h1:LXX2tFVUggS/Zgx/ICPOr3MLyusuM7EcbfkPvNsjdO8= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/iris-contrib/schema v0.0.6/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -53,37 +55,35 @@ github.com/kataras/pio v0.0.13/go.mod h1:k3HNuSw+eJ8Pm2lA4lRhg3DiCjVgHlP8hmXApSe github.com/kataras/sitemap v0.0.6/go.mod h1:dW4dOCNs896OR1HmG+dMLdT7JjDk7mYBzoIRwuj5jA4= github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= -github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= github.com/sirupsen/logrus v1.9.1/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/tdewolff/minify/v2 v2.20.19/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM= github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/twmb/franz-go v1.21.0/go.mod h1:1o+jj5oRbItsIMoE+DGpfJIcPcPtDdtkcNFPj4bWNwU= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= @@ -95,31 +95,24 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +gonum.org/v1/plot v0.15.2/go.mod h1:DX+x+DWso3LTha+AdkJEv5Txvi+Tql3KAGkehP0/Ubg= +gonum.org/v1/tools v0.0.0-20200318103217-c168b003ce8c/go.mod h1:fy6Otjqbk477ELp8IXTpw1cObQtLbRCBVonY+bTTfcM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/grpcclient/README.md b/grpcclient/README.md new file mode 100644 index 0000000..bb8d673 --- /dev/null +++ b/grpcclient/README.md @@ -0,0 +1,23 @@ +# grpcclient + +`grpcclient` constructs non-blocking gRPC client connections from resolver +targets. It does not add retries, deadlines, or blocking dial behavior. + +```go +conn, err := grpcclient.New(grpcclient.Config{ + Target: "dns:///catalog.default.svc.cluster.local:9000", + TLS: grpcclient.TLSConfig{ + Enabled: true, + RootCAFile: "/var/run/secrets/catalog-ca.pem", + ServerName: "catalog.default.svc.cluster.local", + }, +}) +if err != nil { + return err +} +defer conn.Close() +``` + +With TLS enabled, an empty root CA file uses operating-system roots. Client +certificate and key files are optional but must be supplied together. The caller +owns the returned connection and all per-RPC deadlines and retry policy. diff --git a/grpcclient/client.go b/grpcclient/client.go new file mode 100644 index 0000000..b24a90a --- /dev/null +++ b/grpcclient/client.go @@ -0,0 +1,185 @@ +package grpcclient + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "os" + "strings" + + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "go.opentelemetry.io/otel/metric" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + tracenoop "go.opentelemetry.io/otel/trace/noop" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/stats" +) + +// Config contains connection settings that are stable across environments. +type Config struct { + Target string + TLS TLSConfig +} + +// TLSConfig configures file-backed client transport credentials. +type TLSConfig struct { + Enabled bool + RootCAFile string + CertificateFile string + PrivateKeyFile string + ServerName string +} + +// Telemetry contains the OpenTelemetry dependencies used by the client. +// Nil members disable the corresponding signal instead of consulting globals. +type Telemetry struct { + TracerProvider trace.TracerProvider + MeterProvider metric.MeterProvider + Propagator propagation.TextMapPropagator +} + +// Option configures a connection during construction. +type Option interface{ apply(*clientConfig) error } + +type optionFunc func(*clientConfig) error + +func (f optionFunc) apply(cfg *clientConfig) error { return f(cfg) } + +type clientConfig struct { + unaryInterceptors []grpc.UnaryClientInterceptor + streamInterceptors []grpc.StreamClientInterceptor + dialOptions []grpc.DialOption + telemetry *Telemetry +} + +// WithTelemetry enables explicitly supplied OpenTelemetry providers. +func WithTelemetry(telemetry Telemetry) Option { + return optionFunc(func(cfg *clientConfig) error { + cfg.telemetry = &telemetry + return nil + }) +} + +// WithStreamInterceptors appends interceptors in execution order. +func WithStreamInterceptors(interceptors ...grpc.StreamClientInterceptor) Option { + return optionFunc(func(cfg *clientConfig) error { + for _, interceptor := range interceptors { + if interceptor == nil { + return errors.New("grpcclient: stream interceptor must not be nil") + } + cfg.streamInterceptors = append(cfg.streamInterceptors, interceptor) + } + return nil + }) +} + +// WithUnaryInterceptors appends interceptors in execution order. +func WithUnaryInterceptors(interceptors ...grpc.UnaryClientInterceptor) Option { + return optionFunc(func(cfg *clientConfig) error { + for _, interceptor := range interceptors { + if interceptor == nil { + return errors.New("grpcclient: unary interceptor must not be nil") + } + cfg.unaryInterceptors = append(cfg.unaryInterceptors, interceptor) + } + return nil + }) +} + +// WithDialOptions appends native options. Callers must not duplicate transport +// credentials, stats handlers, or interceptor chains owned by this package. +func WithDialOptions(options ...grpc.DialOption) Option { + return optionFunc(func(cfg *clientConfig) error { + cfg.dialOptions = append(cfg.dialOptions, options...) + return nil + }) +} + +// New creates a non-blocking gRPC client connection. The caller owns Close. +func New(config Config, options ...Option) (*grpc.ClientConn, error) { + if strings.TrimSpace(config.Target) == "" { + return nil, errors.New("grpcclient: target must not be blank") + } + if config.TLS.Enabled && (config.TLS.CertificateFile == "") != (config.TLS.PrivateKeyFile == "") { + return nil, errors.New("grpcclient: TLS certificate file and private key file must be provided together") + } + var cfg clientConfig + for _, option := range options { + if option == nil { + continue + } + if err := option.apply(&cfg); err != nil { + return nil, err + } + } + dialOptions := append([]grpc.DialOption(nil), cfg.dialOptions...) + transportCredentials, err := clientCredentials(config.TLS) + if err != nil { + return nil, err + } + dialOptions = append(dialOptions, grpc.WithTransportCredentials(transportCredentials)) + if cfg.telemetry != nil { + dialOptions = append(dialOptions, grpc.WithStatsHandler(newClientStatsHandler(*cfg.telemetry))) + } + if len(cfg.unaryInterceptors) > 0 { + dialOptions = append(dialOptions, grpc.WithChainUnaryInterceptor(cfg.unaryInterceptors...)) + } + if len(cfg.streamInterceptors) > 0 { + dialOptions = append(dialOptions, grpc.WithChainStreamInterceptor(cfg.streamInterceptors...)) + } + return grpc.NewClient(config.Target, dialOptions...) +} + +func newClientStatsHandler(telemetry Telemetry) stats.Handler { + tracerProvider := telemetry.TracerProvider + if tracerProvider == nil { + tracerProvider = tracenoop.NewTracerProvider() + } + meterProvider := telemetry.MeterProvider + if meterProvider == nil { + meterProvider = metricnoop.NewMeterProvider() + } + propagator := telemetry.Propagator + if propagator == nil { + propagator = propagation.NewCompositeTextMapPropagator() + } + return otelgrpc.NewClientHandler( + otelgrpc.WithTracerProvider(tracerProvider), + otelgrpc.WithMeterProvider(meterProvider), + otelgrpc.WithPropagators(propagator), + ) +} + +func clientCredentials(cfg TLSConfig) (credentials.TransportCredentials, error) { + if !cfg.Enabled { + return insecure.NewCredentials(), nil + } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: cfg.ServerName} + if cfg.RootCAFile != "" { + roots, err := x509.SystemCertPool() + if err != nil || roots == nil { + roots = x509.NewCertPool() + } + pem, err := os.ReadFile(cfg.RootCAFile) + if err != nil { + return nil, fmt.Errorf("grpcclient: read root CA: %w", err) + } + if !roots.AppendCertsFromPEM(pem) { + return nil, errors.New("grpcclient: root CA file contains no certificates") + } + tlsConfig.RootCAs = roots + } + if cfg.CertificateFile != "" { + certificate, err := tls.LoadX509KeyPair(cfg.CertificateFile, cfg.PrivateKeyFile) + if err != nil { + return nil, fmt.Errorf("grpcclient: load TLS key pair: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{certificate} + } + return credentials.NewTLS(tlsConfig), nil +} diff --git a/grpcclient/client_test.go b/grpcclient/client_test.go new file mode 100644 index 0000000..8acc537 --- /dev/null +++ b/grpcclient/client_test.go @@ -0,0 +1,177 @@ +package grpcclient_test + +import ( + "context" + "net" + "testing" + + "github.com/devctllabs/go-libs/grpcclient" + "github.com/stretchr/testify/require" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/test/bufconn" +) + +func TestNewRequiresTarget(t *testing.T) { + t.Parallel() + + conn, err := grpcclient.New(grpcclient.Config{}) + + require.Nil(t, conn) + require.ErrorContains(t, err, "target must not be blank") +} + +func TestNewConnectsUsingResolverTargetAndCallerInterceptor(t *testing.T) { + t.Parallel() + + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer() + healthServer := health.NewServer() + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(server, healthServer) + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + require.NoError(t, <-serveDone) + }) + + var interceptedMethod string + conn, err := grpcclient.New( + grpcclient.Config{Target: "passthrough:///health"}, + grpcclient.WithDialOptions(grpc.WithContextDialer( + func(context.Context, string) (net.Conn, error) { return listener.Dial() }, + )), + grpcclient.WithUnaryInterceptors(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + interceptedMethod = method + return invoker(ctx, method, req, reply, cc, opts...) + }), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + + response, err := grpc_health_v1.NewHealthClient(conn).Check(context.Background(), &grpc_health_v1.HealthCheckRequest{}) + + require.NoError(t, err) + require.Equal(t, grpc_health_v1.HealthCheckResponse_SERVING, response.GetStatus()) + require.Equal(t, grpc_health_v1.Health_Check_FullMethodName, interceptedMethod) +} + +func TestStreamInterceptorsWrapStreamingCalls(t *testing.T) { + t.Parallel() + + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer() + healthServer := health.NewServer() + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(server, healthServer) + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + require.NoError(t, <-serveDone) + }) + + var interceptedMethod string + conn, err := grpcclient.New( + grpcclient.Config{Target: "passthrough:///health"}, + grpcclient.WithDialOptions(grpc.WithContextDialer( + func(context.Context, string) (net.Conn, error) { return listener.Dial() }, + )), + grpcclient.WithStreamInterceptors(func( + ctx context.Context, + desc *grpc.StreamDesc, + cc *grpc.ClientConn, + method string, + streamer grpc.Streamer, + opts ...grpc.CallOption, + ) (grpc.ClientStream, error) { + interceptedMethod = method + return streamer(ctx, desc, cc, method, opts...) + }), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + + watch, err := grpc_health_v1.NewHealthClient(conn).Watch(context.Background(), &grpc_health_v1.HealthCheckRequest{}) + require.NoError(t, err) + _, err = watch.Recv() + + require.NoError(t, err) + require.Equal(t, grpc_health_v1.Health_Watch_FullMethodName, interceptedMethod) +} + +func TestTLSClientCertificateRequiresPrivateKey(t *testing.T) { + t.Parallel() + + conn, err := grpcclient.New(grpcclient.Config{ + Target: "dns:///service.example", + TLS: grpcclient.TLSConfig{ + Enabled: true, + CertificateFile: "client.pem", + }, + }) + + require.Nil(t, conn) + require.ErrorContains(t, err, "must be provided together") +} + +func TestTLSFilesAreLoadedAtConstruction(t *testing.T) { + t.Parallel() + + conn, err := grpcclient.New(grpcclient.Config{ + Target: "dns:///service.example", + TLS: grpcclient.TLSConfig{ + Enabled: true, + RootCAFile: "missing-ca.pem", + }, + }) + + require.Nil(t, conn) + require.ErrorContains(t, err, "read root CA") +} + +func TestTelemetryTracesClientCalls(t *testing.T) { + t.Parallel() + + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer() + healthServer := health.NewServer() + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(server, healthServer) + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + require.NoError(t, <-serveDone) + }) + + recorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + t.Cleanup(func() { require.NoError(t, tracerProvider.Shutdown(context.Background())) }) + conn, err := grpcclient.New( + grpcclient.Config{Target: "passthrough:///health"}, + grpcclient.WithDialOptions(grpc.WithContextDialer( + func(context.Context, string) (net.Conn, error) { return listener.Dial() }, + )), + grpcclient.WithTelemetry(grpcclient.Telemetry{TracerProvider: tracerProvider}), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + + _, err = grpc_health_v1.NewHealthClient(conn).Check(context.Background(), &grpc_health_v1.HealthCheckRequest{}) + + require.NoError(t, err) + require.NotEmpty(t, recorder.Ended()) +} diff --git a/grpcclient/doc.go b/grpcclient/doc.go new file mode 100644 index 0000000..e70d306 --- /dev/null +++ b/grpcclient/doc.go @@ -0,0 +1,2 @@ +// Package grpcclient constructs explicitly configured gRPC client connections. +package grpcclient diff --git a/grpcclient/go.mod b/grpcclient/go.mod new file mode 100644 index 0000000..4b24f6a --- /dev/null +++ b/grpcclient/go.mod @@ -0,0 +1,29 @@ +module github.com/devctllabs/go-libs/grpcclient + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + google.golang.org/grpc v1.81.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/grpcclient/go.sum b/grpcclient/go.sum new file mode 100644 index 0000000..3c621e7 --- /dev/null +++ b/grpcclient/go.sum @@ -0,0 +1,60 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grpcserver/README.md b/grpcserver/README.md new file mode 100644 index 0000000..8b3760e --- /dev/null +++ b/grpcserver/README.md @@ -0,0 +1,31 @@ +# grpcserver + +`grpcserver` owns gRPC server construction and lifecycle. It enables reflection, +Protovalidate request validation, and generic panic recovery by default. TLS and +OpenTelemetry are explicit instance configuration; no global logger or telemetry +provider is consulted. + +```go +server, err := grpcserver.New( + grpcserver.Config{Address: ":9000"}, + grpcserver.WithTelemetry(grpcserver.Telemetry{ + TracerProvider: tracerProvider, + MeterProvider: meterProvider, + Propagator: propagator, + }), +) +if err != nil { + return err +} + +examplev1.RegisterExampleServiceServer(server, service) +return server.ListenAndServe() +``` + +This module does not wrap generated protobuf types and does not provide a +protobuf generator or protoc plugin. Contract-owning modules should continue to +use the standard Go protobuf and gRPC plugins (and Buf when already adopted). + +Caller interceptors execute in supplied order inside recovery and before the +default request validator. `Shutdown` attempts graceful completion and forces a +stop when its context expires. diff --git a/grpcserver/doc.go b/grpcserver/doc.go new file mode 100644 index 0000000..b57a198 --- /dev/null +++ b/grpcserver/doc.go @@ -0,0 +1,2 @@ +// Package grpcserver provides an application-owned gRPC server runtime. +package grpcserver diff --git a/grpcserver/go.mod b/grpcserver/go.mod new file mode 100644 index 0000000..7393027 --- /dev/null +++ b/grpcserver/go.mod @@ -0,0 +1,45 @@ +module github.com/devctllabs/go-libs/grpcserver + +go 1.25.0 + +require ( + buf.build/go/protovalidate v0.12.0 + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk/metric v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + go.uber.org/mock v0.6.0 + google.golang.org/grpc v1.81.1 +) + +tool go.uber.org/mock/mockgen + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 // indirect + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/cel-go v0.25.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/grpcserver/go.sum b/grpcserver/go.sum new file mode 100644 index 0000000..d0864da --- /dev/null +++ b/grpcserver/go.sum @@ -0,0 +1,94 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 h1:YhMSc48s25kr7kv31Z8vf7sPUIq5YJva9z1mn/hAt0M= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U= +buf.build/go/protovalidate v0.12.0 h1:4GKJotbspQjRCcqZMGVSuC8SjwZ/FmgtSuKDpKUTZew= +buf.build/go/protovalidate v0.12.0/go.mod h1:q3PFfbzI05LeqxSwq+begW2syjy2Z6hLxZSkP1OH/D0= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY= +github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grpcserver/mocks/panic_observer.gen.go b/grpcserver/mocks/panic_observer.gen.go new file mode 100644 index 0000000..c0ef68e --- /dev/null +++ b/grpcserver/mocks/panic_observer.gen.go @@ -0,0 +1,54 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/grpcserver (interfaces: PanicObserver) +// +// Generated by this command: +// +// mockgen -destination mocks/panic_observer.gen.go -package mocks . PanicObserver +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + grpcserver "github.com/devctllabs/go-libs/grpcserver" + gomock "go.uber.org/mock/gomock" +) + +// MockPanicObserver is a mock of PanicObserver interface. +type MockPanicObserver struct { + ctrl *gomock.Controller + recorder *MockPanicObserverMockRecorder + isgomock struct{} +} + +// MockPanicObserverMockRecorder is the mock recorder for MockPanicObserver. +type MockPanicObserverMockRecorder struct { + mock *MockPanicObserver +} + +// NewMockPanicObserver creates a new mock instance. +func NewMockPanicObserver(ctrl *gomock.Controller) *MockPanicObserver { + mock := &MockPanicObserver{ctrl: ctrl} + mock.recorder = &MockPanicObserverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPanicObserver) EXPECT() *MockPanicObserverMockRecorder { + return m.recorder +} + +// ObservePanic mocks base method. +func (m *MockPanicObserver) ObservePanic(ctx context.Context, event grpcserver.PanicEvent) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ObservePanic", ctx, event) +} + +// ObservePanic indicates an expected call of ObservePanic. +func (mr *MockPanicObserverMockRecorder) ObservePanic(ctx, event any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ObservePanic", reflect.TypeOf((*MockPanicObserver)(nil).ObservePanic), ctx, event) +} diff --git a/grpcserver/panic.go b/grpcserver/panic.go new file mode 100644 index 0000000..15bb561 --- /dev/null +++ b/grpcserver/panic.go @@ -0,0 +1,70 @@ +package grpcserver + +import ( + "context" + "errors" + "runtime/debug" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +//go:generate go tool mockgen -destination mocks/panic_observer.gen.go -package mocks . PanicObserver + +// PanicEvent describes a panic recovered at an RPC boundary. +type PanicEvent struct { + FullMethod string + Value any + Stack string +} + +// PanicObserver receives recovered panic diagnostics. Implementations must +// return promptly and must not retain ctx after ObservePanic returns. +type PanicObserver interface { + ObservePanic(ctx context.Context, event PanicEvent) +} + +// WithPanicObservers appends observers notified after a recovered RPC panic. +func WithPanicObservers(observers ...PanicObserver) Option { + return optionFunc(func(cfg *serverConfig) error { + for _, observer := range observers { + if observer == nil { + return errors.New("grpcserver: panic observer must not be nil") + } + cfg.panicObservers = append(cfg.panicObservers, observer) + } + return nil + }) +} + +func recoveryUnaryInterceptor(observers []PanicObserver) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { + defer recoverRPCPanic(ctx, info.FullMethod, observers, &err) + return handler(ctx, req) + } +} + +func recoveryStreamInterceptor(observers []PanicObserver) grpc.StreamServerInterceptor { + return func(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) (err error) { + defer recoverRPCPanic(stream.Context(), info.FullMethod, observers, &err) + return handler(srv, stream) + } +} + +func recoverRPCPanic(ctx context.Context, fullMethod string, observers []PanicObserver, err *error) { + value := recover() + if value == nil { + return + } + event := PanicEvent{FullMethod: fullMethod, Value: value, Stack: string(debug.Stack())} + for _, observer := range observers { + notifyPanicObserver(ctx, observer, event) + } + *err = status.Error(codes.Internal, "internal server error") +} + +func notifyPanicObserver(ctx context.Context, observer PanicObserver, event PanicEvent) { + defer func() { _ = recover() }() + observer.ObservePanic(ctx, event) +} diff --git a/grpcserver/server.go b/grpcserver/server.go new file mode 100644 index 0000000..e7cc5ef --- /dev/null +++ b/grpcserver/server.go @@ -0,0 +1,231 @@ +package grpcserver + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + + "buf.build/go/protovalidate" + grpcprotovalidate "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/protovalidate" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +// Config contains the server settings that are stable across environments. +type Config struct { + Address string + TLS TLSConfig + DisableDefaultReflection bool + DisableDefaultValidation bool + DisableDefaultRecovery bool +} + +// TLSConfig configures file-backed server transport credentials. +type TLSConfig struct { + Enabled bool + CertificateFile string + PrivateKeyFile string + ClientCAFile string +} + +// Option configures a Server during construction. +type Option interface { + apply(*serverConfig) error +} + +type optionFunc func(*serverConfig) error + +func (f optionFunc) apply(cfg *serverConfig) error { return f(cfg) } + +type serverConfig struct { + panicObservers []PanicObserver + unaryInterceptors []grpc.UnaryServerInterceptor + streamInterceptors []grpc.StreamServerInterceptor + serverOptions []grpc.ServerOption + telemetry *Telemetry +} + +// WithServerOptions appends native options. Callers must not duplicate +// transport credentials, stats handlers, or interceptor chains owned here. +func WithServerOptions(options ...grpc.ServerOption) Option { + return optionFunc(func(cfg *serverConfig) error { + cfg.serverOptions = append(cfg.serverOptions, options...) + return nil + }) +} + +// WithStreamInterceptors appends interceptors in execution order. +func WithStreamInterceptors(interceptors ...grpc.StreamServerInterceptor) Option { + return optionFunc(func(cfg *serverConfig) error { + for _, interceptor := range interceptors { + if interceptor == nil { + return errors.New("grpcserver: stream interceptor must not be nil") + } + cfg.streamInterceptors = append(cfg.streamInterceptors, interceptor) + } + return nil + }) +} + +// WithUnaryInterceptors appends interceptors in execution order. +func WithUnaryInterceptors(interceptors ...grpc.UnaryServerInterceptor) Option { + return optionFunc(func(cfg *serverConfig) error { + for _, interceptor := range interceptors { + if interceptor == nil { + return errors.New("grpcserver: unary interceptor must not be nil") + } + cfg.unaryInterceptors = append(cfg.unaryInterceptors, interceptor) + } + return nil + }) +} + +// Server owns a gRPC server and its lifecycle. +type Server struct { + address string + grpc *grpc.Server +} + +// New constructs a server without opening a listener. +func New(config Config, options ...Option) (*Server, error) { + if err := validateConfig(config); err != nil { + return nil, err + } + var cfg serverConfig + if err := applyOptions(&cfg, options); err != nil { + return nil, err + } + grpcOptions, err := buildGRPCOptions(config, &cfg) + if err != nil { + return nil, err + } + server := &Server{address: config.Address, grpc: grpc.NewServer(grpcOptions...)} + if !config.DisableDefaultReflection { + reflection.Register(server) + } + return server, nil +} + +func validateConfig(config Config) error { + if strings.TrimSpace(config.Address) == "" { + return errors.New("grpcserver: address must not be blank") + } + if config.TLS.Enabled && strings.TrimSpace(config.TLS.CertificateFile) == "" { + return errors.New("grpcserver: TLS certificate file must not be blank") + } + if config.TLS.Enabled && strings.TrimSpace(config.TLS.PrivateKeyFile) == "" { + return errors.New("grpcserver: TLS private key file must not be blank") + } + return nil +} + +func applyOptions(cfg *serverConfig, options []Option) error { + for _, option := range options { + if option == nil { + continue + } + if err := option.apply(cfg); err != nil { + return err + } + } + return nil +} + +func buildGRPCOptions(config Config, cfg *serverConfig) ([]grpc.ServerOption, error) { + if cfg.telemetry != nil && cfg.telemetry.MeterProvider != nil { + observer, err := newPanicMetricObserver(cfg.telemetry.MeterProvider) + if err != nil { + return nil, fmt.Errorf("grpcserver: create panic counter: %w", err) + } + cfg.panicObservers = append(cfg.panicObservers, observer) + } + + unaryInterceptors := make([]grpc.UnaryServerInterceptor, 0, len(cfg.unaryInterceptors)+2) + streamInterceptors := make([]grpc.StreamServerInterceptor, 0, len(cfg.streamInterceptors)+2) + if !config.DisableDefaultRecovery { + unaryInterceptors = append(unaryInterceptors, recoveryUnaryInterceptor(cfg.panicObservers)) + streamInterceptors = append(streamInterceptors, recoveryStreamInterceptor(cfg.panicObservers)) + } + unaryInterceptors = append(unaryInterceptors, cfg.unaryInterceptors...) + streamInterceptors = append(streamInterceptors, cfg.streamInterceptors...) + if !config.DisableDefaultValidation { + validator, err := protovalidate.New() + if err != nil { + return nil, err + } + unaryInterceptors = append(unaryInterceptors, grpcprotovalidate.UnaryServerInterceptor(validator)) + streamInterceptors = append(streamInterceptors, grpcprotovalidate.StreamServerInterceptor(validator)) + } + grpcOptions := append([]grpc.ServerOption(nil), cfg.serverOptions...) + if cfg.telemetry != nil { + grpcOptions = append(grpcOptions, grpc.StatsHandler(newServerStatsHandler(*cfg.telemetry))) + } + if len(unaryInterceptors) > 0 { + grpcOptions = append(grpcOptions, grpc.ChainUnaryInterceptor(unaryInterceptors...)) + } + if len(streamInterceptors) > 0 { + grpcOptions = append(grpcOptions, grpc.ChainStreamInterceptor(streamInterceptors...)) + } + if config.TLS.Enabled { + tlsOption, err := serverTLSOption(config.TLS) + if err != nil { + return nil, err + } + grpcOptions = append(grpcOptions, tlsOption) + } + return grpcOptions, nil +} + +// Address returns the configured listen address. +func (s *Server) Address() string { return s.address } + +// ListenAndServe listens on Address and serves until stopped or an error occurs. +func (s *Server) ListenAndServe() error { + listener, err := net.Listen("tcp", s.address) + if err != nil { + return fmt.Errorf("grpcserver: listen: %w", err) + } + return s.Serve(listener) +} + +// RegisterService implements grpc.ServiceRegistrar. +func (s *Server) RegisterService(desc *grpc.ServiceDesc, impl any) { + s.grpc.RegisterService(desc, impl) +} + +// GetServiceInfo returns metadata for registered services. +func (s *Server) GetServiceInfo() map[string]grpc.ServiceInfo { + return s.grpc.GetServiceInfo() +} + +// Serve accepts connections from listener until stopped or an error occurs. +func (s *Server) Serve(listener net.Listener) error { + if listener == nil { + return errors.New("grpcserver: listener must not be nil") + } + err := s.grpc.Serve(listener) + if errors.Is(err, grpc.ErrServerStopped) { + return nil + } + return err +} + +// Shutdown gracefully stops the server. If ctx expires, active RPCs are +// forcefully stopped and ctx.Err is returned. +func (s *Server) Shutdown(ctx context.Context) error { + done := make(chan struct{}) + go func() { + s.grpc.GracefulStop() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + s.grpc.Stop() + <-done + return ctx.Err() + } +} diff --git a/grpcserver/server_test.go b/grpcserver/server_test.go new file mode 100644 index 0000000..2cece7d --- /dev/null +++ b/grpcserver/server_test.go @@ -0,0 +1,301 @@ +package grpcserver_test + +import ( + "context" + "net" + "testing" + "time" + + "github.com/devctllabs/go-libs/grpcserver" + "github.com/devctllabs/go-libs/grpcserver/mocks" + "github.com/grpc-ecosystem/go-grpc-middleware/v2/testing/testvalidate" + testvalidatev1 "github.com/grpc-ecosystem/go-grpc-middleware/v2/testing/testvalidate/v1" + "github.com/stretchr/testify/require" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.uber.org/mock/gomock" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/health" + grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +func TestNewRequiresAddress(t *testing.T) { + t.Parallel() + + server, err := grpcserver.New(grpcserver.Config{}) + + require.Nil(t, server) + require.ErrorContains(t, err, "address must not be blank") +} + +func TestServerRegistersAndServesGeneratedService(t *testing.T) { + t.Parallel() + + server, err := grpcserver.New(grpcserver.Config{Address: "127.0.0.1:0"}) + require.NoError(t, err) + require.Equal(t, "127.0.0.1:0", server.Address()) + healthServer := health.NewServer() + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(server, healthServer) + + listener := bufconn.Listen(1024 * 1024) + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(listener) }() + conn, err := grpc.NewClient( + "passthrough:///bufnet", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + + response, err := grpc_health_v1.NewHealthClient(conn).Check(context.Background(), &grpc_health_v1.HealthCheckRequest{}) + require.NoError(t, err) + require.Equal(t, grpc_health_v1.HealthCheckResponse_SERVING, response.GetStatus()) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, server.Shutdown(ctx)) + require.NoError(t, <-serveDone) +} + +func TestDefaultReflectionCanBeDisabled(t *testing.T) { + t.Parallel() + + enabled, err := grpcserver.New(grpcserver.Config{Address: "127.0.0.1:0"}) + require.NoError(t, err) + require.Contains(t, enabled.GetServiceInfo(), "grpc.reflection.v1.ServerReflection") + require.Contains(t, enabled.GetServiceInfo(), "grpc.reflection.v1alpha.ServerReflection") + + disabled, err := grpcserver.New(grpcserver.Config{ + Address: "127.0.0.1:0", + DisableDefaultReflection: true, + }) + require.NoError(t, err) + require.NotContains(t, disabled.GetServiceInfo(), "grpc.reflection.v1.ServerReflection") + require.NotContains(t, disabled.GetServiceInfo(), "grpc.reflection.v1alpha.ServerReflection") +} + +func TestDefaultValidationRejectsInvalidUnaryRequest(t *testing.T) { + t.Parallel() + + server, err := grpcserver.New(grpcserver.Config{Address: "127.0.0.1:0"}) + require.NoError(t, err) + testvalidatev1.RegisterTestValidateServiceServer(server, &testvalidate.TestValidateService{}) + conn := serveWithBufconn(t, server) + + _, err = testvalidatev1.NewTestValidateServiceClient(conn).Send(context.Background(), testvalidate.BadUnaryRequest) + + require.Equal(t, codes.InvalidArgument, status.Code(err)) +} + +func TestRecoveryHidesPanicAndContinuesAfterObserverPanic(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + observer := mocks.NewMockPanicObserver(controller) + observer.EXPECT().ObservePanic(gomock.Any(), gomock.Any()).Do(func(_ context.Context, event grpcserver.PanicEvent) { + require.Equal(t, grpc_health_v1.Health_Check_FullMethodName, event.FullMethod) + require.Equal(t, "secret panic", event.Value) + require.NotEmpty(t, event.Stack) + }) + server, err := grpcserver.New( + grpcserver.Config{Address: "127.0.0.1:0"}, + grpcserver.WithPanicObservers(panickingObserver{}, observer), + ) + require.NoError(t, err) + grpc_health_v1.RegisterHealthServer(server, panicHealthServer{}) + conn := serveWithBufconn(t, server) + + _, err = grpc_health_v1.NewHealthClient(conn).Check(context.Background(), &grpc_health_v1.HealthCheckRequest{}) + + require.Equal(t, codes.Internal, status.Code(err)) + require.NotContains(t, status.Convert(err).Message(), "secret panic") +} + +func TestRecoveryWrapsCallerInterceptors(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + observer := mocks.NewMockPanicObserver(controller) + observer.EXPECT().ObservePanic(gomock.Any(), gomock.Any()) + server, err := grpcserver.New( + grpcserver.Config{Address: "127.0.0.1:0"}, + grpcserver.WithPanicObservers(observer), + grpcserver.WithUnaryInterceptors(func( + context.Context, + any, + *grpc.UnaryServerInfo, + grpc.UnaryHandler, + ) (any, error) { + panic("interceptor panic") + }), + ) + require.NoError(t, err) + grpc_health_v1.RegisterHealthServer(server, health.NewServer()) + conn := serveWithBufconn(t, server) + + _, err = grpc_health_v1.NewHealthClient(conn).Check(context.Background(), &grpc_health_v1.HealthCheckRequest{}) + + require.Equal(t, codes.Internal, status.Code(err)) +} + +func TestRecoveryWrapsCallerStreamInterceptors(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + observer := mocks.NewMockPanicObserver(controller) + observer.EXPECT().ObservePanic(gomock.Any(), gomock.Any()) + server, err := grpcserver.New( + grpcserver.Config{Address: "127.0.0.1:0"}, + grpcserver.WithPanicObservers(observer), + grpcserver.WithStreamInterceptors(func( + any, + grpc.ServerStream, + *grpc.StreamServerInfo, + grpc.StreamHandler, + ) error { + panic("stream interceptor panic") + }), + ) + require.NoError(t, err) + grpc_health_v1.RegisterHealthServer(server, health.NewServer()) + conn := serveWithBufconn(t, server) + + watch, err := grpc_health_v1.NewHealthClient(conn).Watch(context.Background(), &grpc_health_v1.HealthCheckRequest{}) + require.NoError(t, err) + _, err = watch.Recv() + + require.Equal(t, codes.Internal, status.Code(err)) +} + +func TestTLSRequiresServerKeyPair(t *testing.T) { + t.Parallel() + + server, err := grpcserver.New(grpcserver.Config{ + Address: "127.0.0.1:0", + TLS: grpcserver.TLSConfig{Enabled: true}, + }) + + require.Nil(t, server) + require.ErrorContains(t, err, "certificate file must not be blank") +} + +func TestTLSFilesAreLoadedAtConstruction(t *testing.T) { + t.Parallel() + + server, err := grpcserver.New(grpcserver.Config{ + Address: "127.0.0.1:0", + TLS: grpcserver.TLSConfig{ + Enabled: true, + CertificateFile: "missing-cert.pem", + PrivateKeyFile: "missing-key.pem", + }, + }) + + require.Nil(t, server) + require.ErrorContains(t, err, "load TLS key pair") +} + +func TestNativeServerOptionsAndRecoveryOptOutAreAccepted(t *testing.T) { + t.Parallel() + + server, err := grpcserver.New( + grpcserver.Config{ + Address: "127.0.0.1:0", + DisableDefaultRecovery: true, + }, + grpcserver.WithServerOptions(grpc.MaxRecvMsgSize(1024)), + ) + + require.NoError(t, err) + require.NotNil(t, server) +} + +func TestListenAndServeReportsListenFailure(t *testing.T) { + t.Parallel() + + server, err := grpcserver.New(grpcserver.Config{Address: "not a valid address"}) + require.NoError(t, err) + + err = server.ListenAndServe() + + require.ErrorContains(t, err, "grpcserver: listen") +} + +func TestTelemetryCountsRecoveredPanics(t *testing.T) { + t.Parallel() + + reader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { require.NoError(t, meterProvider.Shutdown(context.Background())) }) + server, err := grpcserver.New( + grpcserver.Config{Address: "127.0.0.1:0"}, + grpcserver.WithTelemetry(grpcserver.Telemetry{MeterProvider: meterProvider}), + ) + require.NoError(t, err) + grpc_health_v1.RegisterHealthServer(server, panicHealthServer{}) + conn := serveWithBufconn(t, server) + + _, err = grpc_health_v1.NewHealthClient(conn).Check(context.Background(), &grpc_health_v1.HealthCheckRequest{}) + require.Equal(t, codes.Internal, status.Code(err)) + + var metrics metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &metrics)) + require.Equal(t, int64(1), panicCount(metrics)) +} + +func panicCount(metrics metricdata.ResourceMetrics) int64 { + for _, scope := range metrics.ScopeMetrics { + for _, measurement := range scope.Metrics { + if measurement.Name != "grpc.server.panics" { + continue + } + sum, ok := measurement.Data.(metricdata.Sum[int64]) + if ok && len(sum.DataPoints) == 1 { + return sum.DataPoints[0].Value + } + } + } + return 0 +} + +type panickingObserver struct{} + +func (panickingObserver) ObservePanic(context.Context, grpcserver.PanicEvent) { + panic("observer panic") +} + +type panicHealthServer struct { + grpc_health_v1.UnimplementedHealthServer +} + +func (panicHealthServer) Check(context.Context, *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) { + panic("secret panic") +} + +func serveWithBufconn(t *testing.T, server *grpcserver.Server) *grpc.ClientConn { + t.Helper() + + listener := bufconn.Listen(1024 * 1024) + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(listener) }() + conn, err := grpc.NewClient( + "passthrough:///bufnet", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, conn.Close()) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, server.Shutdown(ctx)) + require.NoError(t, <-serveDone) + }) + return conn +} diff --git a/grpcserver/telemetry.go b/grpcserver/telemetry.go new file mode 100644 index 0000000..7938df5 --- /dev/null +++ b/grpcserver/telemetry.go @@ -0,0 +1,68 @@ +package grpcserver + +import ( + "context" + + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + tracenoop "go.opentelemetry.io/otel/trace/noop" + "google.golang.org/grpc/stats" +) + +// Telemetry contains the OpenTelemetry dependencies used by the server. +// Nil members disable the corresponding signal instead of consulting globals. +type Telemetry struct { + TracerProvider trace.TracerProvider + MeterProvider metric.MeterProvider + Propagator propagation.TextMapPropagator +} + +// WithTelemetry enables explicitly supplied OpenTelemetry providers. +func WithTelemetry(telemetry Telemetry) Option { + return optionFunc(func(cfg *serverConfig) error { + cfg.telemetry = &telemetry + return nil + }) +} + +type panicMetricObserver struct{ counter metric.Int64Counter } + +func newPanicMetricObserver(provider metric.MeterProvider) (PanicObserver, error) { + counter, err := provider.Meter("github.com/devctllabs/go-libs/grpcserver").Int64Counter( + "grpc.server.panics", + metric.WithUnit("{panic}"), + metric.WithDescription("Number of recovered gRPC server panics"), + ) + if err != nil { + return nil, err + } + return panicMetricObserver{counter: counter}, nil +} + +func (o panicMetricObserver) ObservePanic(ctx context.Context, event PanicEvent) { + o.counter.Add(ctx, 1, metric.WithAttributes(attribute.String("rpc.grpc.full_method", event.FullMethod))) +} + +func newServerStatsHandler(telemetry Telemetry) stats.Handler { + tracerProvider := telemetry.TracerProvider + if tracerProvider == nil { + tracerProvider = tracenoop.NewTracerProvider() + } + meterProvider := telemetry.MeterProvider + if meterProvider == nil { + meterProvider = metricnoop.NewMeterProvider() + } + propagator := telemetry.Propagator + if propagator == nil { + propagator = propagation.NewCompositeTextMapPropagator() + } + return otelgrpc.NewServerHandler( + otelgrpc.WithTracerProvider(tracerProvider), + otelgrpc.WithMeterProvider(meterProvider), + otelgrpc.WithPropagators(propagator), + ) +} diff --git a/grpcserver/tls.go b/grpcserver/tls.go new file mode 100644 index 0000000..d5c2b89 --- /dev/null +++ b/grpcserver/tls.go @@ -0,0 +1,36 @@ +package grpcserver + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "os" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +func serverTLSOption(cfg TLSConfig) (grpc.ServerOption, error) { + certificate, err := tls.LoadX509KeyPair(cfg.CertificateFile, cfg.PrivateKeyFile) + if err != nil { + return nil, fmt.Errorf("grpcserver: load TLS key pair: %w", err) + } + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS12, + } + if cfg.ClientCAFile != "" { + pem, err := os.ReadFile(cfg.ClientCAFile) + if err != nil { + return nil, fmt.Errorf("grpcserver: read client CA: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, errors.New("grpcserver: client CA file contains no certificates") + } + tlsConfig.ClientCAs = pool + tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + } + return grpc.Creds(credentials.NewTLS(tlsConfig)), nil +} diff --git a/grpczap/README.md b/grpczap/README.md new file mode 100644 index 0000000..9085321 --- /dev/null +++ b/grpczap/README.md @@ -0,0 +1,23 @@ +# grpczap + +`grpczap` is an optional zap adapter for `grpcserver` and `grpcclient`. Its four +interceptors write one completion record per RPC without logging payloads or +metadata. The same adapter implements `grpcserver.PanicObserver`. + +```go +adapter, err := grpczap.New(logger) +if err != nil { + return err +} + +server, err := grpcserver.New( + grpcserver.Config{Address: ":9000"}, + grpcserver.WithUnaryInterceptors(adapter.UnaryServerInterceptor()), + grpcserver.WithStreamInterceptors(adapter.StreamServerInterceptor()), + grpcserver.WithPanicObservers(adapter), +) +``` + +Completion records include direction, full method, status code, duration, and +available trace/span IDs. Only recovered server panics include the recovered +value and stack. diff --git a/grpczap/adapter.go b/grpczap/adapter.go new file mode 100644 index 0000000..7d15bbb --- /dev/null +++ b/grpczap/adapter.go @@ -0,0 +1,172 @@ +package grpczap + +import ( + "context" + "errors" + "io" + "sync" + "time" + + "github.com/devctllabs/go-libs/grpcserver" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Adapter exposes zap-backed gRPC interceptors and panic observation. +type Adapter struct { + logger *zap.Logger +} + +// ObservePanic implements grpcserver.PanicObserver. +func (a *Adapter) ObservePanic(ctx context.Context, event grpcserver.PanicEvent) { + fields := []zap.Field{ + zap.String("rpc.direction", "server"), + zap.String("rpc.grpc.full_method", event.FullMethod), + zap.Any("panic", event.Value), + zap.String("stack", event.Stack), + } + spanContext := trace.SpanContextFromContext(ctx) + if spanContext.IsValid() { + fields = append(fields, + zap.String("trace_id", spanContext.TraceID().String()), + zap.String("span_id", spanContext.SpanID().String()), + ) + } + a.logger.Error("gRPC server panic recovered", fields...) +} + +// StreamServerInterceptor logs one completion record for each streaming server RPC. +func (a *Adapter) StreamServerInterceptor() grpc.StreamServerInterceptor { + return func(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + startedAt := time.Now() + err := handler(srv, stream) + a.logCompleted(stream.Context(), "server", info.FullMethod, time.Since(startedAt), err) + return err + } +} + +// UnaryClientInterceptor logs one completion record for each unary client RPC. +func (a *Adapter) UnaryClientInterceptor() grpc.UnaryClientInterceptor { + return func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + startedAt := time.Now() + err := invoker(ctx, method, req, reply, cc, opts...) + a.logCompleted(ctx, "client", method, time.Since(startedAt), err) + return err + } +} + +// StreamClientInterceptor logs stream creation failures immediately and a +// successful stream when RecvMsg reaches its terminal status. +func (a *Adapter) StreamClientInterceptor() grpc.StreamClientInterceptor { + return func( + ctx context.Context, + desc *grpc.StreamDesc, + cc *grpc.ClientConn, + method string, + streamer grpc.Streamer, + opts ...grpc.CallOption, + ) (grpc.ClientStream, error) { + startedAt := time.Now() + stream, err := streamer(ctx, desc, cc, method, opts...) + if err != nil { + a.logCompleted(ctx, "client", method, time.Since(startedAt), err) + return nil, err + } + return &loggingClientStream{ + ClientStream: stream, + adapter: a, + ctx: ctx, + fullMethod: method, + startedAt: startedAt, + logOnFirstResponse: desc.ClientStreams && !desc.ServerStreams, + }, nil + } +} + +type loggingClientStream struct { + grpc.ClientStream + adapter *Adapter + ctx context.Context + fullMethod string + startedAt time.Time + logOnFirstResponse bool + logOnce sync.Once +} + +func (s *loggingClientStream) RecvMsg(message any) error { + err := s.ClientStream.RecvMsg(message) + if err != nil || s.logOnFirstResponse { + completionErr := err + if errors.Is(err, io.EOF) { + completionErr = nil + } + s.logOnce.Do(func() { + s.adapter.logCompleted(s.ctx, "client", s.fullMethod, time.Since(s.startedAt), completionErr) + }) + } + return err +} + +// UnaryServerInterceptor logs one completion record for each unary server RPC. +func (a *Adapter) UnaryServerInterceptor() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + startedAt := time.Now() + response, err := handler(ctx, req) + a.logCompleted(ctx, "server", info.FullMethod, time.Since(startedAt), err) + return response, err + } +} + +func (a *Adapter) logCompleted(ctx context.Context, direction, fullMethod string, duration time.Duration, err error) { + code := status.Code(err) + fields := []zap.Field{ + zap.String("rpc.direction", direction), + zap.String("rpc.grpc.full_method", fullMethod), + zap.String("rpc.grpc.status_code", code.String()), + zap.Duration("duration", duration), + } + spanContext := trace.SpanContextFromContext(ctx) + if spanContext.IsValid() { + fields = append(fields, + zap.String("trace_id", spanContext.TraceID().String()), + zap.String("span_id", spanContext.SpanID().String()), + ) + } + if checked := a.logger.Check(levelForCode(code), "gRPC call completed"); checked != nil { + checked.Write(fields...) + } +} + +func levelForCode(code codes.Code) zapcore.Level { + switch code { + case codes.OK, codes.Canceled: + return zapcore.DebugLevel + case codes.InvalidArgument, codes.NotFound, codes.AlreadyExists, codes.FailedPrecondition, + codes.OutOfRange, codes.Unauthenticated, codes.PermissionDenied: + return zapcore.InfoLevel + case codes.DeadlineExceeded, codes.ResourceExhausted, codes.Aborted, codes.Unavailable: + return zapcore.WarnLevel + default: + return zapcore.ErrorLevel + } +} + +// New constructs an Adapter using logger. +func New(logger *zap.Logger) (*Adapter, error) { + if logger == nil { + return nil, errors.New("grpczap: logger must not be nil") + } + return &Adapter{logger: logger}, nil +} diff --git a/grpczap/adapter_test.go b/grpczap/adapter_test.go new file mode 100644 index 0000000..f9bfbc6 --- /dev/null +++ b/grpczap/adapter_test.go @@ -0,0 +1,226 @@ +package grpczap_test + +import ( + "context" + "testing" + + "github.com/devctllabs/go-libs/grpcserver" + "github.com/devctllabs/go-libs/grpczap" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func TestNewRejectsNilLogger(t *testing.T) { + t.Parallel() + + adapter, err := grpczap.New(nil) + + require.Nil(t, adapter) + require.ErrorContains(t, err, "logger must not be nil") +} + +func TestUnaryServerInterceptorLogsCompletedCall(t *testing.T) { + t.Parallel() + + core, observed := observer.New(zapcore.DebugLevel) + adapter, err := grpczap.New(zap.New(core)) + require.NoError(t, err) + spanContext := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{1}, + SpanID: trace.SpanID{2}, + }) + ctx := trace.ContextWithSpanContext(context.Background(), spanContext) + + _, err = adapter.UnaryServerInterceptor()( + ctx, + "request body must not be logged", + &grpc.UnaryServerInfo{FullMethod: "/example.Service/Create"}, + func(context.Context, any) (any, error) { + require.Empty(t, observed.All()) + return nil, status.Error(codes.InvalidArgument, "invalid") + }, + ) + + require.Equal(t, codes.InvalidArgument, status.Code(err)) + entries := observed.All() + require.Len(t, entries, 1) + require.Equal(t, zapcore.InfoLevel, entries[0].Level) + require.Equal(t, "gRPC call completed", entries[0].Message) + fields := entries[0].ContextMap() + require.Equal(t, "server", fields["rpc.direction"]) + require.Equal(t, "/example.Service/Create", fields["rpc.grpc.full_method"]) + require.Equal(t, "InvalidArgument", fields["rpc.grpc.status_code"]) + require.Equal(t, spanContext.TraceID().String(), fields["trace_id"]) + require.Equal(t, spanContext.SpanID().String(), fields["span_id"]) + require.Contains(t, fields, "duration") + require.NotContains(t, fields, "request") +} + +func TestStatusCodesUseFixedLogLevels(t *testing.T) { + t.Parallel() + + tests := []struct { + code codes.Code + level zapcore.Level + }{ + {codes.OK, zapcore.DebugLevel}, + {codes.Canceled, zapcore.DebugLevel}, + {codes.InvalidArgument, zapcore.InfoLevel}, + {codes.NotFound, zapcore.InfoLevel}, + {codes.AlreadyExists, zapcore.InfoLevel}, + {codes.FailedPrecondition, zapcore.InfoLevel}, + {codes.OutOfRange, zapcore.InfoLevel}, + {codes.Unauthenticated, zapcore.InfoLevel}, + {codes.PermissionDenied, zapcore.InfoLevel}, + {codes.DeadlineExceeded, zapcore.WarnLevel}, + {codes.ResourceExhausted, zapcore.WarnLevel}, + {codes.Aborted, zapcore.WarnLevel}, + {codes.Unavailable, zapcore.WarnLevel}, + {codes.Unknown, zapcore.ErrorLevel}, + {codes.Unimplemented, zapcore.ErrorLevel}, + {codes.Internal, zapcore.ErrorLevel}, + {codes.DataLoss, zapcore.ErrorLevel}, + } + for _, test := range tests { + t.Run(test.code.String(), func(t *testing.T) { + t.Parallel() + + core, observed := observer.New(zapcore.DebugLevel) + adapter, err := grpczap.New(zap.New(core)) + require.NoError(t, err) + callErr := error(nil) + if test.code != codes.OK { + callErr = status.Error(test.code, "failed") + } + + _, err = adapter.UnaryServerInterceptor()( + context.Background(), nil, &grpc.UnaryServerInfo{FullMethod: "/test"}, + func(context.Context, any) (any, error) { return nil, callErr }, + ) + + require.ErrorIs(t, err, callErr) + require.Len(t, observed.All(), 1) + require.Equal(t, test.level, observed.All()[0].Level) + }) + } +} + +func TestClientAndStreamInterceptorsLogCompletedCalls(t *testing.T) { + t.Parallel() + + core, observed := observer.New(zapcore.DebugLevel) + adapter, err := grpczap.New(zap.New(core)) + require.NoError(t, err) + + err = adapter.UnaryClientInterceptor()( + context.Background(), "/service/Unary", nil, nil, nil, + func(context.Context, string, any, any, *grpc.ClientConn, ...grpc.CallOption) error { + return status.Error(codes.Unavailable, "unavailable") + }, + ) + require.Equal(t, codes.Unavailable, status.Code(err)) + + err = adapter.StreamServerInterceptor()( + nil, + stubServerStream{ctx: context.Background()}, + &grpc.StreamServerInfo{FullMethod: "/service/ServerStream"}, + func(any, grpc.ServerStream) error { return status.Error(codes.DeadlineExceeded, "deadline") }, + ) + require.Equal(t, codes.DeadlineExceeded, status.Code(err)) + + _, err = adapter.StreamClientInterceptor()( + context.Background(), + &grpc.StreamDesc{}, + nil, + "/service/ClientStream", + func(context.Context, *grpc.StreamDesc, *grpc.ClientConn, string, ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, status.Error(codes.Unavailable, "unavailable") + }, + ) + require.Equal(t, codes.Unavailable, status.Code(err)) + + entries := observed.All() + require.Len(t, entries, 3) + require.Equal(t, "client", entries[0].ContextMap()["rpc.direction"]) + require.Equal(t, "server", entries[1].ContextMap()["rpc.direction"]) + require.Equal(t, "client", entries[2].ContextMap()["rpc.direction"]) +} + +func TestObservePanicLogsServerDiagnostics(t *testing.T) { + t.Parallel() + + core, observed := observer.New(zapcore.DebugLevel) + adapter, err := grpczap.New(zap.New(core)) + require.NoError(t, err) + + adapter.ObservePanic(context.Background(), grpcserver.PanicEvent{ + FullMethod: "/example.Service/Create", + Value: "secret panic", + Stack: "stack trace", + }) + + entries := observed.All() + require.Len(t, entries, 1) + require.Equal(t, zapcore.ErrorLevel, entries[0].Level) + require.Equal(t, "gRPC server panic recovered", entries[0].Message) + fields := entries[0].ContextMap() + require.Equal(t, "server", fields["rpc.direction"]) + require.Equal(t, "/example.Service/Create", fields["rpc.grpc.full_method"]) + require.Equal(t, "secret panic", fields["panic"]) + require.Equal(t, "stack trace", fields["stack"]) +} + +func TestClientStreamingLogsAfterUnaryResponse(t *testing.T) { + t.Parallel() + + core, observed := observer.New(zapcore.DebugLevel) + adapter, err := grpczap.New(zap.New(core)) + require.NoError(t, err) + stream, err := adapter.StreamClientInterceptor()( + context.Background(), + &grpc.StreamDesc{ClientStreams: true}, + nil, + "/service/Upload", + func(ctx context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + return stubClientStream{ctx: ctx}, nil + }, + ) + require.NoError(t, err) + require.Empty(t, observed.All()) + + require.NoError(t, stream.RecvMsg(nil)) + + entries := observed.All() + require.Len(t, entries, 1) + require.Equal(t, zapcore.DebugLevel, entries[0].Level) + require.Equal(t, "client", entries[0].ContextMap()["rpc.direction"]) +} + +type stubServerStream struct { + ctx context.Context +} + +func (stubServerStream) SetHeader(metadata.MD) error { return nil } +func (stubServerStream) SendHeader(metadata.MD) error { return nil } +func (stubServerStream) SetTrailer(metadata.MD) {} +func (s stubServerStream) Context() context.Context { return s.ctx } +func (stubServerStream) SendMsg(any) error { return nil } +func (stubServerStream) RecvMsg(any) error { return nil } + +type stubClientStream struct { + ctx context.Context +} + +func (stubClientStream) Header() (metadata.MD, error) { return nil, nil } +func (stubClientStream) Trailer() metadata.MD { return nil } +func (stubClientStream) CloseSend() error { return nil } +func (s stubClientStream) Context() context.Context { return s.ctx } +func (stubClientStream) SendMsg(any) error { return nil } +func (stubClientStream) RecvMsg(any) error { return nil } diff --git a/grpczap/doc.go b/grpczap/doc.go new file mode 100644 index 0000000..7d44e1d --- /dev/null +++ b/grpczap/doc.go @@ -0,0 +1,2 @@ +// Package grpczap adapts gRPC client, server, and panic events to zap logs. +package grpczap diff --git a/grpczap/go.mod b/grpczap/go.mod new file mode 100644 index 0000000..0ca4bac --- /dev/null +++ b/grpczap/go.mod @@ -0,0 +1,39 @@ +module github.com/devctllabs/go-libs/grpczap + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/grpcserver v0.1.0 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel/trace v1.44.0 + go.uber.org/zap v1.28.0 + google.golang.org/grpc v1.81.1 +) + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 // indirect + buf.build/go/protovalidate v0.12.0 // indirect + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/cel-go v0.25.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/grpczap/go.sum b/grpczap/go.sum new file mode 100644 index 0000000..86b19e4 --- /dev/null +++ b/grpczap/go.sum @@ -0,0 +1,94 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 h1:YhMSc48s25kr7kv31Z8vf7sPUIq5YJva9z1mn/hAt0M= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U= +buf.build/go/protovalidate v0.12.0 h1:4GKJotbspQjRCcqZMGVSuC8SjwZ/FmgtSuKDpKUTZew= +buf.build/go/protovalidate v0.12.0/go.mod h1:q3PFfbzI05LeqxSwq+begW2syjy2Z6hLxZSkP1OH/D0= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY= +github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/healthgrpc/README.md b/healthgrpc/README.md new file mode 100644 index 0000000..e2583a1 --- /dev/null +++ b/healthgrpc/README.md @@ -0,0 +1,22 @@ +# healthgrpc + +`healthgrpc` exposes a concrete `health.Probes` instance through the standard +gRPC Health protocol. `Check` always evaluates fresh probe state; `Watch` uses +the latest periodically published state and coalesces stale updates for slow +consumers. + +```go +healthService, err := healthgrpc.New(healthgrpc.Config{}, probes) +if err != nil { + return err +} +grpc_health_v1.RegisterHealthServer(grpcServer, healthService) + +go healthService.Run(ctx) +``` + +The empty service name and `readiness` are aggregate readiness. `liveness` is +process liveness. The initial watched state is `NOT_SERVING`; `Run` evaluates +immediately and then every five seconds by default. During shutdown, stop this +health service before stopping the application gRPC server so watchers receive +the terminal `NOT_SERVING` state. diff --git a/healthgrpc/doc.go b/healthgrpc/doc.go new file mode 100644 index 0000000..149a2ea --- /dev/null +++ b/healthgrpc/doc.go @@ -0,0 +1,2 @@ +// Package healthgrpc exposes health.Probes through the standard gRPC Health API. +package healthgrpc diff --git a/healthgrpc/go.mod b/healthgrpc/go.mod new file mode 100644 index 0000000..2b30753 --- /dev/null +++ b/healthgrpc/go.mod @@ -0,0 +1,20 @@ +module github.com/devctllabs/go-libs/healthgrpc + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/health v0.1.0 + github.com/stretchr/testify v1.11.1 + google.golang.org/grpc v1.81.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/healthgrpc/go.sum b/healthgrpc/go.sum new file mode 100644 index 0000000..5098e98 --- /dev/null +++ b/healthgrpc/go.sum @@ -0,0 +1,50 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/healthgrpc/server.go b/healthgrpc/server.go new file mode 100644 index 0000000..9e59398 --- /dev/null +++ b/healthgrpc/server.go @@ -0,0 +1,222 @@ +package healthgrpc + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/devctllabs/go-libs/health" + "google.golang.org/grpc/codes" + grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/status" +) + +const ( + // LivenessService is the standard service name used for liveness checks. + LivenessService = "liveness" + // ReadinessService is the standard service name used for readiness checks. + ReadinessService = "readiness" +) + +// Config controls periodic health evaluation. +type Config struct { + PollInterval time.Duration +} + +// Server implements the standard gRPC Health service for one probe set. +type Server struct { + grpc_health_v1.UnimplementedHealthServer + probes *health.Probes + pollInterval time.Duration + + mu sync.Mutex + statuses map[string]grpc_health_v1.HealthCheckResponse_ServingStatus + watchers map[string]map[chan grpc_health_v1.HealthCheckResponse_ServingStatus]struct{} + stopped bool + shutdown chan struct{} + stopOnce sync.Once +} + +// New constructs a health service in the NOT_SERVING state. +func New(config Config, probes *health.Probes) (*Server, error) { + if probes == nil { + return nil, errors.New("healthgrpc: Probes must not be nil") + } + if config.PollInterval < 0 { + return nil, errors.New("healthgrpc: poll interval must not be negative") + } + if config.PollInterval == 0 { + config.PollInterval = 5 * time.Second + } + return &Server{ + probes: probes, + pollInterval: config.PollInterval, + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_NOT_SERVING, + LivenessService: grpc_health_v1.HealthCheckResponse_NOT_SERVING, + ReadinessService: grpc_health_v1.HealthCheckResponse_NOT_SERVING, + }, + watchers: make(map[string]map[chan grpc_health_v1.HealthCheckResponse_ServingStatus]struct{}), + shutdown: make(chan struct{}), + }, nil +} + +// Check evaluates the requested probe from fresh state. +func (s *Server) Check(ctx context.Context, request *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) { + var report health.Report + switch request.GetService() { + case LivenessService: + report = s.probes.Liveness() + case "", ReadinessService: + report = s.probes.Readiness(ctx) + default: + return nil, status.Error(codes.NotFound, "unknown health service") + } + return &grpc_health_v1.HealthCheckResponse{Status: reportStatus(report)}, nil +} + +// List returns the latest published status for all known services. +func (s *Server) List(context.Context, *grpc_health_v1.HealthListRequest) (*grpc_health_v1.HealthListResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + statuses := make(map[string]*grpc_health_v1.HealthCheckResponse, len(s.statuses)) + for service, servingStatus := range s.statuses { + statuses[service] = &grpc_health_v1.HealthCheckResponse{Status: servingStatus} + } + return &grpc_health_v1.HealthListResponse{Statuses: statuses}, nil +} + +// Watch sends the current status immediately and subsequent status changes. +func (s *Server) Watch(request *grpc_health_v1.HealthCheckRequest, stream grpc_health_v1.Health_WatchServer) error { + updates := make(chan grpc_health_v1.HealthCheckResponse_ServingStatus, 1) + service := request.GetService() + s.mu.Lock() + registered := !s.stopped + if registered { + if s.watchers[service] == nil { + s.watchers[service] = make(map[chan grpc_health_v1.HealthCheckResponse_ServingStatus]struct{}) + } + s.watchers[service][updates] = struct{}{} + } + initial, exists := s.statuses[service] + if !exists { + initial = grpc_health_v1.HealthCheckResponse_SERVICE_UNKNOWN + } + updates <- initial + if !registered { + close(updates) + } + s.mu.Unlock() + if registered { + defer s.removeWatcher(service, updates) + } + + lastSent := grpc_health_v1.HealthCheckResponse_ServingStatus(-1) + for { + select { + case servingStatus, open := <-updates: + if !open { + return status.Error(codes.Canceled, "health service stopped") + } + if servingStatus == lastSent { + continue + } + if err := stream.Send(&grpc_health_v1.HealthCheckResponse{Status: servingStatus}); err != nil { + return status.Error(codes.Canceled, "health watch ended") + } + lastSent = servingStatus + case <-stream.Context().Done(): + return status.Error(codes.Canceled, "health watch ended") + } + } +} + +// Run evaluates probes immediately and then at the configured interval until +// ctx is canceled or Shutdown is called. +func (s *Server) Run(ctx context.Context) error { + s.publishCurrent(ctx) + ticker := time.NewTicker(s.pollInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.publishCurrent(ctx) + case <-ctx.Done(): + s.stop() + return nil + case <-s.shutdown: + return nil + } + } +} + +// Shutdown marks all known services NOT_SERVING and terminates watchers. +func (s *Server) Shutdown(context.Context) error { + s.stop() + return nil +} + +func (s *Server) publishCurrent(ctx context.Context) { + s.setStatus(LivenessService, reportStatus(s.probes.Liveness())) + readiness := reportStatus(s.probes.Readiness(ctx)) + s.setStatus("", readiness) + s.setStatus(ReadinessService, readiness) +} + +func reportStatus(report health.Report) grpc_health_v1.HealthCheckResponse_ServingStatus { + if report.Status == health.StatusOK { + return grpc_health_v1.HealthCheckResponse_SERVING + } + return grpc_health_v1.HealthCheckResponse_NOT_SERVING +} + +func (s *Server) setStatus(service string, servingStatus grpc_health_v1.HealthCheckResponse_ServingStatus) { + s.mu.Lock() + defer s.mu.Unlock() + if s.stopped { + return + } + if s.statuses[service] == servingStatus { + return + } + s.statuses[service] = servingStatus + for updates := range s.watchers[service] { + select { + case <-updates: + default: + } + updates <- servingStatus + } +} + +func (s *Server) stop() { + s.stopOnce.Do(func() { + s.mu.Lock() + s.stopped = true + for service := range s.statuses { + s.statuses[service] = grpc_health_v1.HealthCheckResponse_NOT_SERVING + } + for service, watchers := range s.watchers { + _, knownService := s.statuses[service] + for updates := range watchers { + if knownService { + select { + case <-updates: + default: + } + updates <- grpc_health_v1.HealthCheckResponse_NOT_SERVING + } + close(updates) + } + } + close(s.shutdown) + s.mu.Unlock() + }) +} + +func (s *Server) removeWatcher(service string, updates chan grpc_health_v1.HealthCheckResponse_ServingStatus) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.watchers[service], updates) +} diff --git a/healthgrpc/server_test.go b/healthgrpc/server_test.go new file mode 100644 index 0000000..bedab2c --- /dev/null +++ b/healthgrpc/server_test.go @@ -0,0 +1,154 @@ +package healthgrpc_test + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/devctllabs/go-libs/health" + "github.com/devctllabs/go-libs/healthgrpc" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +func TestNewRejectsNilProbes(t *testing.T) { + t.Parallel() + + server, err := healthgrpc.New(healthgrpc.Config{}, nil) + + require.Nil(t, server) + require.ErrorContains(t, err, "Probes must not be nil") +} + +func TestCheckEvaluatesTheRequestedProbe(t *testing.T) { + t.Parallel() + + readinessCalls := 0 + probes, err := health.New(health.Critical("database", health.CheckFunc(func(context.Context) error { + readinessCalls++ + return errors.New("unavailable") + }))) + require.NoError(t, err) + server, err := healthgrpc.New(healthgrpc.Config{}, probes) + require.NoError(t, err) + + liveness, err := server.Check(context.Background(), &grpc_health_v1.HealthCheckRequest{Service: healthgrpc.LivenessService}) + require.NoError(t, err) + require.Equal(t, grpc_health_v1.HealthCheckResponse_SERVING, liveness.GetStatus()) + require.Zero(t, readinessCalls) + + for _, service := range []string{"", healthgrpc.ReadinessService} { + response, err := server.Check(context.Background(), &grpc_health_v1.HealthCheckRequest{Service: service}) + require.NoError(t, err) + require.Equal(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING, response.GetStatus()) + } + require.Equal(t, 2, readinessCalls) + + _, err = server.Check(context.Background(), &grpc_health_v1.HealthCheckRequest{Service: "unknown"}) + require.Equal(t, codes.NotFound, status.Code(err)) +} + +func TestRunPublishesWatchTransitionsAndEndsWatchers(t *testing.T) { + t.Parallel() + + probes, err := health.New() + require.NoError(t, err) + healthServer, err := healthgrpc.New(healthgrpc.Config{PollInterval: time.Hour}, probes) + require.NoError(t, err) + + listener := bufconn.Listen(1024 * 1024) + grpcServer := grpc.NewServer() + grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + serveDone := make(chan error, 1) + go func() { serveDone <- grpcServer.Serve(listener) }() + t.Cleanup(func() { + grpcServer.Stop() + require.NoError(t, <-serveDone) + }) + conn, err := grpc.NewClient( + "passthrough:///health", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + + watch, err := grpc_health_v1.NewHealthClient(conn).Watch( + context.Background(), + &grpc_health_v1.HealthCheckRequest{Service: healthgrpc.ReadinessService}, + ) + require.NoError(t, err) + initial, err := watch.Recv() + require.NoError(t, err) + require.Equal(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING, initial.GetStatus()) + unknownWatch, err := grpc_health_v1.NewHealthClient(conn).Watch( + context.Background(), + &grpc_health_v1.HealthCheckRequest{Service: "unknown"}, + ) + require.NoError(t, err) + unknownInitial, err := unknownWatch.Recv() + require.NoError(t, err) + require.Equal(t, grpc_health_v1.HealthCheckResponse_SERVICE_UNKNOWN, unknownInitial.GetStatus()) + + runCtx, cancelRun := context.WithCancel(context.Background()) + runDone := make(chan error, 1) + go func() { runDone <- healthServer.Run(runCtx) }() + serving, err := watch.Recv() + require.NoError(t, err) + require.Equal(t, grpc_health_v1.HealthCheckResponse_SERVING, serving.GetStatus()) + + cancelRun() + require.NoError(t, <-runDone) + notServing, err := watch.Recv() + require.NoError(t, err) + require.Equal(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING, notServing.GetStatus()) + _, err = watch.Recv() + require.Equal(t, codes.Canceled, status.Code(err)) + unknownDone := make(chan error, 1) + go func() { + _, recvErr := unknownWatch.Recv() + unknownDone <- recvErr + }() + select { + case err := <-unknownDone: + require.Equal(t, codes.Canceled, status.Code(err)) + case <-time.After(200 * time.Millisecond): + t.Fatal("unknown service watcher did not end") + } +} + +func TestShutdownIsIdempotentAndPreventsLateProbePublication(t *testing.T) { + t.Parallel() + + checkStarted := make(chan struct{}) + releaseCheck := make(chan struct{}) + probes, err := health.New(health.Critical("database", health.CheckFunc(func(context.Context) error { + close(checkStarted) + <-releaseCheck + return nil + }))) + require.NoError(t, err) + server, err := healthgrpc.New(healthgrpc.Config{PollInterval: time.Hour}, probes) + require.NoError(t, err) + runDone := make(chan error, 1) + go func() { runDone <- server.Run(context.Background()) }() + <-checkStarted + + require.NoError(t, server.Shutdown(context.Background())) + require.NoError(t, server.Shutdown(context.Background())) + close(releaseCheck) + require.NoError(t, <-runDone) + + listed, err := server.List(context.Background(), &grpc_health_v1.HealthListRequest{}) + require.NoError(t, err) + for _, response := range listed.GetStatuses() { + require.Equal(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING, response.GetStatus()) + } +} diff --git a/healthgrpc/watch_test.go b/healthgrpc/watch_test.go new file mode 100644 index 0000000..bf7e063 --- /dev/null +++ b/healthgrpc/watch_test.go @@ -0,0 +1,123 @@ +package healthgrpc + +import ( + "context" + "testing" + "time" + + "github.com/devctllabs/go-libs/health" + "github.com/stretchr/testify/require" + grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" +) + +func TestSlowWatcherDoesNotBlockAndReceivesLatestStatus(t *testing.T) { + t.Parallel() + + probes, err := health.New() + require.NoError(t, err) + server, err := New(Config{}, probes) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + stream := &blockingWatchStream{ + ctx: ctx, + sent: make(chan grpc_health_v1.HealthCheckResponse_ServingStatus, 2), + release: make(chan struct{}, 2), + } + watchDone := make(chan error, 1) + go func() { + watchDone <- server.Watch( + &grpc_health_v1.HealthCheckRequest{Service: ReadinessService}, + stream, + ) + }() + require.Equal(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING, <-stream.sent) + + updatesDone := make(chan struct{}) + go func() { + server.setStatus(ReadinessService, grpc_health_v1.HealthCheckResponse_SERVING) + server.setStatus(ReadinessService, grpc_health_v1.HealthCheckResponse_NOT_SERVING) + server.setStatus(ReadinessService, grpc_health_v1.HealthCheckResponse_SERVING) + close(updatesDone) + }() + select { + case <-updatesDone: + case <-time.After(100 * time.Millisecond): + t.Fatal("status publication blocked on a slow watcher") + } + + stream.release <- struct{}{} + require.Equal(t, grpc_health_v1.HealthCheckResponse_SERVING, <-stream.sent) + cancel() + stream.release <- struct{}{} + require.Error(t, <-watchDone) +} + +func TestWatchStartedAfterShutdownReturnsInitialNotServingAndEnds(t *testing.T) { + t.Parallel() + + probes, err := health.New() + require.NoError(t, err) + server, err := New(Config{}, probes) + require.NoError(t, err) + require.NoError(t, server.Shutdown(context.Background())) + stream := &recordingWatchStream{ + ctx: context.Background(), + sent: make(chan grpc_health_v1.HealthCheckResponse_ServingStatus, 1), + } + watchDone := make(chan error, 1) + go func() { + watchDone <- server.Watch( + &grpc_health_v1.HealthCheckRequest{Service: ReadinessService}, + stream, + ) + }() + + require.Equal(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING, <-stream.sent) + select { + case err := <-watchDone: + require.Error(t, err) + case <-time.After(100 * time.Millisecond): + t.Fatal("watch started after shutdown did not end") + } +} + +type blockingWatchStream struct { + ctx context.Context + sent chan grpc_health_v1.HealthCheckResponse_ServingStatus + release chan struct{} +} + +func (s *blockingWatchStream) Send(response *grpc_health_v1.HealthCheckResponse) error { + s.sent <- response.GetStatus() + select { + case <-s.release: + return nil + case <-s.ctx.Done(): + return s.ctx.Err() + } +} + +func (*blockingWatchStream) SetHeader(metadata.MD) error { return nil } +func (*blockingWatchStream) SendHeader(metadata.MD) error { return nil } +func (*blockingWatchStream) SetTrailer(metadata.MD) {} +func (s *blockingWatchStream) Context() context.Context { return s.ctx } +func (*blockingWatchStream) SendMsg(any) error { return nil } +func (*blockingWatchStream) RecvMsg(any) error { return nil } + +type recordingWatchStream struct { + ctx context.Context + sent chan grpc_health_v1.HealthCheckResponse_ServingStatus +} + +func (s *recordingWatchStream) Send(response *grpc_health_v1.HealthCheckResponse) error { + s.sent <- response.GetStatus() + return nil +} + +func (*recordingWatchStream) SetHeader(metadata.MD) error { return nil } +func (*recordingWatchStream) SendHeader(metadata.MD) error { return nil } +func (*recordingWatchStream) SetTrailer(metadata.MD) {} +func (s *recordingWatchStream) Context() context.Context { return s.ctx } +func (*recordingWatchStream) SendMsg(any) error { return nil } +func (*recordingWatchStream) RecvMsg(any) error { return nil } diff --git a/kafka/README.md b/kafka/README.md new file mode 100644 index 0000000..6cee6b4 --- /dev/null +++ b/kafka/README.md @@ -0,0 +1,173 @@ +# kafka + +Typed, instance-owned Kafka producer and consumer runtimes built on +[`franz-go`](https://github.com/twmb/franz-go). + +`franz-go` is the base client because it is pure Go, actively implements the +Kafka protocol, exposes precise polling and commit control, supports Kafka +transactions when an application needs them directly, and has first-party +OpenTelemetry hooks. `segmentio/kafka-go` has a smaller API and is a reasonable +choice for simple consumers, while Confluent's Go client is appropriate when +`librdkafka` and its native deployment dependency are already standard. This +wrapper deliberately targets `franz-go` only. + +## Delivery model + +- Delivery is **at least once**. The library does not claim exactly-once + processing and does not enable transactions. +- A batch is committed only after decode policy, a successful handler attempt, + DLQ delivery when required, and commit retries have completed. +- Handler retries repeat the whole decoded batch. Values are decoded once; + `Skip` and `Reject` decisions from a failed attempt are cleared before the + next attempt. +- Commit retries never rerun the handler or DLQ phase. +- `retry.Permanent(err)` stops handler retries immediately. +- `Run` is single-use and owns client shutdown. Plain context cancellation is a + clean stop; an active partial batch is drained under `ShutdownTimeout`. + +This is intentionally an atomic scheduling/commit model, not atomic business +side effects. A handler may run more than once after process failure, so +business operations still need idempotency. + +## Batching and single-message mode + +All consumers use one `BatchHandler[T]` contract. Set `Batch.MaxSize` and +`Batch.FlushInterval` to seal a batch on the first reached boundary. For +single-message processing use `MaxSize: 1`; `FlushInterval` may then be zero. +There is no second single-record handler abstraction because it would duplicate +retry, reject, and commit semantics. + +```go +consumer, err := kafka.NewConsumer( + kafka.ConsumerConfig{ + Brokers: []string{"localhost:9092"}, + Group: "invoice-worker", + Topics: []string{"invoices"}, + Batch: kafka.BatchConfig{ + MaxSize: 100, + FlushInterval: 250 * time.Millisecond, + }, + Retry: kafka.RetryConfig{ + Policy: backoff, + MaxAttempts: 5, + }, + OnReject: kafka.RejectStop, + RebalanceTimeout: time.Minute, + RebalanceDrainTimeout: 30 * time.Second, + ShutdownTimeout: time.Minute, + }, + decoder, + kafka.BatchHandlerFunc[Invoice](func(ctx context.Context, batch *kafka.Batch[Invoice]) error { + for index, message := range batch.Messages() { + if alreadyProcessed(message.Value.ID) { + if err := batch.Skip(index); err != nil { + return err + } + } + } + return process(ctx, batch.Messages()) + }), +) +if err != nil { + return err +} +return consumer.Run(ctx) +``` + +`Messages()` is a borrowed immutable view. Handlers must not mutate or retain +the slice, headers, keys, or values after returning. + +## Codecs + +Use `NewJSONEncoder[T]` and `NewJSONDecoder[T]` for standard JSON payloads. +The decoder creates a fresh value for each record and is safe to share across +partition workers. Use `NewBytesEncoder` only when the payload is already in +its final wire format; it avoids a copy, so the caller must not mutate the +slice until the send or enqueue operation returns. + +## Reject and DLQ policies + +Decode failures are permanent rejects and never enter the generic handler. +The handler can reject a decoded record with `batch.Reject(index, cause)`. +Every consumer must explicitly choose one policy: + +| Policy | Result | +|---|---| +| `RejectStop` | Return `RejectedMessageError`; do not commit the input batch. | +| `RejectDrop` | Commit the input batch and emit a dropped disposition. | +| `RejectDLQ` | Publish the original record to the configured same-cluster topic, then commit. | + +DLQ records preserve the original key, value, timestamp, and non-reserved +headers. Reserved provenance headers contain original topic, partition, offset, +and failure kind (`decode` or `handler`). Error text is never written to Kafka. +`DLQFailureStop` favors durability; `DLQFailureDrop` favors continued processing +and emits a dropped disposition. DLQ and commit retries inherit the baseline +retry config unless an explicit override is supplied. + +## Partition isolation + +`NewPartitionedConsumer` creates one `PartitionHandler` per observed topic +partition. Different partitions may run concurrently; each handler is called +sequentially and is closed under a bounded context. `MaxConcurrentPartitions` +sets the concurrency limit and zero means unlimited. Per-partition partial +batches accumulate until their own size or interval boundary. Offset commit +calls are serialized. + +## Producer + +`Producer.Send` and `SendBatch` encode the complete input before sending any +record, wait synchronously for broker acknowledgements, and use all-ISR acks. +The producer is safe for concurrent sends. Partial broker delivery is returned +as `BatchDeliveryError`, with per-input-index failure metadata and a success +count. `Close(ctx)` prevents new sends, waits for active sends, and is +idempotent. + +## Observability + +Core code does not log implicitly. Configure an `Observer`, or compose several +with `NewMultiObserver`. + +`NewOTelObserver` supplies franz-go's standard broker/fetch/produce metrics and +propagation hooks plus wrapper instruments: + +- `kafka.consumer.operation.attempts` +- `kafka.consumer.operation.retries` +- `kafka.consumer.operation.inflight` +- `kafka.consumer.operation.duration` +- `kafka.consumer.batch.size` +- `kafka.consumer.messages` with outcome +- `kafka.consumer.record.dispositions` + +Message throughput is the rate of the monotonic `kafka.consumer.messages` +counter; a separate rate gauge would be aggregation-window dependent and is +therefore not emitted. Exact consumer lag should come from committed offsets +versus broker log-end offsets in the monitoring backend; the runtime does not +guess it from locally fetched records. + +Each handler, DLQ, and commit attempt creates a consumer span. A span is linked +to unique upstream record contexts, capped at 128 links by default. Error text +is recorded on spans but never used as a metric attribute. + +The sibling `kafkazap` module logs every retry at Warn, confirmed DLQ delivery +at Info, and drops at Warn. Terminal `Run` errors are returned, not logged, so +the application has one owner for terminal error reporting. Treat rejection +causes as potentially sensitive when configuring log sinks. + +The sibling `kafkaproto` module supplies concurrency-safe protobuf encoders and +fresh-message decoders without adding protobuf to the core module. + +## Tests + +```sh +go test -race ./kafka/... ./kafkaproto/... ./kafkazap/... +golangci-lint run ./kafka/... ./kafkaproto/... ./kafkazap/... +``` + +The default suite includes an in-memory `kfake` protocol round-trip. A real +broker smoke test is opt-in: + +```sh +KAFKA_BROKERS=localhost:9092 \ +KAFKA_TEST_TOPIC=go-libs-kafka-integration \ +go test -tags=kafka_integration ./kafka/... +``` diff --git a/kafka/batch.go b/kafka/batch.go new file mode 100644 index 0000000..1f3eb3f --- /dev/null +++ b/kafka/batch.go @@ -0,0 +1,83 @@ +package kafka + +import ( + "fmt" +) + +type messageOutcome uint8 + +const ( + messageProcessed messageOutcome = iota + messageSkipped + messageRejected +) + +type messageDecision struct { + outcome messageOutcome + err error +} + +// Batch is one immutable collection of decoded messages and its mutable +// per-attempt outcome buffer. +type Batch[T any] struct { + messages []Message[T] + decisionsBuffer []messageDecision +} + +// BatchDecisionError reports invalid Skip or Reject usage by a handler. +type BatchDecisionError struct { + Index int + Reason string +} + +// Error implements error. +func (err *BatchDecisionError) Error() string { + return fmt.Sprintf("kafka: batch decision for index %d: %s", err.Index, err.Reason) +} + +func newBatch[T any](messages []Message[T]) *Batch[T] { + return &Batch[T]{ + messages: messages, + decisionsBuffer: make([]messageDecision, len(messages)), + } +} + +// Messages returns a borrowed immutable view of the decoded messages. The +// returned slice and its elements are reused between handler attempts. +func (batch *Batch[T]) Messages() []Message[T] { + return batch.messages +} + +// Skip marks messages[index] as intentionally acknowledged without business +// processing. A message may be classified at most once per attempt. +func (batch *Batch[T]) Skip(index int) error { + return batch.mark(index, messageDecision{outcome: messageSkipped}) +} + +// Reject marks messages[index] as permanently rejected for the configured +// reject policy. cause is diagnostic and must not be nil. +func (batch *Batch[T]) Reject(index int, cause error) error { + if cause == nil { + return &BatchDecisionError{Index: index, Reason: "reject cause must not be nil"} + } + return batch.mark(index, messageDecision{outcome: messageRejected, err: cause}) +} + +func (batch *Batch[T]) mark(index int, decision messageDecision) error { + if index < 0 || index >= len(batch.decisionsBuffer) { + return &BatchDecisionError{Index: index, Reason: "index is out of range"} + } + if batch.decisionsBuffer[index].outcome != messageProcessed || batch.decisionsBuffer[index].err != nil { + return &BatchDecisionError{Index: index, Reason: "message is already classified"} + } + batch.decisionsBuffer[index] = decision + return nil +} + +func (batch *Batch[T]) reset() { + clear(batch.decisionsBuffer) +} + +func (batch *Batch[T]) decisions() []messageDecision { + return append([]messageDecision(nil), batch.decisionsBuffer...) +} diff --git a/kafka/batch_test.go b/kafka/batch_test.go new file mode 100644 index 0000000..5eaedb2 --- /dev/null +++ b/kafka/batch_test.go @@ -0,0 +1,51 @@ +package kafka + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBatchClassifiesMessages(t *testing.T) { + t.Parallel() + + rejected := errors.New("invalid invoice") + batch := newBatch([]Message[string]{ + {Topic: "invoices", Partition: 1, Offset: 10, Value: "processed"}, + {Topic: "invoices", Partition: 1, Offset: 11, Value: "skipped"}, + {Topic: "invoices", Partition: 1, Offset: 12, Value: "rejected"}, + }) + + require.Len(t, batch.Messages(), 3) + require.NoError(t, batch.Skip(1)) + require.NoError(t, batch.Reject(2, rejected)) + require.Equal(t, []messageDecision{ + {outcome: messageProcessed}, + {outcome: messageSkipped}, + {outcome: messageRejected, err: rejected}, + }, batch.decisions()) +} + +func TestBatchRejectsInvalidDecisions(t *testing.T) { + t.Parallel() + + batch := newBatch([]Message[string]{{Value: "invoice"}}) + + var decisionErr *BatchDecisionError + require.ErrorAs(t, batch.Skip(1), &decisionErr) + require.ErrorAs(t, batch.Reject(0, nil), &decisionErr) + require.NoError(t, batch.Skip(0)) + require.ErrorAs(t, batch.Reject(0, errors.New("conflict")), &decisionErr) +} + +func TestBatchResetClearsAttemptDecisions(t *testing.T) { + t.Parallel() + + batch := newBatch([]Message[string]{{Value: "invoice"}}) + require.NoError(t, batch.Skip(0)) + + batch.reset() + + require.Equal(t, []messageDecision{{outcome: messageProcessed}}, batch.decisions()) +} diff --git a/kafka/codec.go b/kafka/codec.go new file mode 100644 index 0000000..ddad77a --- /dev/null +++ b/kafka/codec.go @@ -0,0 +1,61 @@ +package kafka + +import ( + "context" + "encoding/json" +) + +//go:generate go tool mockgen -destination=contracts.gen_test.go -package=kafka -typed . Decoder,Encoder,BatchHandler + +// Decoder converts one Kafka record value into the handler's immutable type. +type Decoder[T any] interface { + // Decode converts value into T. Implementations must be concurrency-safe + // when used by a partitioned consumer. + Decode(ctx context.Context, value []byte) (T, error) +} + +// DecoderFunc adapts a function to Decoder. +type DecoderFunc[T any] func(ctx context.Context, value []byte) (T, error) + +// Decode calls decoder(ctx, value). +func (decoder DecoderFunc[T]) Decode(ctx context.Context, value []byte) (T, error) { + return decoder(ctx, value) +} + +// Encoder converts a typed value into one Kafka record value. +type Encoder[T any] interface { + // Encode serializes value. Implementations must be concurrency-safe. + Encode(ctx context.Context, value T) ([]byte, error) +} + +// EncoderFunc adapts a function to Encoder. +type EncoderFunc[T any] func(ctx context.Context, value T) ([]byte, error) + +// Encode calls encoder(ctx, value). +func (encoder EncoderFunc[T]) Encode(ctx context.Context, value T) ([]byte, error) { + return encoder(ctx, value) +} + +// NewBytesEncoder returns a zero-copy encoder for an already serialized value. +// Callers must not mutate value until the operation using the encoded bytes returns. +func NewBytesEncoder() Encoder[[]byte] { + return EncoderFunc[[]byte](func(_ context.Context, value []byte) ([]byte, error) { + return value, nil + }) +} + +// NewJSONEncoder returns a stateless JSON value encoder. +func NewJSONEncoder[T any]() Encoder[T] { + return EncoderFunc[T](func(_ context.Context, value T) ([]byte, error) { + return json.Marshal(value) + }) +} + +// NewJSONDecoder returns a concurrency-safe decoder that creates a fresh value for every record. +func NewJSONDecoder[T any]() Decoder[T] { + return DecoderFunc[T](func(_ context.Context, wire []byte) (T, error) { + var value T + err := json.Unmarshal(wire, &value) + return value, err + }) +} diff --git a/kafka/codec_test.go b/kafka/codec_test.go new file mode 100644 index 0000000..e77899d --- /dev/null +++ b/kafka/codec_test.go @@ -0,0 +1,67 @@ +package kafka + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBytesEncoderReturnsBorrowedWireBytes(t *testing.T) { + t.Parallel() + encoder := NewBytesEncoder() + value := []byte("event") + + wire, err := encoder.Encode(context.Background(), value) + require.NoError(t, err) + require.Equal(t, value, wire) + wire[0] = 'E' + require.Equal(t, []byte("Event"), value) + + wire, err = encoder.Encode(context.Background(), nil) + require.NoError(t, err) + require.Nil(t, wire) +} + +func TestJSONCodecRoundTrip(t *testing.T) { + t.Parallel() + encoder := NewJSONEncoder[jsonEvent]() + decoder := NewJSONDecoder[jsonEvent]() + want := jsonEvent{ID: "event-42"} + + wire, err := encoder.Encode(context.Background(), want) + require.NoError(t, err) + require.JSONEq(t, `{"id":"event-42"}`, string(wire)) + got, err := decoder.Decode(context.Background(), wire) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestJSONDecoderAllocatesFreshPointer(t *testing.T) { + t.Parallel() + decoder := NewJSONDecoder[*jsonEvent]() + + first, err := decoder.Decode(context.Background(), []byte(`{"id":"first"}`)) + require.NoError(t, err) + second, err := decoder.Decode(context.Background(), []byte(`{"id":"second"}`)) + require.NoError(t, err) + + require.NotSame(t, first, second) + require.Equal(t, "first", first.ID) + require.Equal(t, "second", second.ID) +} + +func TestJSONCodecReturnsSerializationErrors(t *testing.T) { + t.Parallel() + encoder := NewJSONEncoder[func()]() + decoder := NewJSONDecoder[jsonEvent]() + + _, err := encoder.Encode(context.Background(), func() {}) + require.Error(t, err) + _, err = decoder.Decode(context.Background(), []byte(`{"id":`)) + require.Error(t, err) +} + +type jsonEvent struct { + ID string `json:"id"` +} diff --git a/kafka/config.go b/kafka/config.go new file mode 100644 index 0000000..7fc6f3a --- /dev/null +++ b/kafka/config.go @@ -0,0 +1,170 @@ +package kafka + +import ( + "errors" + "fmt" + "strings" + "time" + + libretry "github.com/devctllabs/go-libs/retry" +) + +// RejectAction selects the terminal handling of permanently rejected records. +type RejectAction uint8 + +const ( + RejectUnspecified RejectAction = iota + RejectStop + RejectDrop + RejectDLQ +) + +// DLQFailureAction selects durability or processing availability after DLQ +// delivery retries are exhausted. +type DLQFailureAction uint8 + +const ( + DLQFailureUnspecified DLQFailureAction = iota + DLQFailureStop + DLQFailureDrop +) + +// BatchConfig controls when an accumulated raw Kafka batch is sealed. +type BatchConfig struct { + MaxSize int + FlushInterval time.Duration +} + +// RetryConfig bounds a retry cycle by calls, elapsed time, or both. +type RetryConfig struct { + Policy libretry.Policy + MaxAttempts uint + MaxElapsedTime time.Duration +} + +// DLQConfig routes permanently rejected records within the consumer cluster. +type DLQConfig struct { + Topic string + OnFailure DLQFailureAction + // Retry overrides ConsumerConfig.Retry for DLQ delivery. Nil inherits it. + Retry *RetryConfig +} + +// ConsumerConfig contains required consumer routing, batching, failure, and +// lifecycle budgets. +type ConsumerConfig struct { + Brokers []string + Group string + Topics []string + Batch BatchConfig + Retry RetryConfig + // CommitRetry overrides Retry for offset commits. Nil inherits it. + CommitRetry *RetryConfig + OnReject RejectAction + DLQ *DLQConfig + RebalanceTimeout time.Duration + RebalanceDrainTimeout time.Duration + ShutdownTimeout time.Duration + // Observer receives processing, retry, and disposition signals. Nil disables + // custom observation. + Observer Observer +} + +func (config ConsumerConfig) validate() error { + if err := validateStrings("broker", config.Brokers); err != nil { + return err + } + if strings.TrimSpace(config.Group) == "" { + return errors.New("kafka: consumer group must not be blank") + } + if err := validateStrings("topic", config.Topics); err != nil { + return err + } + if err := config.Batch.validate(); err != nil { + return err + } + if err := config.Retry.validate(); err != nil { + return fmt.Errorf("kafka: retry config: %w", err) + } + if config.CommitRetry != nil { + if err := config.CommitRetry.validate(); err != nil { + return fmt.Errorf("kafka: commit retry config: %w", err) + } + } + if config.RebalanceTimeout <= 0 { + return errors.New("kafka: rebalance timeout must be positive") + } + if config.RebalanceDrainTimeout <= 0 || config.RebalanceDrainTimeout >= config.RebalanceTimeout { + return errors.New("kafka: rebalance drain timeout must be positive and shorter than rebalance timeout") + } + if config.ShutdownTimeout <= 0 { + return errors.New("kafka: shutdown timeout must be positive") + } + return config.validateReject() +} + +func (config BatchConfig) validate() error { + if config.MaxSize <= 0 { + return errors.New("kafka: maximum batch size must be positive") + } + if config.MaxSize > 1 && config.FlushInterval <= 0 { + return errors.New("kafka: flush interval must be positive when maximum batch size is greater than one") + } + if config.FlushInterval < 0 { + return errors.New("kafka: flush interval must not be negative") + } + return nil +} + +func (config RetryConfig) validate() error { + if config.MaxAttempts == 0 && config.MaxElapsedTime <= 0 { + return errors.New("at least one retry limit must be positive") + } + if config.MaxElapsedTime < 0 { + return errors.New("maximum elapsed time must not be negative") + } + if config.MaxAttempts != 1 && config.Policy == nil { + return errors.New("retry policy must not be nil when retries are possible") + } + return nil +} + +func (config ConsumerConfig) validateReject() error { + switch config.OnReject { + case RejectStop, RejectDrop: + if config.DLQ != nil { + return errors.New("kafka: DLQ config is only valid with RejectDLQ") + } + return nil + case RejectDLQ: + if config.DLQ == nil { + return errors.New("kafka: DLQ config is required with RejectDLQ") + } + if strings.TrimSpace(config.DLQ.Topic) == "" { + return errors.New("kafka: DLQ topic must not be blank") + } + if config.DLQ.OnFailure != DLQFailureStop && config.DLQ.OnFailure != DLQFailureDrop { + return errors.New("kafka: DLQ failure action must be Stop or Drop") + } + if config.DLQ.Retry != nil { + if err := config.DLQ.Retry.validate(); err != nil { + return fmt.Errorf("kafka: DLQ retry config: %w", err) + } + } + return nil + default: + return errors.New("kafka: reject action must be explicitly configured") + } +} + +func validateStrings(name string, values []string) error { + if len(values) == 0 { + return fmt.Errorf("kafka: at least one %s is required", name) + } + for index, value := range values { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("kafka: %s %d must not be blank", name, index) + } + } + return nil +} diff --git a/kafka/config_test.go b/kafka/config_test.go new file mode 100644 index 0000000..90b6cfe --- /dev/null +++ b/kafka/config_test.go @@ -0,0 +1,111 @@ +package kafka + +import ( + "testing" + "time" + + libretry "github.com/devctllabs/go-libs/retry" + "github.com/stretchr/testify/require" +) + +func TestConsumerConfigAcceptsSingleMessageMode(t *testing.T) { + t.Parallel() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 1} + + require.NoError(t, cfg.validate()) +} + +func TestConsumerConfigRequiresFlushIntervalForBatches(t *testing.T) { + t.Parallel() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 2} + + require.ErrorContains(t, cfg.validate(), "flush interval") +} + +func TestConsumerConfigRequiresBoundedRetry(t *testing.T) { + t.Parallel() + + cfg := validConsumerConfig(t) + cfg.Retry = RetryConfig{Policy: cfg.Retry.Policy} + + require.ErrorContains(t, cfg.validate(), "retry") +} + +func TestConsumerConfigValidatesRetryOverrides(t *testing.T) { + t.Parallel() + + cfg := validConsumerConfig(t) + cfg.CommitRetry = &RetryConfig{} + require.ErrorContains(t, cfg.validate(), "commit retry") + + cfg = validConsumerConfig(t) + cfg.OnReject = RejectDLQ + cfg.DLQ = &DLQConfig{ + Topic: "invoices.dlq", OnFailure: DLQFailureStop, + Retry: &RetryConfig{}, + } + require.ErrorContains(t, cfg.validate(), "DLQ retry") +} + +func TestConsumerConfigValidatesRejectPolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + action RejectAction + dlq *DLQConfig + wantErr string + }{ + {name: "unspecified", action: RejectUnspecified, wantErr: "reject action"}, + {name: "stop with dlq", action: RejectStop, dlq: &DLQConfig{Topic: "invalid"}, wantErr: "DLQ"}, + {name: "drop with dlq", action: RejectDrop, dlq: &DLQConfig{Topic: "invalid"}, wantErr: "DLQ"}, + {name: "dlq missing config", action: RejectDLQ, wantErr: "DLQ"}, + {name: "dlq missing failure action", action: RejectDLQ, dlq: &DLQConfig{Topic: "invoices.dlq"}, wantErr: "failure action"}, + {name: "stop", action: RejectStop}, + {name: "drop", action: RejectDrop}, + {name: "dlq stop", action: RejectDLQ, dlq: &DLQConfig{Topic: "invoices.dlq", OnFailure: DLQFailureStop}}, + {name: "dlq drop", action: RejectDLQ, dlq: &DLQConfig{Topic: "invoices.dlq", OnFailure: DLQFailureDrop}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + cfg := validConsumerConfig(t) + cfg.OnReject = test.action + cfg.DLQ = test.dlq + + err := cfg.validate() + if test.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, test.wantErr) + }) + } +} + +func validConsumerConfig(t *testing.T) ConsumerConfig { + t.Helper() + + policy, err := libretry.NewExponential(libretry.ExponentialConfig{ + InitialDelay: time.Millisecond, + MaxDelay: time.Second, + Multiplier: 2, + }) + require.NoError(t, err) + return ConsumerConfig{ + Brokers: []string{"localhost:9092"}, + Group: "invoice-consumer", + Topics: []string{"invoices"}, + Batch: BatchConfig{MaxSize: 100, FlushInterval: time.Second}, + Retry: RetryConfig{Policy: policy, MaxAttempts: 3}, + OnReject: RejectStop, + RebalanceTimeout: time.Minute, + RebalanceDrainTimeout: 30 * time.Second, + ShutdownTimeout: time.Minute, + } +} diff --git a/kafka/consumer.go b/kafka/consumer.go new file mode 100644 index 0000000..ca69b95 --- /dev/null +++ b/kafka/consumer.go @@ -0,0 +1,362 @@ +package kafka + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + libretry "github.com/devctllabs/go-libs/retry" + "github.com/twmb/franz-go/pkg/kgo" +) + +// ErrAlreadyRun reports a second Run call on a single-use consumer. +var ErrAlreadyRun = errors.New("kafka: consumer has already run") + +// Consumer processes mixed-partition batches with one handler instance. +type Consumer[T any] struct { + config ConsumerConfig + client consumerClient + decoder Decoder[T] + handler BatchHandler[T] + + mu sync.Mutex + started bool +} + +// NewConsumer constructs a mixed-partition consumer. The returned consumer is +// single-use; Run owns and closes its franz-go client. +func NewConsumer[T any]( + config ConsumerConfig, + decoder Decoder[T], + handler BatchHandler[T], +) (*Consumer[T], error) { + if err := config.validate(); err != nil { + return nil, err + } + if decoder == nil { + return nil, errors.New("kafka: decoder must not be nil") + } + if handler == nil { + return nil, errors.New("kafka: batch handler must not be nil") + } + options := []kgo.Opt{ + kgo.SeedBrokers(config.Brokers...), + kgo.ConsumerGroup(config.Group), + kgo.ConsumeTopics(config.Topics...), + kgo.DisableAutoCommit(), + kgo.BlockRebalanceOnPoll(), + kgo.RebalanceTimeout(config.RebalanceTimeout), + kgo.RequiredAcks(kgo.AllISRAcks()), + } + if hooks := observerHooks(config.Observer, config.Group); len(hooks) > 0 { + options = append(options, kgo.WithHooks(hooks...)) + } + client, err := kgo.NewClient(options...) + if err != nil { + return nil, fmt.Errorf("kafka: create consumer client: %w", err) + } + return newConsumerWithClient(config, client, decoder, handler), nil +} + +// Run polls, processes, and commits batches until ctx is canceled or a +// terminal failure occurs. A plain cancellation is a clean stop. +func (consumer *Consumer[T]) Run(ctx context.Context) error { + if ctx == nil { + return errors.New("kafka: context must not be nil") + } + if err := consumer.beginRun(); err != nil { + return err + } + defer consumer.client.Close() + + raw := make([]*kgo.Record, 0, consumer.config.Batch.MaxSize) + var flushAt time.Time + for ctx.Err() == nil { + pollCtx := ctx + cancelPoll := func() {} + if !flushAt.IsZero() { + pollCtx, cancelPoll = context.WithDeadline(ctx, flushAt) + } + fetches := consumer.client.PollRecords(pollCtx, consumer.config.Batch.MaxSize-len(raw)) + flushExpired := errors.Is(pollCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil + cancelPoll() + if err := fetches.Err(); err != nil { + if ctx.Err() != nil || errors.Is(err, context.Canceled) || fetches.IsClientClosed() { + return cleanCancellation(ctx) + } + return fmt.Errorf("kafka: poll records: %w", err) + } + wasEmpty := len(raw) == 0 + raw = append(raw, fetches.Records()...) + if wasEmpty && len(raw) > 0 && consumer.config.Batch.FlushInterval > 0 { + flushAt = time.Now().Add(consumer.config.Batch.FlushInterval) + } + if len(raw) < consumer.config.Batch.MaxSize && !flushExpired { + continue + } + if len(raw) == 0 { + flushAt = time.Time{} + continue + } + if err := consumer.processBatch(ctx, raw); err != nil { + return err + } + raw = raw[:0] + flushAt = time.Time{} + consumer.client.AllowRebalance() + } + if len(raw) > 0 { + drainCtx, cancelDrain := context.WithTimeout(context.WithoutCancel(ctx), consumer.config.ShutdownTimeout) + defer cancelDrain() + if err := consumer.processBatch(drainCtx, raw); err != nil { + return fmt.Errorf("kafka: drain active batch: %w", err) + } + consumer.client.AllowRebalance() + } + return cleanCancellation(ctx) +} + +func (consumer *Consumer[T]) beginRun() error { + consumer.mu.Lock() + defer consumer.mu.Unlock() + if consumer.started { + return ErrAlreadyRun + } + consumer.started = true + return nil +} + +func (consumer *Consumer[T]) processBatch(ctx context.Context, records []*kgo.Record) error { + decoded, err := consumer.decodeRecords(ctx, records) + if err != nil { + return err + } + if len(decoded.messages) > 0 { + batch := newBatch(decoded.messages) + if err := consumer.handleBatch(ctx, batch, decoded.records); err != nil { + return fmt.Errorf("kafka: handle batch: %w", err) + } + handlerRejections, err := consumer.applyRejectPolicy(batch, decoded.records) + if err != nil { + return err + } + decoded.dispositions = append(decoded.dispositions, handlerRejections...) + if consumer.config.OnReject == RejectDLQ { + decoded.rejections = append(decoded.rejections, handlerRejections...) + } + countDecisions(&decoded.result, batch.decisionsBuffer) + } + dlqDelivered, err := consumer.deliverDLQ(ctx, decoded.rejections) + if err != nil { + return fmt.Errorf("kafka: deliver rejected records: %w", err) + } + observer := effectiveObserver(consumer.config.Observer) + if err := doWithRetry(ctx, effectiveRetry(consumer.config.Retry, consumer.config.CommitRetry), observer, AttemptCommit, records, func(commitCtx context.Context) error { + return consumer.client.CommitRecords(commitCtx, records...) + }); err != nil { + return fmt.Errorf("kafka: commit batch: %w", err) + } + consumer.observeCompletedBatch(ctx, observer, decoded, dlqDelivered) + return nil +} + +type decodedBatch[T any] struct { + messages []Message[T] + records []*kgo.Record + rejections []recordRejection + dispositions []recordRejection + result BatchResult +} + +func (consumer *Consumer[T]) decodeRecords( + ctx context.Context, + records []*kgo.Record, +) (*decodedBatch[T], error) { + decoded := &decodedBatch[T]{ + messages: make([]Message[T], 0, len(records)), + records: make([]*kgo.Record, 0, len(records)), + result: BatchResult{Size: len(records)}, + } + for index, record := range records { + value, err := consumer.decoder.Decode(ctx, record.Value) + if err != nil { + decoded.result.Rejected++ + rejection := recordRejection{ + record: record, + kind: "decode", + cause: &DecodeError{Index: index, Err: err}, + } + switch consumer.config.OnReject { + case RejectStop: + return nil, rejectedMessageError(record, rejection.cause) + case RejectDLQ: + decoded.rejections = append(decoded.rejections, rejection) + } + decoded.dispositions = append(decoded.dispositions, rejection) + continue + } + decoded.messages = append(decoded.messages, decodedMessage(record, value)) + decoded.records = append(decoded.records, record) + } + return decoded, nil +} + +func countDecisions(result *BatchResult, decisions []messageDecision) { + for _, decision := range decisions { + switch decision.outcome { + case messageProcessed: + result.Processed++ + case messageSkipped: + result.Skipped++ + case messageRejected: + result.Rejected++ + } + } +} + +func (consumer *Consumer[T]) observeCompletedBatch( + ctx context.Context, + observer Observer, + decoded *decodedBatch[T], + dlqDelivered bool, +) { + if dlqDelivered { + decoded.result.DLQ = len(decoded.rejections) + } + decoded.result.Dropped = decoded.result.Rejected - decoded.result.DLQ + for _, rejection := range decoded.dispositions { + kind := DispositionDropped + if consumer.config.OnReject == RejectDLQ && dlqDelivered { + kind = DispositionDLQ + } + observer.RecordDisposition(ctx, RecordDisposition{ + Record: recordMetadata(rejection.record), + Kind: kind, + Cause: rejection.cause, + }) + } + observer.BatchCompleted(ctx, decoded.result) +} + +func effectiveRetry(baseline RetryConfig, override *RetryConfig) RetryConfig { + if override != nil { + return *override + } + return baseline +} + +func (consumer *Consumer[T]) applyRejectPolicy( + batch *Batch[T], + records []*kgo.Record, +) ([]recordRejection, error) { + rejections := make([]recordRejection, 0) + for index, decision := range batch.decisionsBuffer { + if decision.outcome != messageRejected { + continue + } + if consumer.config.OnReject == RejectStop { + return nil, rejectedMessageError(records[index], decision.err) + } + rejections = append(rejections, recordRejection{ + record: records[index], + kind: "handler", + cause: decision.err, + }) + } + return rejections, nil +} + +func rejectedMessageError(record *kgo.Record, cause error) *RejectedMessageError { + return &RejectedMessageError{ + Topic: record.Topic, + Partition: record.Partition, + Offset: record.Offset, + Cause: cause, + } +} + +func (consumer *Consumer[T]) handleBatch( + ctx context.Context, + batch *Batch[T], + records []*kgo.Record, +) error { + return doWithRetry(ctx, consumer.config.Retry, effectiveObserver(consumer.config.Observer), AttemptHandler, records, func(attemptCtx context.Context) error { + batch.reset() + return consumer.handler.Handle(attemptCtx, batch) + }) +} + +func doWithRetry( + ctx context.Context, + config RetryConfig, + observer Observer, + phase AttemptPhase, + records []*kgo.Record, + operation libretry.Operation, +) error { + attempt := uint(0) + observedOperation := func(attemptCtx context.Context) error { + attempt++ + operationCtx, done := observer.StartAttempt(attemptCtx, Attempt{ + Phase: phase, Attempt: attempt, Records: metadata(records), + }) + if done == nil { + done = func(error) {} + } + err := operation(operationCtx) + done(err) + return err + } + if config.MaxAttempts == 1 { + return observedOperation(ctx) + } + options := make([]libretry.Option, 0, 3) + if config.MaxAttempts > 0 { + options = append(options, libretry.WithMaxAttempts(config.MaxAttempts)) + } + if config.MaxElapsedTime > 0 { + options = append(options, libretry.WithMaxElapsedTime(config.MaxElapsedTime)) + } + options = append(options, libretry.WithNotify(func(attempt uint, err error, nextDelay time.Duration) { + observer.Retry(ctx, RetryEvent{Phase: phase, Attempt: attempt, NextDelay: nextDelay, Err: err}) + })) + return libretry.Do(ctx, config.Policy, observedOperation, options...) +} + +func decodedMessage[T any](record *kgo.Record, value T) Message[T] { + var headers []Header + if record.Headers != nil { + headers = make([]Header, len(record.Headers)) + for index, header := range record.Headers { + headers[index] = Header{Key: header.Key, Value: header.Value} + } + } + return Message[T]{ + Topic: record.Topic, + Partition: record.Partition, + Offset: record.Offset, + Timestamp: record.Timestamp, + Key: record.Key, + Headers: headers, + Value: value, + } +} + +func cleanCancellation(ctx context.Context) error { + cause := context.Cause(ctx) + if cause == nil || cause == context.Canceled { + return nil + } + return cause +} + +func newConsumerWithClient[T any]( + config ConsumerConfig, + client consumerClient, + decoder Decoder[T], + handler BatchHandler[T], +) *Consumer[T] { + return &Consumer[T]{config: config, client: client, decoder: decoder, handler: handler} +} diff --git a/kafka/consumer_client.gen_test.go b/kafka/consumer_client.gen_test.go new file mode 100644 index 0000000..a60fa68 --- /dev/null +++ b/kafka/consumer_client.gen_test.go @@ -0,0 +1,238 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: consumer_client.go +// +// Generated by this command: +// +// mockgen -source=consumer_client.go -destination=consumer_client.gen_test.go -package=kafka -typed +// + +// Package kafka is a generated GoMock package. +package kafka + +import ( + context "context" + reflect "reflect" + + kgo "github.com/twmb/franz-go/pkg/kgo" + gomock "go.uber.org/mock/gomock" +) + +// MockconsumerClient is a mock of consumerClient interface. +type MockconsumerClient struct { + ctrl *gomock.Controller + recorder *MockconsumerClientMockRecorder + isgomock struct{} +} + +// MockconsumerClientMockRecorder is the mock recorder for MockconsumerClient. +type MockconsumerClientMockRecorder struct { + mock *MockconsumerClient +} + +// NewMockconsumerClient creates a new mock instance. +func NewMockconsumerClient(ctrl *gomock.Controller) *MockconsumerClient { + mock := &MockconsumerClient{ctrl: ctrl} + mock.recorder = &MockconsumerClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockconsumerClient) EXPECT() *MockconsumerClientMockRecorder { + return m.recorder +} + +// AllowRebalance mocks base method. +func (m *MockconsumerClient) AllowRebalance() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "AllowRebalance") +} + +// AllowRebalance indicates an expected call of AllowRebalance. +func (mr *MockconsumerClientMockRecorder) AllowRebalance() *MockconsumerClientAllowRebalanceCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AllowRebalance", reflect.TypeOf((*MockconsumerClient)(nil).AllowRebalance)) + return &MockconsumerClientAllowRebalanceCall{Call: call} +} + +// MockconsumerClientAllowRebalanceCall wrap *gomock.Call +type MockconsumerClientAllowRebalanceCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockconsumerClientAllowRebalanceCall) Return() *MockconsumerClientAllowRebalanceCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockconsumerClientAllowRebalanceCall) Do(f func()) *MockconsumerClientAllowRebalanceCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockconsumerClientAllowRebalanceCall) DoAndReturn(f func()) *MockconsumerClientAllowRebalanceCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Close mocks base method. +func (m *MockconsumerClient) Close() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Close") +} + +// Close indicates an expected call of Close. +func (mr *MockconsumerClientMockRecorder) Close() *MockconsumerClientCloseCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockconsumerClient)(nil).Close)) + return &MockconsumerClientCloseCall{Call: call} +} + +// MockconsumerClientCloseCall wrap *gomock.Call +type MockconsumerClientCloseCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockconsumerClientCloseCall) Return() *MockconsumerClientCloseCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockconsumerClientCloseCall) Do(f func()) *MockconsumerClientCloseCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockconsumerClientCloseCall) DoAndReturn(f func()) *MockconsumerClientCloseCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CommitRecords mocks base method. +func (m *MockconsumerClient) CommitRecords(ctx context.Context, records ...*kgo.Record) error { + m.ctrl.T.Helper() + varargs := []any{ctx} + for _, a := range records { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CommitRecords", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// CommitRecords indicates an expected call of CommitRecords. +func (mr *MockconsumerClientMockRecorder) CommitRecords(ctx any, records ...any) *MockconsumerClientCommitRecordsCall { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx}, records...) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommitRecords", reflect.TypeOf((*MockconsumerClient)(nil).CommitRecords), varargs...) + return &MockconsumerClientCommitRecordsCall{Call: call} +} + +// MockconsumerClientCommitRecordsCall wrap *gomock.Call +type MockconsumerClientCommitRecordsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockconsumerClientCommitRecordsCall) Return(arg0 error) *MockconsumerClientCommitRecordsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockconsumerClientCommitRecordsCall) Do(f func(context.Context, ...*kgo.Record) error) *MockconsumerClientCommitRecordsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockconsumerClientCommitRecordsCall) DoAndReturn(f func(context.Context, ...*kgo.Record) error) *MockconsumerClientCommitRecordsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PollRecords mocks base method. +func (m *MockconsumerClient) PollRecords(ctx context.Context, maxRecords int) kgo.Fetches { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PollRecords", ctx, maxRecords) + ret0, _ := ret[0].(kgo.Fetches) + return ret0 +} + +// PollRecords indicates an expected call of PollRecords. +func (mr *MockconsumerClientMockRecorder) PollRecords(ctx, maxRecords any) *MockconsumerClientPollRecordsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PollRecords", reflect.TypeOf((*MockconsumerClient)(nil).PollRecords), ctx, maxRecords) + return &MockconsumerClientPollRecordsCall{Call: call} +} + +// MockconsumerClientPollRecordsCall wrap *gomock.Call +type MockconsumerClientPollRecordsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockconsumerClientPollRecordsCall) Return(arg0 kgo.Fetches) *MockconsumerClientPollRecordsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockconsumerClientPollRecordsCall) Do(f func(context.Context, int) kgo.Fetches) *MockconsumerClientPollRecordsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockconsumerClientPollRecordsCall) DoAndReturn(f func(context.Context, int) kgo.Fetches) *MockconsumerClientPollRecordsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ProduceSync mocks base method. +func (m *MockconsumerClient) ProduceSync(ctx context.Context, records ...*kgo.Record) kgo.ProduceResults { + m.ctrl.T.Helper() + varargs := []any{ctx} + for _, a := range records { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ProduceSync", varargs...) + ret0, _ := ret[0].(kgo.ProduceResults) + return ret0 +} + +// ProduceSync indicates an expected call of ProduceSync. +func (mr *MockconsumerClientMockRecorder) ProduceSync(ctx any, records ...any) *MockconsumerClientProduceSyncCall { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx}, records...) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProduceSync", reflect.TypeOf((*MockconsumerClient)(nil).ProduceSync), varargs...) + return &MockconsumerClientProduceSyncCall{Call: call} +} + +// MockconsumerClientProduceSyncCall wrap *gomock.Call +type MockconsumerClientProduceSyncCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockconsumerClientProduceSyncCall) Return(arg0 kgo.ProduceResults) *MockconsumerClientProduceSyncCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockconsumerClientProduceSyncCall) Do(f func(context.Context, ...*kgo.Record) kgo.ProduceResults) *MockconsumerClientProduceSyncCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockconsumerClientProduceSyncCall) DoAndReturn(f func(context.Context, ...*kgo.Record) kgo.ProduceResults) *MockconsumerClientProduceSyncCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/kafka/consumer_client.go b/kafka/consumer_client.go new file mode 100644 index 0000000..6129de0 --- /dev/null +++ b/kafka/consumer_client.go @@ -0,0 +1,22 @@ +package kafka + +import ( + "context" + + "github.com/twmb/franz-go/pkg/kgo" +) + +//go:generate go tool mockgen -source=consumer_client.go -destination=consumer_client.gen_test.go -package=kafka -typed + +type consumerClient interface { + // PollRecords fetches at most maxRecords records. + PollRecords(ctx context.Context, maxRecords int) kgo.Fetches + // ProduceSync waits for delivery results for all records. + ProduceSync(ctx context.Context, records ...*kgo.Record) kgo.ProduceResults + // CommitRecords synchronously commits records in partition order. + CommitRecords(ctx context.Context, records ...*kgo.Record) error + // AllowRebalance permits a rebalance blocked by the latest poll. + AllowRebalance() + // Close closes the owned Kafka client. + Close() +} diff --git a/kafka/consumer_test.go b/kafka/consumer_test.go new file mode 100644 index 0000000..808e604 --- /dev/null +++ b/kafka/consumer_test.go @@ -0,0 +1,459 @@ +package kafka + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/twmb/franz-go/pkg/kgo" + "go.uber.org/mock/gomock" +) + +func TestConsumerProcessesAndCommitsFullBatch(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := NewMockconsumerClient(ctrl) + decoder := NewMockDecoder[string](ctrl) + handler := NewMockBatchHandler[string](ctrl) + first := &kgo.Record{Topic: "invoices", Partition: 1, Offset: 10, Value: []byte("first")} + second := &kgo.Record{Topic: "invoices", Partition: 1, Offset: 11, Value: []byte("second")} + fetches := fetchedInvoiceRecords(1, first, second) + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client.EXPECT().PollRecords(gomock.Any(), 2).Return(fetches) + decoder.EXPECT().Decode(gomock.Any(), []byte("first")).Return("decoded-first", nil) + decoder.EXPECT().Decode(gomock.Any(), []byte("second")).Return("decoded-second", nil) + handled := make(chan []Message[string], 1) + handler.EXPECT().Handle(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, batch *Batch[string]) error { + handled <- append([]Message[string](nil), batch.Messages()...) + return batch.Skip(1) + }, + ) + client.EXPECT().CommitRecords(gomock.Any(), first, second).DoAndReturn( + func(_ context.Context, _ ...*kgo.Record) error { + cancel() + return nil + }, + ) + client.EXPECT().AllowRebalance() + client.EXPECT().Close() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 2, FlushInterval: 10} + consumer := newConsumerWithClient(cfg, client, decoder, handler) + + require.NoError(t, consumer.Run(runCtx)) + require.Equal(t, []Message[string]{ + {Topic: "invoices", Partition: 1, Offset: 10, Value: "decoded-first"}, + {Topic: "invoices", Partition: 1, Offset: 11, Value: "decoded-second"}, + }, <-handled) +} + +func TestNewConsumerValidatesDependencies(t *testing.T) { + t.Parallel() + + cfg := validConsumerConfig(t) + decoder := DecoderFunc[string](func(_ context.Context, value []byte) (string, error) { + return string(value), nil + }) + handler := BatchHandlerFunc[string](func(context.Context, *Batch[string]) error { return nil }) + + _, err := NewConsumer[string](cfg, nil, handler) + require.ErrorContains(t, err, "decoder") + _, err = NewConsumer[string](cfg, decoder, nil) + require.ErrorContains(t, err, "handler") + + cfg.Group = "" + _, err = NewConsumer(cfg, decoder, handler) + require.ErrorContains(t, err, "group") +} + +func TestConsumerFlushesPartialBatchAfterInterval(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := NewMockconsumerClient(ctrl) + decoder := NewMockDecoder[string](ctrl) + handler := NewMockBatchHandler[string](ctrl) + record := &kgo.Record{Topic: "invoices", Partition: 1, Offset: 10, Value: []byte("first")} + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gomock.InOrder( + client.EXPECT().PollRecords(gomock.Any(), 2). + Return(fetchedInvoiceRecords(1, record)), + client.EXPECT().PollRecords(gomock.Any(), 1).DoAndReturn( + func(ctx context.Context, _ int) kgo.Fetches { + <-ctx.Done() + return nil + }, + ), + ) + decoder.EXPECT().Decode(gomock.Any(), []byte("first")).Return("decoded-first", nil) + handler.EXPECT().Handle(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, batch *Batch[string]) error { + require.Len(t, batch.Messages(), 1) + return nil + }, + ) + client.EXPECT().CommitRecords(gomock.Any(), record).DoAndReturn( + func(_ context.Context, _ ...*kgo.Record) error { + cancel() + return nil + }, + ) + client.EXPECT().AllowRebalance() + client.EXPECT().Close() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 2, FlushInterval: time.Millisecond} + consumer := newConsumerWithClient(cfg, client, decoder, handler) + + require.NoError(t, consumer.Run(runCtx)) +} + +func TestConsumerDrainsActiveBatchOnShutdown(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := NewMockconsumerClient(ctrl) + decoder := NewMockDecoder[string](ctrl) + handler := NewMockBatchHandler[string](ctrl) + record := &kgo.Record{Topic: "invoices", Partition: 1, Offset: 10, Value: []byte("first")} + runCtx, cancel := context.WithCancel(context.Background()) + + gomock.InOrder( + client.EXPECT().PollRecords(gomock.Any(), 2). + Return(fetchedInvoiceRecords(1, record)), + client.EXPECT().PollRecords(gomock.Any(), 1).DoAndReturn( + func(_ context.Context, _ int) kgo.Fetches { + cancel() + return nil + }, + ), + ) + decoder.EXPECT().Decode(gomock.Any(), []byte("first")).Return("decoded-first", nil) + handler.EXPECT().Handle(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, _ *Batch[string]) error { + require.NoError(t, ctx.Err()) + _, hasDeadline := ctx.Deadline() + require.True(t, hasDeadline) + return nil + }, + ) + client.EXPECT().CommitRecords(gomock.Any(), record).Return(nil) + client.EXPECT().AllowRebalance() + client.EXPECT().Close() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 2, FlushInterval: time.Hour} + cfg.ShutdownTimeout = time.Second + consumer := newConsumerWithClient(cfg, client, decoder, handler) + + require.NoError(t, consumer.Run(runCtx)) +} + +func TestConsumerRetriesHandlerWithResetDecisionsAndOneDecode(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := NewMockconsumerClient(ctrl) + decoder := NewMockDecoder[string](ctrl) + handler := NewMockBatchHandler[string](ctrl) + record := &kgo.Record{Topic: "invoices", Partition: 1, Offset: 10, Value: []byte("first")} + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client.EXPECT().PollRecords(gomock.Any(), 1).Return(fetchedInvoiceRecords(1, record)) + decoder.EXPECT().Decode(gomock.Any(), []byte("first")).Return("decoded-first", nil).Times(1) + attempt := 0 + handler.EXPECT().Handle(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, batch *Batch[string]) error { + attempt++ + if attempt == 1 { + require.NoError(t, batch.Skip(0)) + return errors.New("temporary") + } + require.Equal(t, []messageDecision{{outcome: messageProcessed}}, batch.decisions()) + return nil + }, + ).Times(2) + client.EXPECT().CommitRecords(gomock.Any(), record).DoAndReturn( + func(_ context.Context, _ ...*kgo.Record) error { + cancel() + return nil + }, + ) + client.EXPECT().AllowRebalance() + client.EXPECT().Close() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 1} + cfg.Retry = RetryConfig{Policy: immediatePolicy{}, MaxAttempts: 2} + observer := &recordingObserver{} + cfg.Observer = observer + consumer := newConsumerWithClient(cfg, client, decoder, handler) + + require.NoError(t, consumer.Run(runCtx)) + require.Equal(t, 2, attempt) + require.Equal(t, []AttemptPhase{AttemptHandler, AttemptHandler, AttemptCommit}, observer.phases) + require.Equal(t, []AttemptPhase{AttemptHandler}, observer.retries) + require.Equal(t, []BatchResult{{Processed: 1, Size: 1}}, observer.batches) +} + +func TestConsumerStopsWithoutCommitOnRejectedMessage(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := NewMockconsumerClient(ctrl) + decoder := NewMockDecoder[string](ctrl) + handler := NewMockBatchHandler[string](ctrl) + record := &kgo.Record{Topic: "invoices", Partition: 3, Offset: 42, Value: []byte("invalid")} + rejected := errors.New("invalid invoice") + + client.EXPECT().PollRecords(gomock.Any(), 1).Return(fetchedInvoiceRecords(3, record)) + decoder.EXPECT().Decode(gomock.Any(), []byte("invalid")).Return("decoded", nil) + handler.EXPECT().Handle(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, batch *Batch[string]) error { + return batch.Reject(0, rejected) + }, + ) + client.EXPECT().Close() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 1} + cfg.OnReject = RejectStop + consumer := newConsumerWithClient(cfg, client, decoder, handler) + + err := consumer.Run(context.Background()) + var rejectedErr *RejectedMessageError + require.ErrorAs(t, err, &rejectedErr) + require.ErrorIs(t, err, rejected) + require.Equal(t, "invoices", rejectedErr.Topic) + require.Equal(t, int32(3), rejectedErr.Partition) + require.Equal(t, int64(42), rejectedErr.Offset) +} + +func TestConsumerDropsDecodeFailureWithoutCallingHandler(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := NewMockconsumerClient(ctrl) + decoder := NewMockDecoder[string](ctrl) + handler := NewMockBatchHandler[string](ctrl) + record := &kgo.Record{Topic: "invoices", Partition: 3, Offset: 42, Value: []byte("invalid")} + decodeErr := errors.New("bad wire format") + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client.EXPECT().PollRecords(gomock.Any(), 1).Return(fetchedInvoiceRecords(3, record)) + decoder.EXPECT().Decode(gomock.Any(), []byte("invalid")).Return("", decodeErr) + client.EXPECT().CommitRecords(gomock.Any(), record).DoAndReturn( + func(_ context.Context, _ ...*kgo.Record) error { + cancel() + return nil + }, + ) + client.EXPECT().AllowRebalance() + client.EXPECT().Close() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 1} + cfg.OnReject = RejectDrop + observer := &recordingObserver{} + cfg.Observer = observer + consumer := newConsumerWithClient(cfg, client, decoder, handler) + + require.NoError(t, consumer.Run(runCtx)) + require.Len(t, observer.dispositions, 1) + require.Equal(t, DispositionDropped, observer.dispositions[0].Kind) + require.ErrorIs(t, observer.dispositions[0].Cause, decodeErr) +} + +func TestConsumerSendsRejectedMessageToDLQBeforeCommit(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := NewMockconsumerClient(ctrl) + decoder := NewMockDecoder[string](ctrl) + handler := NewMockBatchHandler[string](ctrl) + record := &kgo.Record{ + Topic: "invoices", Partition: 3, Offset: 42, + Key: []byte("invoice-42"), Value: []byte("invalid"), + Headers: []kgo.RecordHeader{{Key: "tenant", Value: []byte("acme")}}, + } + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client.EXPECT().PollRecords(gomock.Any(), 1).Return(fetchedInvoiceRecords(3, record)) + decoder.EXPECT().Decode(gomock.Any(), []byte("invalid")).Return("decoded", nil) + handler.EXPECT().Handle(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, batch *Batch[string]) error { + return batch.Reject(0, errors.New("sensitive business reason")) + }, + ) + client.EXPECT().ProduceSync(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, records ...*kgo.Record) kgo.ProduceResults { + require.Len(t, records, 1) + dlqRecord := records[0] + require.Equal(t, "invoices.dlq", dlqRecord.Topic) + require.Equal(t, record.Key, dlqRecord.Key) + require.Equal(t, record.Value, dlqRecord.Value) + require.Equal(t, []kgo.RecordHeader{ + {Key: "tenant", Value: []byte("acme")}, + {Key: DLQHeaderOriginalTopic, Value: []byte("invoices")}, + {Key: DLQHeaderOriginalPartition, Value: []byte("3")}, + {Key: DLQHeaderOriginalOffset, Value: []byte("42")}, + {Key: DLQHeaderFailureKind, Value: []byte("handler")}, + }, dlqRecord.Headers) + return kgo.ProduceResults{{Record: dlqRecord}} + }, + ) + client.EXPECT().CommitRecords(gomock.Any(), record).DoAndReturn( + func(_ context.Context, _ ...*kgo.Record) error { + cancel() + return nil + }, + ) + client.EXPECT().AllowRebalance() + client.EXPECT().Close() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 1} + cfg.OnReject = RejectDLQ + cfg.DLQ = &DLQConfig{Topic: "invoices.dlq", OnFailure: DLQFailureStop} + observer := &recordingObserver{} + cfg.Observer = observer + consumer := newConsumerWithClient(cfg, client, decoder, handler) + + require.NoError(t, consumer.Run(runCtx)) + require.Equal(t, []RecordDisposition{{ + Record: RecordMetadata{Topic: "invoices", Partition: 3, Offset: 42}, + Kind: DispositionDLQ, + Cause: errors.New("sensitive business reason"), + }}, observer.dispositions) +} + +func TestConsumerRetriesCommitWithoutRerunningHandler(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := NewMockconsumerClient(ctrl) + decoder := NewMockDecoder[string](ctrl) + handler := NewMockBatchHandler[string](ctrl) + record := &kgo.Record{Topic: "invoices", Partition: 3, Offset: 42, Value: []byte("valid")} + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client.EXPECT().PollRecords(gomock.Any(), 1).Return(fetchedInvoiceRecords(3, record)) + decoder.EXPECT().Decode(gomock.Any(), []byte("valid")).Return("decoded", nil).Times(1) + handler.EXPECT().Handle(gomock.Any(), gomock.Any()).Return(nil).Times(1) + gomock.InOrder( + client.EXPECT().CommitRecords(gomock.Any(), record).Return(errors.New("coordinator unavailable")), + client.EXPECT().CommitRecords(gomock.Any(), record).DoAndReturn( + func(_ context.Context, _ ...*kgo.Record) error { + cancel() + return nil + }, + ), + ) + client.EXPECT().AllowRebalance() + client.EXPECT().Close() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 1} + cfg.Retry = RetryConfig{Policy: immediatePolicy{}, MaxAttempts: 2} + consumer := newConsumerWithClient(cfg, client, decoder, handler) + + require.NoError(t, consumer.Run(runCtx)) +} + +func TestConsumerReportsDropWhenDLQDeliveryFailureIsConfiguredToDrop(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := NewMockconsumerClient(ctrl) + decoder := NewMockDecoder[string](ctrl) + handler := NewMockBatchHandler[string](ctrl) + record := &kgo.Record{Topic: "invoices", Partition: 3, Offset: 42, Value: []byte("invalid")} + rejectionCause := errors.New("invalid invoice") + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client.EXPECT().PollRecords(gomock.Any(), 1).Return(fetchedInvoiceRecords(3, record)) + decoder.EXPECT().Decode(gomock.Any(), record.Value).Return("decoded", nil) + handler.EXPECT().Handle(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, batch *Batch[string]) error { return batch.Reject(0, rejectionCause) }, + ) + client.EXPECT().ProduceSync(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, records ...*kgo.Record) kgo.ProduceResults { + return kgo.ProduceResults{{Record: records[0], Err: errors.New("DLQ unavailable")}} + }, + ) + client.EXPECT().CommitRecords(gomock.Any(), record).DoAndReturn( + func(_ context.Context, _ ...*kgo.Record) error { cancel(); return nil }, + ) + client.EXPECT().AllowRebalance() + client.EXPECT().Close() + + observer := &recordingObserver{} + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 1} + cfg.Retry = RetryConfig{MaxAttempts: 1} + cfg.OnReject = RejectDLQ + cfg.DLQ = &DLQConfig{Topic: "invoices.dlq", OnFailure: DLQFailureDrop} + cfg.Observer = observer + consumer := newConsumerWithClient(cfg, client, decoder, handler) + + require.NoError(t, consumer.Run(runCtx)) + require.Len(t, observer.dispositions, 1) + require.Equal(t, DispositionDropped, observer.dispositions[0].Kind) + require.Equal(t, []BatchResult{{Size: 1, Rejected: 1, Dropped: 1}}, observer.batches) +} + +type immediatePolicy struct{} + +func (immediatePolicy) Delay(uint) time.Duration { return 0 } + +type recordingObserver struct { + phases []AttemptPhase + retries []AttemptPhase + batches []BatchResult + dispositions []RecordDisposition +} + +func (observer *recordingObserver) StartAttempt( + ctx context.Context, + attempt Attempt, +) (context.Context, AttemptDone) { + observer.phases = append(observer.phases, attempt.Phase) + return ctx, func(error) {} +} + +func (observer *recordingObserver) Retry(_ context.Context, event RetryEvent) { + observer.retries = append(observer.retries, event.Phase) +} + +func (observer *recordingObserver) BatchCompleted(_ context.Context, result BatchResult) { + observer.batches = append(observer.batches, result) +} + +func (observer *recordingObserver) RecordDisposition(_ context.Context, disposition RecordDisposition) { + disposition.Record.Context = nil + observer.dispositions = append(observer.dispositions, disposition) +} + +func fetchedInvoiceRecords(partition int32, records ...*kgo.Record) kgo.Fetches { + return kgo.Fetches{{Topics: []kgo.FetchTopic{{ + Topic: "invoices", + Partitions: []kgo.FetchPartition{{ + Partition: partition, + Records: records, + }}, + }}}} +} diff --git a/kafka/contracts.gen_test.go b/kafka/contracts.gen_test.go new file mode 100644 index 0000000..57bda41 --- /dev/null +++ b/kafka/contracts.gen_test.go @@ -0,0 +1,205 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/kafka (interfaces: Decoder,Encoder,BatchHandler) +// +// Generated by this command: +// +// mockgen -destination=contracts.gen_test.go -package=kafka -typed . Decoder,Encoder,BatchHandler +// + +// Package kafka is a generated GoMock package. +package kafka + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockDecoder is a mock of Decoder interface. +type MockDecoder[T any] struct { + ctrl *gomock.Controller + recorder *MockDecoderMockRecorder[T] + isgomock struct{} +} + +// MockDecoderMockRecorder is the mock recorder for MockDecoder. +type MockDecoderMockRecorder[T any] struct { + mock *MockDecoder[T] +} + +// NewMockDecoder creates a new mock instance. +func NewMockDecoder[T any](ctrl *gomock.Controller) *MockDecoder[T] { + mock := &MockDecoder[T]{ctrl: ctrl} + mock.recorder = &MockDecoderMockRecorder[T]{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDecoder[T]) EXPECT() *MockDecoderMockRecorder[T] { + return m.recorder +} + +// Decode mocks base method. +func (m *MockDecoder[T]) Decode(ctx context.Context, value []byte) (T, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Decode", ctx, value) + ret0, _ := ret[0].(T) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Decode indicates an expected call of Decode. +func (mr *MockDecoderMockRecorder[T]) Decode(ctx, value any) *MockDecoderDecodeCall[T] { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Decode", reflect.TypeOf((*MockDecoder[T])(nil).Decode), ctx, value) + return &MockDecoderDecodeCall[T]{Call: call} +} + +// MockDecoderDecodeCall wrap *gomock.Call +type MockDecoderDecodeCall[T any] struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockDecoderDecodeCall[T]) Return(arg0 T, arg1 error) *MockDecoderDecodeCall[T] { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockDecoderDecodeCall[T]) Do(f func(context.Context, []byte) (T, error)) *MockDecoderDecodeCall[T] { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockDecoderDecodeCall[T]) DoAndReturn(f func(context.Context, []byte) (T, error)) *MockDecoderDecodeCall[T] { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockEncoder is a mock of Encoder interface. +type MockEncoder[T any] struct { + ctrl *gomock.Controller + recorder *MockEncoderMockRecorder[T] + isgomock struct{} +} + +// MockEncoderMockRecorder is the mock recorder for MockEncoder. +type MockEncoderMockRecorder[T any] struct { + mock *MockEncoder[T] +} + +// NewMockEncoder creates a new mock instance. +func NewMockEncoder[T any](ctrl *gomock.Controller) *MockEncoder[T] { + mock := &MockEncoder[T]{ctrl: ctrl} + mock.recorder = &MockEncoderMockRecorder[T]{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEncoder[T]) EXPECT() *MockEncoderMockRecorder[T] { + return m.recorder +} + +// Encode mocks base method. +func (m *MockEncoder[T]) Encode(ctx context.Context, value T) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Encode", ctx, value) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Encode indicates an expected call of Encode. +func (mr *MockEncoderMockRecorder[T]) Encode(ctx, value any) *MockEncoderEncodeCall[T] { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Encode", reflect.TypeOf((*MockEncoder[T])(nil).Encode), ctx, value) + return &MockEncoderEncodeCall[T]{Call: call} +} + +// MockEncoderEncodeCall wrap *gomock.Call +type MockEncoderEncodeCall[T any] struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockEncoderEncodeCall[T]) Return(arg0 []byte, arg1 error) *MockEncoderEncodeCall[T] { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockEncoderEncodeCall[T]) Do(f func(context.Context, T) ([]byte, error)) *MockEncoderEncodeCall[T] { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockEncoderEncodeCall[T]) DoAndReturn(f func(context.Context, T) ([]byte, error)) *MockEncoderEncodeCall[T] { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockBatchHandler is a mock of BatchHandler interface. +type MockBatchHandler[T any] struct { + ctrl *gomock.Controller + recorder *MockBatchHandlerMockRecorder[T] + isgomock struct{} +} + +// MockBatchHandlerMockRecorder is the mock recorder for MockBatchHandler. +type MockBatchHandlerMockRecorder[T any] struct { + mock *MockBatchHandler[T] +} + +// NewMockBatchHandler creates a new mock instance. +func NewMockBatchHandler[T any](ctrl *gomock.Controller) *MockBatchHandler[T] { + mock := &MockBatchHandler[T]{ctrl: ctrl} + mock.recorder = &MockBatchHandlerMockRecorder[T]{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBatchHandler[T]) EXPECT() *MockBatchHandlerMockRecorder[T] { + return m.recorder +} + +// Handle mocks base method. +func (m *MockBatchHandler[T]) Handle(ctx context.Context, batch *Batch[T]) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Handle", ctx, batch) + ret0, _ := ret[0].(error) + return ret0 +} + +// Handle indicates an expected call of Handle. +func (mr *MockBatchHandlerMockRecorder[T]) Handle(ctx, batch any) *MockBatchHandlerHandleCall[T] { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockBatchHandler[T])(nil).Handle), ctx, batch) + return &MockBatchHandlerHandleCall[T]{Call: call} +} + +// MockBatchHandlerHandleCall wrap *gomock.Call +type MockBatchHandlerHandleCall[T any] struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBatchHandlerHandleCall[T]) Return(arg0 error) *MockBatchHandlerHandleCall[T] { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBatchHandlerHandleCall[T]) Do(f func(context.Context, *Batch[T]) error) *MockBatchHandlerHandleCall[T] { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBatchHandlerHandleCall[T]) DoAndReturn(f func(context.Context, *Batch[T]) error) *MockBatchHandlerHandleCall[T] { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/kafka/dlq.go b/kafka/dlq.go new file mode 100644 index 0000000..8661527 --- /dev/null +++ b/kafka/dlq.go @@ -0,0 +1,79 @@ +package kafka + +import ( + "context" + "strconv" + + "github.com/twmb/franz-go/pkg/kgo" +) + +const ( + // DLQHeaderOriginalTopic contains the source record topic. + DLQHeaderOriginalTopic = "kafka-dlq-original-topic" + // DLQHeaderOriginalPartition contains the source partition in base 10. + DLQHeaderOriginalPartition = "kafka-dlq-original-partition" + // DLQHeaderOriginalOffset contains the source offset in base 10. + DLQHeaderOriginalOffset = "kafka-dlq-original-offset" + // DLQHeaderFailureKind is either "decode" or "handler" and never contains + // an error string. + DLQHeaderFailureKind = "kafka-dlq-failure-kind" +) + +type recordRejection struct { + record *kgo.Record + kind string + cause error +} + +func (consumer *Consumer[T]) deliverDLQ(ctx context.Context, rejections []recordRejection) (bool, error) { + if len(rejections) == 0 { + return true, nil + } + records := make([]*kgo.Record, len(rejections)) + for index, rejection := range rejections { + records[index] = dlqRecord(consumer.config.DLQ.Topic, rejection) + } + sources := make([]*kgo.Record, len(rejections)) + for index, rejection := range rejections { + sources[index] = rejection.record + } + err := doWithRetry(ctx, effectiveRetry(consumer.config.Retry, consumer.config.DLQ.Retry), effectiveObserver(consumer.config.Observer), AttemptDLQ, sources, func(produceCtx context.Context) error { + return deliveryError(consumer.client.ProduceSync(produceCtx, records...)) + }) + if err != nil && consumer.config.DLQ.OnFailure == DLQFailureStop { + return false, err + } + return err == nil, nil +} + +func dlqRecord(topic string, rejection recordRejection) *kgo.Record { + source := rejection.record + headers := make([]kgo.RecordHeader, 0, len(source.Headers)+4) + for _, header := range source.Headers { + if !isDLQHeader(header.Key) { + headers = append(headers, header) + } + } + headers = append(headers, + kgo.RecordHeader{Key: DLQHeaderOriginalTopic, Value: []byte(source.Topic)}, + kgo.RecordHeader{Key: DLQHeaderOriginalPartition, Value: []byte(strconv.FormatInt(int64(source.Partition), 10))}, + kgo.RecordHeader{Key: DLQHeaderOriginalOffset, Value: []byte(strconv.FormatInt(source.Offset, 10))}, + kgo.RecordHeader{Key: DLQHeaderFailureKind, Value: []byte(rejection.kind)}, + ) + return &kgo.Record{ + Topic: topic, + Key: source.Key, + Value: source.Value, + Headers: headers, + Timestamp: source.Timestamp, + } +} + +func isDLQHeader(key string) bool { + switch key { + case DLQHeaderOriginalTopic, DLQHeaderOriginalPartition, DLQHeaderOriginalOffset, DLQHeaderFailureKind: + return true + default: + return false + } +} diff --git a/kafka/doc.go b/kafka/doc.go new file mode 100644 index 0000000..1e13f5b --- /dev/null +++ b/kafka/doc.go @@ -0,0 +1,2 @@ +// Package kafka provides typed, at-least-once Kafka consumers and producers. +package kafka diff --git a/kafka/errors.go b/kafka/errors.go new file mode 100644 index 0000000..7d30937 --- /dev/null +++ b/kafka/errors.go @@ -0,0 +1,39 @@ +package kafka + +import "fmt" + +// DecodeError reports a record value that could not be decoded. Index refers +// to the raw batch passed through the consumer. +type DecodeError struct { + Index int + Err error +} + +// Error implements error. +func (err *DecodeError) Error() string { + return fmt.Sprintf("kafka: decode record %d: %v", err.Index, err.Err) +} + +// Unwrap returns the decoder failure. +func (err *DecodeError) Unwrap() error { + return err.Err +} + +// RejectedMessageError reports a permanently rejected Kafka record for which +// the configured policy stopped consumption. +type RejectedMessageError struct { + Topic string + Partition int32 + Offset int64 + Cause error +} + +// Error implements error. +func (err *RejectedMessageError) Error() string { + return fmt.Sprintf("kafka: rejected message %s/%d at offset %d: %v", err.Topic, err.Partition, err.Offset, err.Cause) +} + +// Unwrap returns the handler-provided rejection cause. +func (err *RejectedMessageError) Unwrap() error { + return err.Cause +} diff --git a/kafka/go.mod b/kafka/go.mod new file mode 100644 index 0000000..f2c0298 --- /dev/null +++ b/kafka/go.mod @@ -0,0 +1,36 @@ +module github.com/devctllabs/go-libs/kafka + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/retry v0.1.0 + github.com/stretchr/testify v1.11.1 + github.com/twmb/franz-go v1.21.1 + github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe + github.com/twmb/franz-go/plugin/kotel v1.7.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk v1.34.0 + go.opentelemetry.io/otel/trace v1.44.0 + go.uber.org/mock v0.6.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool go.uber.org/mock/mockgen diff --git a/kafka/go.sum b/kafka/go.sum new file mode 100644 index 0000000..088465b --- /dev/null +++ b/kafka/go.sum @@ -0,0 +1,66 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twmb/franz-go v1.21.1 h1:sp17bMRLz6OB/w+7vHtBadHGIQVymzQHwvRbEKe5c4I= +github.com/twmb/franz-go v1.21.1/go.mod h1:1o+jj5oRbItsIMoE+DGpfJIcPcPtDdtkcNFPj4bWNwU= +github.com/twmb/franz-go/pkg/kadm v1.18.0 h1:WRf/LZmDdcDXwX7WMbtDU++v+b3NzYh2bCGoPMmzirw= +github.com/twmb/franz-go/pkg/kadm v1.18.0/go.mod h1:XeLhGoLXLFzK8/ryv5FfpxPxGwj4oFEGpPJMB/x6KDE= +github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe h1:IweTEfQRTN98RFYKWLBqpXw7r1xwdiZ+vsdw7pnVcAY= +github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe/go.mod h1:9j4VxU2ng6tHgD4lIkNJ5OJ3D6vgPhhIp3tBa7dJgLA= +github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI= +github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY= +github.com/twmb/franz-go/plugin/kotel v1.7.0 h1:TAj9zmeqtnH0z4m7+ooa7EEbDIMIvvDdAqejIhNZjB4= +github.com/twmb/franz-go/plugin/kotel v1.7.0/go.mod h1:Cq5tsiazIWro0y/SNpYEwoVW0C6KK1dIYyhccDXV9bs= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/kafka/handler.go b/kafka/handler.go new file mode 100644 index 0000000..bf0baab --- /dev/null +++ b/kafka/handler.go @@ -0,0 +1,54 @@ +package kafka + +import "context" + +// BatchHandler processes one immutable batch. A non-nil error retries the +// whole batch and discards per-message decisions from that attempt. +type BatchHandler[T any] interface { + // Handle processes batch synchronously and must stop work when ctx is + // canceled. Implementations must not retain batch data after returning. + Handle(ctx context.Context, batch *Batch[T]) error +} + +// BatchHandlerFunc adapts a function to BatchHandler. +type BatchHandlerFunc[T any] func(ctx context.Context, batch *Batch[T]) error + +// Handle calls handler(ctx, batch). +func (handler BatchHandlerFunc[T]) Handle(ctx context.Context, batch *Batch[T]) error { + return handler(ctx, batch) +} + +// TopicPartition uniquely identifies one Kafka partition. +type TopicPartition struct { + Topic string + Partition int32 +} + +// PartitionHandler is owned by one assigned partition and is never called +// concurrently by PartitionedConsumer. +type PartitionHandler[T any] interface { + BatchHandler[T] + // Close releases partition-scoped resources after processing stops. + Close(ctx context.Context) error +} + +// PartitionHandlerFactory creates one handler for each observed partition. +// Implementations must be concurrency-safe. +type PartitionHandlerFactory[T any] interface { + // NewHandler constructs the handler owned by partition. + NewHandler(ctx context.Context, partition TopicPartition) (PartitionHandler[T], error) +} + +// PartitionHandlerFactoryFunc adapts a function to PartitionHandlerFactory. +type PartitionHandlerFactoryFunc[T any] func( + ctx context.Context, + partition TopicPartition, +) (PartitionHandler[T], error) + +// NewHandler calls factory(ctx, partition). +func (factory PartitionHandlerFactoryFunc[T]) NewHandler( + ctx context.Context, + partition TopicPartition, +) (PartitionHandler[T], error) { + return factory(ctx, partition) +} diff --git a/kafka/integration_test.go b/kafka/integration_test.go new file mode 100644 index 0000000..e4245df --- /dev/null +++ b/kafka/integration_test.go @@ -0,0 +1,48 @@ +package kafka_test + +import ( + "context" + "testing" + "time" + + "github.com/devctllabs/go-libs/kafka" + "github.com/stretchr/testify/require" + "github.com/twmb/franz-go/pkg/kfake" + "github.com/twmb/franz-go/pkg/kgo" +) + +func TestProducerRoundTripWithKfake(t *testing.T) { + t.Parallel() + + cluster, err := kfake.NewCluster(kfake.SeedTopics(1, "invoices")) + require.NoError(t, err) + t.Cleanup(cluster.Close) + producer, err := kafka.NewProducer( + kafka.ProducerConfig{Brokers: cluster.ListenAddrs()}, + kafka.EncoderFunc[string](func(_ context.Context, value string) ([]byte, error) { + return []byte(value), nil + }), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, producer.Close(context.Background())) }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, producer.Send(ctx, kafka.OutgoingMessage[string]{ + Topic: "invoices", Key: []byte("42"), Value: "invoice-42", + })) + + consumer, err := kgo.NewClient( + kgo.SeedBrokers(cluster.ListenAddrs()...), + kgo.ConsumePartitions(map[string]map[int32]kgo.Offset{ + "invoices": {0: kgo.NewOffset().AtStart()}, + }), + ) + require.NoError(t, err) + t.Cleanup(consumer.Close) + fetches := consumer.PollRecords(ctx, 1) + require.NoError(t, fetches.Err()) + require.Len(t, fetches.Records(), 1) + require.Equal(t, []byte("42"), fetches.Records()[0].Key) + require.Equal(t, []byte("invoice-42"), fetches.Records()[0].Value) +} diff --git a/kafka/message.go b/kafka/message.go new file mode 100644 index 0000000..b78b453 --- /dev/null +++ b/kafka/message.go @@ -0,0 +1,22 @@ +package kafka + +import "time" + +// Header is one Kafka record header. Value is borrowed for the duration of the +// operation that supplied it. +type Header struct { + Key string + Value []byte +} + +// Message is a decoded Kafka record. Handlers must treat Message and all data +// reachable from it as immutable and must not retain it after Handle returns. +type Message[T any] struct { + Topic string + Partition int32 + Offset int64 + Timestamp time.Time + Key []byte + Headers []Header + Value T +} diff --git a/kafka/observer.go b/kafka/observer.go new file mode 100644 index 0000000..a367691 --- /dev/null +++ b/kafka/observer.go @@ -0,0 +1,180 @@ +package kafka + +import ( + "context" + "errors" + "time" + + "github.com/twmb/franz-go/pkg/kgo" +) + +// AttemptPhase identifies an independently retried operation. +type AttemptPhase string + +const ( + AttemptHandler AttemptPhase = "handler" + AttemptDLQ AttemptPhase = "dlq" + AttemptCommit AttemptPhase = "commit" +) + +// RecordMetadata identifies a record without retaining its payload. +type RecordMetadata struct { + Topic string + Partition int32 + Offset int64 + // Context carries extracted trace state and must not be retained after the + // observer call. + Context context.Context +} + +// Attempt describes one operation call. Attempt numbering starts at one. +type Attempt struct { + Phase AttemptPhase + Attempt uint + Records []RecordMetadata +} + +// AttemptDone completes instrumentation for an attempt. +type AttemptDone func(err error) + +// RetryEvent reports a retryable failure and its next delay. +type RetryEvent struct { + Phase AttemptPhase + Attempt uint + NextDelay time.Duration + Err error +} + +// BatchResult reports outcomes after a successful offset commit. +type BatchResult struct { + Size int + Processed int + Skipped int + Rejected int + Dropped int + DLQ int +} + +// DispositionKind identifies terminal handling of a rejected record. +type DispositionKind string + +const ( + DispositionDropped DispositionKind = "dropped" + DispositionDLQ DispositionKind = "dlq" +) + +// RecordDisposition reports a rejected record that was dropped or delivered +// to a DLQ. +type RecordDisposition struct { + Record RecordMetadata + Kind DispositionKind + Cause error +} + +// Observer receives synchronous lifecycle signals. Implementations must be +// concurrency-safe and should return quickly. +type Observer interface { + StartAttempt(ctx context.Context, attempt Attempt) (context.Context, AttemptDone) + Retry(ctx context.Context, event RetryEvent) + BatchCompleted(ctx context.Context, result BatchResult) + RecordDisposition(ctx context.Context, disposition RecordDisposition) +} + +type noopObserver struct{} + +func (noopObserver) StartAttempt(ctx context.Context, _ Attempt) (context.Context, AttemptDone) { + return ctx, func(error) {} +} +func (noopObserver) Retry(context.Context, RetryEvent) {} +func (noopObserver) BatchCompleted(context.Context, BatchResult) {} +func (noopObserver) RecordDisposition(context.Context, RecordDisposition) {} + +func effectiveObserver(observer Observer) Observer { + if observer == nil { + return noopObserver{} + } + return observer +} + +type multiObserver struct { + observers []Observer +} + +// NewMultiObserver composes observers in registration order. Nil observers +// are rejected as configuration errors. +func NewMultiObserver(observers ...Observer) (Observer, error) { + for _, observer := range observers { + if observer == nil { + return nil, errors.New("kafka: observer must not be nil") + } + } + return multiObserver{observers: append([]Observer(nil), observers...)}, nil +} + +func (observer multiObserver) StartAttempt(ctx context.Context, attempt Attempt) (context.Context, AttemptDone) { + doneCallbacks := make([]AttemptDone, 0, len(observer.observers)) + for _, child := range observer.observers { + var done AttemptDone + ctx, done = child.StartAttempt(ctx, attempt) + if done == nil { + done = func(error) {} + } + doneCallbacks = append(doneCallbacks, done) + } + return ctx, func(err error) { + for index := len(doneCallbacks) - 1; index >= 0; index-- { + doneCallbacks[index](err) + } + } +} + +func (observer multiObserver) Retry(ctx context.Context, event RetryEvent) { + for _, child := range observer.observers { + child.Retry(ctx, event) + } +} + +func (observer multiObserver) BatchCompleted(ctx context.Context, result BatchResult) { + for _, child := range observer.observers { + child.BatchCompleted(ctx, result) + } +} + +func (observer multiObserver) RecordDisposition(ctx context.Context, disposition RecordDisposition) { + for _, child := range observer.observers { + child.RecordDisposition(ctx, disposition) + } +} + +func (observer multiObserver) Hooks(group string) []kgo.Hook { + var hooks []kgo.Hook + for _, child := range observer.observers { + if provider, ok := child.(interface{ Hooks(group string) []kgo.Hook }); ok { + hooks = append(hooks, provider.Hooks(group)...) + } + } + return hooks +} + +func metadata(records []*kgo.Record) []RecordMetadata { + result := make([]RecordMetadata, len(records)) + for index, record := range records { + result[index] = recordMetadata(record) + } + return result +} + +func recordMetadata(record *kgo.Record) RecordMetadata { + return RecordMetadata{ + Topic: record.Topic, Partition: record.Partition, Offset: record.Offset, + Context: record.Context, + } +} + +func observerHooks(observer Observer, group string) []kgo.Hook { + provider, ok := observer.(interface{ Hooks(group string) []kgo.Hook }) + if !ok { + return nil + } + return provider.Hooks(group) +} diff --git a/kafka/observer_test.go b/kafka/observer_test.go new file mode 100644 index 0000000..2be849f --- /dev/null +++ b/kafka/observer_test.go @@ -0,0 +1,46 @@ +package kafka + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMultiObserverComposesAttemptLifecycle(t *testing.T) { + t.Parallel() + + var events []string + first := observerFuncs{ + start: func(ctx context.Context, _ Attempt) (context.Context, AttemptDone) { + events = append(events, "start-first") + return ctx, func(error) { events = append(events, "done-first") } + }, + } + second := observerFuncs{ + start: func(ctx context.Context, _ Attempt) (context.Context, AttemptDone) { + events = append(events, "start-second") + return ctx, func(error) { events = append(events, "done-second") } + }, + } + observer, err := NewMultiObserver(first, second) + require.NoError(t, err) + + _, done := observer.StartAttempt(context.Background(), Attempt{Phase: AttemptHandler, Attempt: 1}) + done(nil) + + require.Equal(t, []string{ + "start-first", "start-second", "done-second", "done-first", + }, events) +} + +type observerFuncs struct { + start func(context.Context, Attempt) (context.Context, AttemptDone) +} + +func (observer observerFuncs) StartAttempt(ctx context.Context, attempt Attempt) (context.Context, AttemptDone) { + return observer.start(ctx, attempt) +} +func (observerFuncs) Retry(context.Context, RetryEvent) {} +func (observerFuncs) BatchCompleted(context.Context, BatchResult) {} +func (observerFuncs) RecordDisposition(context.Context, RecordDisposition) {} diff --git a/kafka/otel.go b/kafka/otel.go new file mode 100644 index 0000000..9f0315e --- /dev/null +++ b/kafka/otel.go @@ -0,0 +1,228 @@ +package kafka + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/twmb/franz-go/pkg/kgo" + "github.com/twmb/franz-go/plugin/kotel" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +const instrumentationName = "github.com/devctllabs/go-libs/kafka" + +// OTelObserverConfig configures standard franz-go hooks and wrapper-level +// tracing and metrics. +type OTelObserverConfig struct { + MeterProvider metric.MeterProvider + TracerProvider trace.TracerProvider + Propagator propagation.TextMapPropagator + MaxBatchSpanLinks int + // DisablePartitionAttribute removes partition from custom metrics when its + // cardinality is too high for the deployment. + DisablePartitionAttribute bool +} + +// OTelObserver implements Observer and supplies standard franz-go OTel hooks. +type OTelObserver struct { + meterProvider metric.MeterProvider + tracerProvider trace.TracerProvider + propagator propagation.TextMapPropagator + tracer trace.Tracer + maxLinks int + partition bool + + attempts metric.Int64Counter + retries metric.Int64Counter + messages metric.Int64Counter + dispositions metric.Int64Counter + inflight metric.Int64UpDownCounter + duration metric.Float64Histogram + batchSize metric.Int64Histogram +} + +// NewOTelObserver constructs wrapper instruments. A zero link cap uses 128; +// a negative cap is invalid. +func NewOTelObserver(config OTelObserverConfig) (*OTelObserver, error) { + if config.MaxBatchSpanLinks < 0 { + return nil, errors.New("kafka: maximum batch span links must not be negative") + } + if config.MaxBatchSpanLinks == 0 { + config.MaxBatchSpanLinks = 128 + } + if config.MeterProvider == nil { + config.MeterProvider = otel.GetMeterProvider() + } + if config.TracerProvider == nil { + config.TracerProvider = otel.GetTracerProvider() + } + if config.Propagator == nil { + config.Propagator = otel.GetTextMapPropagator() + } + meter := config.MeterProvider.Meter(instrumentationName) + observer := &OTelObserver{ + meterProvider: config.MeterProvider, tracerProvider: config.TracerProvider, + propagator: config.Propagator, tracer: config.TracerProvider.Tracer(instrumentationName), + maxLinks: config.MaxBatchSpanLinks, partition: !config.DisablePartitionAttribute, + } + var err error + observer.attempts, err = meter.Int64Counter("kafka.consumer.operation.attempts") + if err != nil { + return nil, fmt.Errorf("kafka: create attempts counter: %w", err) + } + observer.retries, err = meter.Int64Counter("kafka.consumer.operation.retries") + if err != nil { + return nil, fmt.Errorf("kafka: create retries counter: %w", err) + } + observer.messages, err = meter.Int64Counter("kafka.consumer.messages") + if err != nil { + return nil, fmt.Errorf("kafka: create messages counter: %w", err) + } + observer.dispositions, err = meter.Int64Counter("kafka.consumer.record.dispositions") + if err != nil { + return nil, fmt.Errorf("kafka: create dispositions counter: %w", err) + } + observer.inflight, err = meter.Int64UpDownCounter("kafka.consumer.operation.inflight") + if err != nil { + return nil, fmt.Errorf("kafka: create in-flight counter: %w", err) + } + observer.duration, err = meter.Float64Histogram( + "kafka.consumer.operation.duration", + metric.WithUnit("s"), + ) + if err != nil { + return nil, fmt.Errorf("kafka: create duration histogram: %w", err) + } + observer.batchSize, err = meter.Int64Histogram("kafka.consumer.batch.size", metric.WithUnit("{message}")) + if err != nil { + return nil, fmt.Errorf("kafka: create batch size histogram: %w", err) + } + return observer, nil +} + +// Hooks returns standard broker, fetch, produce, and propagation hooks for a +// franz-go client. group may be blank for producers. +func (observer *OTelObserver) Hooks(group string) []kgo.Hook { + tracerOptions := []kotel.TracerOpt{ + kotel.TracerProvider(observer.tracerProvider), + kotel.TracerPropagator(observer.propagator), + } + if group != "" { + tracerOptions = append(tracerOptions, kotel.ConsumerGroup(group)) + } + return kotel.NewKotel( + kotel.WithMeter(kotel.NewMeter(kotel.MeterProvider(observer.meterProvider))), + kotel.WithTracer(kotel.NewTracer(tracerOptions...)), + ).Hooks() +} + +// StartAttempt starts one root consumer span linked to unique upstream record +// contexts and updates attempt metrics. +func (observer *OTelObserver) StartAttempt(ctx context.Context, attempt Attempt) (context.Context, AttemptDone) { + attributes := observer.attemptAttributes(attempt) + observer.attempts.Add(ctx, 1, metric.WithAttributes(attributes...)) + observer.inflight.Add(ctx, 1, metric.WithAttributes(attributes...)) + started := time.Now() + spanCtx, span := observer.tracer.Start( + ctx, + "kafka "+string(attempt.Phase)+" attempt", + trace.WithNewRoot(), + trace.WithSpanKind(trace.SpanKindConsumer), + trace.WithAttributes(attributes...), + trace.WithLinks(observer.links(attempt.Records)...), + ) + var once sync.Once + return spanCtx, func(err error) { + once.Do(func() { + observer.inflight.Add(spanCtx, -1, metric.WithAttributes(attributes...)) + observer.duration.Record( + spanCtx, + time.Since(started).Seconds(), + metric.WithAttributes(attributes...), + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "operation failed") + } + span.End() + }) + } +} + +// Retry records one retryable failure. Error text is not used as a metric +// attribute. +func (observer *OTelObserver) Retry(ctx context.Context, event RetryEvent) { + observer.retries.Add(ctx, 1, metric.WithAttributes(attribute.String("kafka.phase", string(event.Phase)))) +} + +// BatchCompleted records batch size and outcome counters after commit. +func (observer *OTelObserver) BatchCompleted(ctx context.Context, result BatchResult) { + observer.batchSize.Record(ctx, int64(result.Size)) + observer.addMessages(ctx, "processed", result.Processed) + observer.addMessages(ctx, "skipped", result.Skipped) + observer.addMessages(ctx, "rejected", result.Rejected) +} + +// RecordDisposition records low-cardinality disposition counts. +func (observer *OTelObserver) RecordDisposition(ctx context.Context, disposition RecordDisposition) { + observer.dispositions.Add(ctx, 1, metric.WithAttributes( + attribute.String("kafka.disposition", string(disposition.Kind)), + attribute.String("messaging.destination.name", disposition.Record.Topic), + )) +} + +func (observer *OTelObserver) addMessages(ctx context.Context, outcome string, count int) { + if count > 0 { + observer.messages.Add(ctx, int64(count), metric.WithAttributes(attribute.String("kafka.outcome", outcome))) + } +} + +func (observer *OTelObserver) attemptAttributes(attempt Attempt) []attribute.KeyValue { + attributes := []attribute.KeyValue{ + attribute.String("kafka.phase", string(attempt.Phase)), + attribute.Int("kafka.attempt", int(attempt.Attempt)), + attribute.Int("messaging.batch.message_count", len(attempt.Records)), + } + if len(attempt.Records) == 0 { + return attributes + } + first := attempt.Records[0] + attributes = append(attributes, attribute.String("messaging.destination.name", first.Topic)) + if observer.partition { + attributes = append(attributes, attribute.Int("messaging.kafka.partition", int(first.Partition))) + } + return attributes +} + +func (observer *OTelObserver) links(records []RecordMetadata) []trace.Link { + links := make([]trace.Link, 0, min(len(records), observer.maxLinks)) + type linkKey struct { + traceID trace.TraceID + spanID trace.SpanID + } + seen := make(map[linkKey]struct{}, cap(links)) + for _, record := range records { + spanContext := trace.SpanContextFromContext(record.Context) + if !spanContext.IsValid() { + continue + } + key := linkKey{traceID: spanContext.TraceID(), spanID: spanContext.SpanID()} + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + links = append(links, trace.Link{SpanContext: spanContext}) + if len(links) == observer.maxLinks { + break + } + } + return links +} diff --git a/kafka/otel_test.go b/kafka/otel_test.go new file mode 100644 index 0000000..9270e69 --- /dev/null +++ b/kafka/otel_test.go @@ -0,0 +1,57 @@ +package kafka + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +func TestOTelObserverCreatesCappedDeduplicatedAttemptLinks(t *testing.T) { + t.Parallel() + + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) + observer, err := NewOTelObserver(OTelObserverConfig{ + TracerProvider: provider, + MaxBatchSpanLinks: 1, + }) + require.NoError(t, err) + first := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{1}, SpanID: trace.SpanID{1}, Remote: true, + }) + second := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{2}, SpanID: trace.SpanID{2}, Remote: true, + }) + + _, done := observer.StartAttempt(context.Background(), Attempt{ + Phase: AttemptHandler, + Attempt: 1, + Records: []RecordMetadata{ + {Topic: "invoices", Partition: 1, Context: trace.ContextWithSpanContext(context.Background(), first)}, + {Topic: "invoices", Partition: 1, Context: trace.ContextWithSpanContext(context.Background(), first)}, + {Topic: "invoices", Partition: 1, Context: trace.ContextWithSpanContext(context.Background(), second)}, + }, + }) + done(nil) + + spans := recorder.Ended() + require.Len(t, spans, 1) + require.Equal(t, "kafka handler attempt", spans[0].Name()) + require.Len(t, spans[0].Links(), 1) + require.Equal(t, first, spans[0].Links()[0].SpanContext) +} + +func TestOTelObserverSuppliesStandardFranzHooks(t *testing.T) { + t.Parallel() + + observer, err := NewOTelObserver(OTelObserverConfig{}) + require.NoError(t, err) + + require.Len(t, observerHooks(observer, "invoice-consumer"), 2) + require.Empty(t, observerHooks(noopObserver{}, "invoice-consumer")) +} diff --git a/kafka/partitioned_consumer.go b/kafka/partitioned_consumer.go new file mode 100644 index 0000000..74824f3 --- /dev/null +++ b/kafka/partitioned_consumer.go @@ -0,0 +1,281 @@ +package kafka + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/twmb/franz-go/pkg/kgo" +) + +// PartitionedConsumerConfig configures partition-isolated processing. +type PartitionedConsumerConfig struct { + Consumer ConsumerConfig + // MaxConcurrentPartitions limits active partition handlers. Zero is + // unlimited. + MaxConcurrentPartitions int +} + +func (config PartitionedConsumerConfig) validate() error { + if err := config.Consumer.validate(); err != nil { + return err + } + if config.MaxConcurrentPartitions < 0 { + return errors.New("kafka: maximum concurrent partitions must not be negative") + } + return nil +} + +// PartitionedConsumer processes different partitions concurrently while +// preserving sequential handling within each partition. +type PartitionedConsumer[T any] struct { + config PartitionedConsumerConfig + client consumerClient + decoder Decoder[T] + factory PartitionHandlerFactory[T] + + mu sync.Mutex + started bool +} + +// NewPartitionedConsumer constructs a single-use partition-isolated consumer. +func NewPartitionedConsumer[T any]( + config PartitionedConsumerConfig, + decoder Decoder[T], + factory PartitionHandlerFactory[T], +) (*PartitionedConsumer[T], error) { + if err := config.validate(); err != nil { + return nil, err + } + if decoder == nil { + return nil, errors.New("kafka: decoder must not be nil") + } + if factory == nil { + return nil, errors.New("kafka: partition handler factory must not be nil") + } + consumerConfig := config.Consumer + options := []kgo.Opt{ + kgo.SeedBrokers(consumerConfig.Brokers...), + kgo.ConsumerGroup(consumerConfig.Group), + kgo.ConsumeTopics(consumerConfig.Topics...), + kgo.DisableAutoCommit(), + kgo.BlockRebalanceOnPoll(), + kgo.RebalanceTimeout(consumerConfig.RebalanceTimeout), + kgo.RequiredAcks(kgo.AllISRAcks()), + } + if hooks := observerHooks(consumerConfig.Observer, consumerConfig.Group); len(hooks) > 0 { + options = append(options, kgo.WithHooks(hooks...)) + } + client, err := kgo.NewClient(options...) + if err != nil { + return nil, fmt.Errorf("kafka: create partitioned consumer client: %w", err) + } + return newPartitionedConsumerWithClient(config, client, decoder, factory), nil +} + +// Run polls and processes records until cancellation or a terminal partition +// failure. It owns the client and all factory-created handlers. +func (consumer *PartitionedConsumer[T]) Run(ctx context.Context) error { + if ctx == nil { + return errors.New("kafka: context must not be nil") + } + consumer.mu.Lock() + if consumer.started { + consumer.mu.Unlock() + return ErrAlreadyRun + } + consumer.started = true + consumer.mu.Unlock() + defer consumer.client.Close() + + handlers := make(map[TopicPartition]PartitionHandler[T]) + defer func() { consumer.closeHandlers(ctx, handlers) }() + serializedClient := &serializedCommitClient{consumerClient: consumer.client} + pending := make(map[TopicPartition][]*kgo.Record) + flushAt := make(map[TopicPartition]time.Time) + for ctx.Err() == nil { + records, err := consumer.pollRecords(ctx, flushAt) + if err != nil { + return err + } + for partition, records := range groupByPartition(records) { + if len(pending[partition]) == 0 { + flushAt[partition] = time.Now().Add(consumer.config.Consumer.Batch.FlushInterval) + } + pending[partition] = append(pending[partition], records...) + } + ready := readyPartitions(pending, flushAt, consumer.config.Consumer.Batch.MaxSize, time.Now()) + if len(ready) == 0 { + continue + } + if err := consumer.processPartitions(ctx, ready, handlers, serializedClient); err != nil { + return err + } + for partition := range ready { + delete(pending, partition) + delete(flushAt, partition) + } + if len(pending) == 0 { + consumer.client.AllowRebalance() + } + } + if len(pending) > 0 { + drainCtx, cancelDrain := context.WithTimeout( + context.WithoutCancel(ctx), + consumer.config.Consumer.ShutdownTimeout, + ) + defer cancelDrain() + if err := consumer.processPartitions(drainCtx, pending, handlers, serializedClient); err != nil { + return fmt.Errorf("kafka: drain partition batches: %w", err) + } + consumer.client.AllowRebalance() + } + return cleanCancellation(ctx) +} + +func (consumer *PartitionedConsumer[T]) pollRecords( + ctx context.Context, + flushAt map[TopicPartition]time.Time, +) ([]*kgo.Record, error) { + pollCtx := ctx + cancelPoll := func() {} + if deadline, ok := earliestDeadline(flushAt); ok { + pollCtx, cancelPoll = context.WithDeadline(ctx, deadline) + } + fetches := consumer.client.PollRecords(pollCtx, consumer.config.Consumer.Batch.MaxSize) + flushExpired := errors.Is(pollCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil + cancelPoll() + if err := fetches.Err(); err != nil { + if flushExpired { + return nil, nil + } + if ctx.Err() != nil || errors.Is(err, context.Canceled) || fetches.IsClientClosed() { + return nil, nil + } + return nil, fmt.Errorf("kafka: poll partition records: %w", err) + } + return fetches.Records(), nil +} + +func earliestDeadline(deadlines map[TopicPartition]time.Time) (time.Time, bool) { + var earliest time.Time + for _, deadline := range deadlines { + if earliest.IsZero() || deadline.Before(earliest) { + earliest = deadline + } + } + return earliest, !earliest.IsZero() +} + +func readyPartitions( + pending map[TopicPartition][]*kgo.Record, + deadlines map[TopicPartition]time.Time, + maxSize int, + now time.Time, +) map[TopicPartition][]*kgo.Record { + ready := make(map[TopicPartition][]*kgo.Record) + for partition, records := range pending { + if len(records) >= maxSize || !deadlines[partition].After(now) { + ready[partition] = records + } + } + return ready +} + +func (consumer *PartitionedConsumer[T]) processPartitions( + ctx context.Context, + grouped map[TopicPartition][]*kgo.Record, + handlers map[TopicPartition]PartitionHandler[T], + client consumerClient, +) error { + limit := consumer.config.MaxConcurrentPartitions + if limit == 0 || limit > len(grouped) { + limit = len(grouped) + } + semaphore := make(chan struct{}, limit) + errorsChannel := make(chan error, len(grouped)) + var group sync.WaitGroup + for partition, records := range grouped { + handler, ok := handlers[partition] + if !ok { + var err error + handler, err = consumer.factory.NewHandler(ctx, partition) + if err != nil { + return fmt.Errorf("kafka: create handler for %s/%d: %w", partition.Topic, partition.Partition, err) + } + if handler == nil { + return fmt.Errorf("kafka: factory returned nil handler for %s/%d", partition.Topic, partition.Partition) + } + handlers[partition] = handler + } + group.Add(1) + go func(records []*kgo.Record, handler PartitionHandler[T]) { + defer group.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + processor := &Consumer[T]{ + config: consumer.config.Consumer, client: client, + decoder: consumer.decoder, handler: handler, + } + maxSize := consumer.config.Consumer.Batch.MaxSize + for start := 0; start < len(records); start += maxSize { + end := min(start+maxSize, len(records)) + if err := processor.processBatch(ctx, records[start:end]); err != nil { + errorsChannel <- err + return + } + } + }(records, handler) + } + group.Wait() + close(errorsChannel) + for err := range errorsChannel { + return fmt.Errorf("kafka: process partition: %w", err) + } + return nil +} + +func (consumer *PartitionedConsumer[T]) closeHandlers( + ctx context.Context, + handlers map[TopicPartition]PartitionHandler[T], +) { + closeCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), + consumer.config.Consumer.ShutdownTimeout, + ) + defer cancel() + for _, handler := range handlers { + _ = handler.Close(closeCtx) + } +} + +func groupByPartition(records []*kgo.Record) map[TopicPartition][]*kgo.Record { + grouped := make(map[TopicPartition][]*kgo.Record) + for _, record := range records { + partition := TopicPartition{Topic: record.Topic, Partition: record.Partition} + grouped[partition] = append(grouped[partition], record) + } + return grouped +} + +type serializedCommitClient struct { + consumerClient + mu sync.Mutex +} + +func (client *serializedCommitClient) CommitRecords(ctx context.Context, records ...*kgo.Record) error { + client.mu.Lock() + defer client.mu.Unlock() + return client.consumerClient.CommitRecords(ctx, records...) +} + +func newPartitionedConsumerWithClient[T any]( + config PartitionedConsumerConfig, + client consumerClient, + decoder Decoder[T], + factory PartitionHandlerFactory[T], +) *PartitionedConsumer[T] { + return &PartitionedConsumer[T]{config: config, client: client, decoder: decoder, factory: factory} +} diff --git a/kafka/partitioned_consumer_test.go b/kafka/partitioned_consumer_test.go new file mode 100644 index 0000000..07da7e5 --- /dev/null +++ b/kafka/partitioned_consumer_test.go @@ -0,0 +1,114 @@ +package kafka + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/twmb/franz-go/pkg/kgo" + "go.uber.org/mock/gomock" +) + +func TestPartitionedConsumerNeverMixesPartitionsAndClosesHandlers(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := NewMockconsumerClient(ctrl) + decoder := NewMockDecoder[string](ctrl) + first := &kgo.Record{Topic: "invoices", Partition: 1, Offset: 10, Value: []byte("first")} + second := &kgo.Record{Topic: "invoices", Partition: 2, Offset: 20, Value: []byte("second")} + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gomock.InOrder( + client.EXPECT().PollRecords(gomock.Any(), 2).Return(kgo.Fetches{{Topics: []kgo.FetchTopic{{ + Topic: "invoices", + Partitions: []kgo.FetchPartition{ + {Partition: 1, Records: []*kgo.Record{first}}, + {Partition: 2, Records: []*kgo.Record{second}}, + }, + }}}}), + client.EXPECT().PollRecords(gomock.Any(), 2).DoAndReturn( + func(ctx context.Context, _ int) kgo.Fetches { + <-ctx.Done() + return nil + }, + ), + ) + decoder.EXPECT().Decode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, value []byte) (string, error) { return string(value), nil }, + ).Times(2) + + var mu sync.Mutex + handled := make(map[TopicPartition][]int64) + closed := make(map[TopicPartition]int) + factory := PartitionHandlerFactoryFunc[string](func( + _ context.Context, + partition TopicPartition, + ) (PartitionHandler[string], error) { + return partitionHandlerFuncs[string]{ + handle: func(_ context.Context, batch *Batch[string]) error { + mu.Lock() + defer mu.Unlock() + for _, message := range batch.Messages() { + require.Equal(t, TopicPartition{Topic: message.Topic, Partition: message.Partition}, partition) + handled[partition] = append(handled[partition], message.Offset) + } + return nil + }, + close: func(context.Context) error { + mu.Lock() + defer mu.Unlock() + closed[partition]++ + return nil + }, + }, nil + }) + + commits := 0 + client.EXPECT().CommitRecords(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ ...*kgo.Record) error { + mu.Lock() + defer mu.Unlock() + commits++ + if commits == 2 { + cancel() + } + return nil + }, + ).Times(2) + client.EXPECT().AllowRebalance() + client.EXPECT().Close() + + cfg := validConsumerConfig(t) + cfg.Batch = BatchConfig{MaxSize: 2, FlushInterval: time.Millisecond} + consumer := newPartitionedConsumerWithClient( + PartitionedConsumerConfig{Consumer: cfg, MaxConcurrentPartitions: 2}, + client, + decoder, + factory, + ) + + require.NoError(t, consumer.Run(runCtx)) + mu.Lock() + defer mu.Unlock() + require.Equal(t, []int64{10}, handled[TopicPartition{Topic: "invoices", Partition: 1}]) + require.Equal(t, []int64{20}, handled[TopicPartition{Topic: "invoices", Partition: 2}]) + require.Equal(t, 1, closed[TopicPartition{Topic: "invoices", Partition: 1}]) + require.Equal(t, 1, closed[TopicPartition{Topic: "invoices", Partition: 2}]) +} + +type partitionHandlerFuncs[T any] struct { + handle func(ctx context.Context, batch *Batch[T]) error + close func(ctx context.Context) error +} + +func (handler partitionHandlerFuncs[T]) Handle(ctx context.Context, batch *Batch[T]) error { + return handler.handle(ctx, batch) +} + +func (handler partitionHandlerFuncs[T]) Close(ctx context.Context) error { + return handler.close(ctx) +} diff --git a/kafka/producer.go b/kafka/producer.go new file mode 100644 index 0000000..7e36bd2 --- /dev/null +++ b/kafka/producer.go @@ -0,0 +1,236 @@ +package kafka + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/twmb/franz-go/pkg/kgo" +) + +// ErrClosed reports an operation attempted after a runtime began closing. +var ErrClosed = errors.New("kafka: runtime is closed") + +// ProducerConfig contains the required producer connection values. +type ProducerConfig struct { + Brokers []string + // Observer may provide standard franz-go hooks. Producer operations do not + // emit consumer lifecycle callbacks. + Observer Observer +} + +// OutgoingMessage is one typed record to produce. +type OutgoingMessage[T any] struct { + Topic string + Key []byte + Headers []Header + Value T +} + +// EncodeError reports a local encoding failure before any batch records were +// sent. Index identifies the input message. +type EncodeError struct { + Index int + Err error +} + +// DeliveryFailure describes one failed record from SendBatch. Index refers to +// the caller's input slice; payload data is never retained. +type DeliveryFailure struct { + Index int + Topic string + Partition int32 + Err error +} + +// BatchDeliveryError reports partial or complete broker delivery failure. +type BatchDeliveryError struct { + failures []DeliveryFailure + succeeded int + cause error +} + +// Error implements error. +func (err *BatchDeliveryError) Error() string { + return fmt.Sprintf( + "kafka: deliver batch: %d succeeded, %d failed", + err.succeeded, + len(err.failures), + ) +} + +// Unwrap returns all retained delivery failures as one error chain. +func (err *BatchDeliveryError) Unwrap() error { + return err.cause +} + +// Failures returns a copy of the per-record failure metadata. +func (err *BatchDeliveryError) Failures() []DeliveryFailure { + return append([]DeliveryFailure(nil), err.failures...) +} + +// Succeeded returns the number of records acknowledged without error. +func (err *BatchDeliveryError) Succeeded() int { + return err.succeeded +} + +// Error implements error. +func (err *EncodeError) Error() string { + return fmt.Sprintf("kafka: encode message %d: %v", err.Index, err.Err) +} + +// Unwrap returns the encoder failure. +func (err *EncodeError) Unwrap() error { + return err.Err +} + +// Producer synchronously publishes typed Kafka messages. +type Producer[T any] struct { + client recordProducer + encoder Encoder[T] + + mu sync.Mutex + closed bool + active sync.WaitGroup + close sync.Once +} + +// NewProducer constructs an instance-owned producer without opening a network +// connection. The first send establishes broker connections lazily. +func NewProducer[T any](config ProducerConfig, encoder Encoder[T]) (*Producer[T], error) { + if err := validateStrings("broker", config.Brokers); err != nil { + return nil, err + } + if encoder == nil { + return nil, errors.New("kafka: encoder must not be nil") + } + options := []kgo.Opt{ + kgo.SeedBrokers(config.Brokers...), + kgo.RequiredAcks(kgo.AllISRAcks()), + } + if hooks := observerHooks(config.Observer, ""); len(hooks) > 0 { + options = append(options, kgo.WithHooks(hooks...)) + } + client, err := kgo.NewClient(options...) + if err != nil { + return nil, fmt.Errorf("kafka: create producer client: %w", err) + } + return newProducerWithClient(client, encoder), nil +} + +// Send encodes message and waits for its broker delivery result. +func (producer *Producer[T]) Send(ctx context.Context, message OutgoingMessage[T]) error { + return producer.SendBatch(ctx, []OutgoingMessage[T]{message}) +} + +// SendBatch encodes all messages before sending any and waits for every broker +// delivery result. +func (producer *Producer[T]) SendBatch(ctx context.Context, messages []OutgoingMessage[T]) error { + if ctx == nil { + return errors.New("kafka: context must not be nil") + } + if err := producer.beginSend(); err != nil { + return err + } + defer producer.active.Done() + + records, err := producer.encode(ctx, messages) + if err != nil || len(records) == 0 { + return err + } + return deliveryError(producer.client.ProduceSync(ctx, records...)) +} + +// Close prevents new sends, waits for active sends, and closes the owned +// franz-go client. It is safe to call concurrently and repeatedly. +func (producer *Producer[T]) Close(ctx context.Context) error { + if ctx == nil { + return errors.New("kafka: context must not be nil") + } + producer.mu.Lock() + producer.closed = true + producer.mu.Unlock() + + done := make(chan struct{}) + go func() { + producer.active.Wait() + close(done) + }() + select { + case <-ctx.Done(): + producer.close.Do(producer.client.Close) + return ctx.Err() + case <-done: + producer.close.Do(producer.client.Close) + return nil + } +} + +func (producer *Producer[T]) beginSend() error { + producer.mu.Lock() + defer producer.mu.Unlock() + if producer.closed { + return ErrClosed + } + producer.active.Add(1) + return nil +} + +func (producer *Producer[T]) encode(ctx context.Context, messages []OutgoingMessage[T]) ([]*kgo.Record, error) { + records := make([]*kgo.Record, len(messages)) + for index, message := range messages { + if strings.TrimSpace(message.Topic) == "" { + return nil, fmt.Errorf("kafka: message %d topic must not be blank", index) + } + value, err := producer.encoder.Encode(ctx, message.Value) + if err != nil { + return nil, &EncodeError{Index: index, Err: err} + } + records[index] = &kgo.Record{ + Topic: message.Topic, + Key: message.Key, + Value: value, + Headers: recordHeaders(message.Headers), + } + } + return records, nil +} + +func recordHeaders(headers []Header) []kgo.RecordHeader { + converted := make([]kgo.RecordHeader, len(headers)) + for index, header := range headers { + converted[index] = kgo.RecordHeader{Key: header.Key, Value: header.Value} + } + return converted +} + +func deliveryError(results kgo.ProduceResults) error { + failures := make([]DeliveryFailure, 0) + causes := make([]error, 0) + for index, result := range results { + if result.Err == nil { + continue + } + failures = append(failures, DeliveryFailure{ + Index: index, + Topic: result.Record.Topic, + Partition: result.Record.Partition, + Err: result.Err, + }) + causes = append(causes, result.Err) + } + if len(failures) == 0 { + return nil + } + return &BatchDeliveryError{ + failures: failures, + succeeded: len(results) - len(failures), + cause: errors.Join(causes...), + } +} + +func newProducerWithClient[T any](client recordProducer, encoder Encoder[T]) *Producer[T] { + return &Producer[T]{client: client, encoder: encoder} +} diff --git a/kafka/producer_client.gen_test.go b/kafka/producer_client.gen_test.go new file mode 100644 index 0000000..afad8f5 --- /dev/null +++ b/kafka/producer_client.gen_test.go @@ -0,0 +1,121 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: producer_client.go +// +// Generated by this command: +// +// mockgen -source=producer_client.go -destination=producer_client.gen_test.go -package=kafka -typed +// + +// Package kafka is a generated GoMock package. +package kafka + +import ( + context "context" + reflect "reflect" + + kgo "github.com/twmb/franz-go/pkg/kgo" + gomock "go.uber.org/mock/gomock" +) + +// MockrecordProducer is a mock of recordProducer interface. +type MockrecordProducer struct { + ctrl *gomock.Controller + recorder *MockrecordProducerMockRecorder + isgomock struct{} +} + +// MockrecordProducerMockRecorder is the mock recorder for MockrecordProducer. +type MockrecordProducerMockRecorder struct { + mock *MockrecordProducer +} + +// NewMockrecordProducer creates a new mock instance. +func NewMockrecordProducer(ctrl *gomock.Controller) *MockrecordProducer { + mock := &MockrecordProducer{ctrl: ctrl} + mock.recorder = &MockrecordProducerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockrecordProducer) EXPECT() *MockrecordProducerMockRecorder { + return m.recorder +} + +// Close mocks base method. +func (m *MockrecordProducer) Close() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Close") +} + +// Close indicates an expected call of Close. +func (mr *MockrecordProducerMockRecorder) Close() *MockrecordProducerCloseCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockrecordProducer)(nil).Close)) + return &MockrecordProducerCloseCall{Call: call} +} + +// MockrecordProducerCloseCall wrap *gomock.Call +type MockrecordProducerCloseCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockrecordProducerCloseCall) Return() *MockrecordProducerCloseCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockrecordProducerCloseCall) Do(f func()) *MockrecordProducerCloseCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockrecordProducerCloseCall) DoAndReturn(f func()) *MockrecordProducerCloseCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ProduceSync mocks base method. +func (m *MockrecordProducer) ProduceSync(ctx context.Context, records ...*kgo.Record) kgo.ProduceResults { + m.ctrl.T.Helper() + varargs := []any{ctx} + for _, a := range records { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ProduceSync", varargs...) + ret0, _ := ret[0].(kgo.ProduceResults) + return ret0 +} + +// ProduceSync indicates an expected call of ProduceSync. +func (mr *MockrecordProducerMockRecorder) ProduceSync(ctx any, records ...any) *MockrecordProducerProduceSyncCall { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx}, records...) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProduceSync", reflect.TypeOf((*MockrecordProducer)(nil).ProduceSync), varargs...) + return &MockrecordProducerProduceSyncCall{Call: call} +} + +// MockrecordProducerProduceSyncCall wrap *gomock.Call +type MockrecordProducerProduceSyncCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockrecordProducerProduceSyncCall) Return(arg0 kgo.ProduceResults) *MockrecordProducerProduceSyncCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockrecordProducerProduceSyncCall) Do(f func(context.Context, ...*kgo.Record) kgo.ProduceResults) *MockrecordProducerProduceSyncCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockrecordProducerProduceSyncCall) DoAndReturn(f func(context.Context, ...*kgo.Record) kgo.ProduceResults) *MockrecordProducerProduceSyncCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/kafka/producer_client.go b/kafka/producer_client.go new file mode 100644 index 0000000..c3455a8 --- /dev/null +++ b/kafka/producer_client.go @@ -0,0 +1,16 @@ +package kafka + +import ( + "context" + + "github.com/twmb/franz-go/pkg/kgo" +) + +//go:generate go tool mockgen -source=producer_client.go -destination=producer_client.gen_test.go -package=kafka -typed + +type recordProducer interface { + // ProduceSync sends records and waits for all delivery results. + ProduceSync(ctx context.Context, records ...*kgo.Record) kgo.ProduceResults + // Close closes the owned Kafka client. + Close() +} diff --git a/kafka/producer_test.go b/kafka/producer_test.go new file mode 100644 index 0000000..7447635 --- /dev/null +++ b/kafka/producer_test.go @@ -0,0 +1,92 @@ +package kafka + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "github.com/twmb/franz-go/pkg/kgo" + "go.uber.org/mock/gomock" +) + +func TestProducerEncodesEntireBatchBeforeSending(t *testing.T) { + t.Parallel() + + encodeErr := errors.New("marshal invoice") + producer, err := NewProducer( + ProducerConfig{Brokers: []string{"localhost:9092"}}, + EncoderFunc[string](func(_ context.Context, value string) ([]byte, error) { + if value == "invalid" { + return nil, encodeErr + } + return []byte(value), nil + }), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, producer.Close(context.Background())) }) + + err = producer.SendBatch(context.Background(), []OutgoingMessage[string]{ + {Topic: "invoices", Value: "valid"}, + {Topic: "invoices", Value: "invalid"}, + }) + + var got *EncodeError + require.ErrorAs(t, err, &got) + require.Equal(t, 1, got.Index) + require.ErrorIs(t, got, encodeErr) +} + +func TestProducerReportsPartialDelivery(t *testing.T) { + t.Parallel() + + deliveryErr := errors.New("message too large") + client := NewMockrecordProducer(gomock.NewController(t)) + client.EXPECT().ProduceSync(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, records ...*kgo.Record) kgo.ProduceResults { + records[0].Partition = 1 + records[0].Offset = 10 + records[1].Partition = 2 + return kgo.ProduceResults{ + {Record: records[0]}, + {Record: records[1], Err: deliveryErr}, + } + }, + ) + producer := newProducerWithClient(client, EncoderFunc[string]( + func(_ context.Context, value string) ([]byte, error) { return []byte(value), nil }, + )) + + err := producer.SendBatch(context.Background(), []OutgoingMessage[string]{ + {Topic: "invoices", Value: "first"}, + {Topic: "invoices", Value: "second"}, + }) + + var got *BatchDeliveryError + require.ErrorAs(t, err, &got) + require.Equal(t, 1, got.Succeeded()) + require.Equal(t, []DeliveryFailure{{ + Index: 1, + Topic: "invoices", + Partition: 2, + Err: deliveryErr, + }}, got.Failures()) + require.ErrorIs(t, got, deliveryErr) +} + +func TestProducerRejectsBlankTopicsBeforeSending(t *testing.T) { + t.Parallel() + + producer, err := NewProducer( + ProducerConfig{Brokers: []string{"localhost:9092"}}, + EncoderFunc[string](func(_ context.Context, value string) ([]byte, error) { + return []byte(value), nil + }), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, producer.Close(context.Background())) }) + + err = producer.Send(context.Background(), OutgoingMessage[string]{Value: "invoice"}) + + require.ErrorContains(t, err, "topic") +} diff --git a/kafka/real_kafka_integration_test.go b/kafka/real_kafka_integration_test.go new file mode 100644 index 0000000..014b1f2 --- /dev/null +++ b/kafka/real_kafka_integration_test.go @@ -0,0 +1,41 @@ +//go:build kafka_integration + +package kafka_test + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/devctllabs/go-libs/kafka" + "github.com/stretchr/testify/require" +) + +func TestProducerAgainstRealKafka(t *testing.T) { + brokersValue := strings.TrimSpace(os.Getenv("KAFKA_BROKERS")) + if brokersValue == "" { + t.Skip("KAFKA_BROKERS is not set") + } + topic := strings.TrimSpace(os.Getenv("KAFKA_TEST_TOPIC")) + if topic == "" { + topic = "go-libs-kafka-integration" + } + producer, err := kafka.NewProducer( + kafka.ProducerConfig{Brokers: strings.Split(brokersValue, ",")}, + kafka.EncoderFunc[string](func(_ context.Context, value string) ([]byte, error) { + return []byte(value), nil + }), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, producer.Close(context.Background())) }) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + require.NoError(t, producer.Send(ctx, kafka.OutgoingMessage[string]{ + Topic: topic, + Key: []byte("go-libs-smoke"), + Value: "ok", + })) +} diff --git a/kafkaoutbox/README.md b/kafkaoutbox/README.md new file mode 100644 index 0000000..36f4a8a --- /dev/null +++ b/kafkaoutbox/README.md @@ -0,0 +1,168 @@ +# kafkaoutbox + +Transactional outbox primitives for PostgreSQL and Kafka. The module supports +two explicit delivery profiles: + +- `PollingStore` plus `Worker`: an application-owned publisher with leased + virtual shards, whole-batch retry, and fenced deletion. +- `CDCStore` plus `PartitionManager`: an append-only, time-partitioned table + for Debezium's Outbox Event Router. + +Both stores implement the same `Appender` contract and require an active +`postgresdb.Endpoint.WithinTx` transaction. Migrations are embedded but remain +caller-owned through `PollingMigrations()` and `CDCMigrations()`. + +## Enqueue + +The typed enqueuer encodes before appending. The application transaction is the +only transaction boundary: + +```go +store, err := kafkaoutbox.NewPollingStore(db.Writer()) +if err != nil { + return err +} +enqueuer, err := kafkaoutbox.NewEnqueuer( + kafkaoutbox.EnqueuerConfig{Observer: observer}, + store, + kafka.NewJSONEncoder[OrderPaid](), +) +if err != nil { + return err +} + +return db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + if err := updateOrder(txCtx); err != nil { + return err + } + return enqueuer.Enqueue(txCtx, kafkaoutbox.Event[OrderPaid]{ + Topic: "orders.events", AggregateType: "Order", AggregateID: "42", + Type: "OrderPaid", Value: event, + }) +}) +``` + +`AggregateType + "::" + AggregateID` is the Kafka key and routing identity. +One aggregate therefore always maps to one virtual shard and, with normal +Kafka key partitioning, one Kafka partition. The delimiter is reserved. + +The sibling codec choices are `kafka.NewBytesEncoder()`, +`kafka.NewJSONEncoder[T]()`, and +`kafkaproto.NewMessageEncoder[*mypb.Event]()`. A nil encoded byte slice is +stored as an empty non-null payload. + +## Polling worker + +```go +worker, err := kafkaoutbox.NewWorker(kafkaoutbox.WorkerConfig{ + MaxBatchSize: 100, + PollInterval: 100 * time.Millisecond, + DatabaseTimeout: 2 * time.Second, + PublishTimeout: 10 * time.Second, + LeaseDuration: 15 * time.Second, + RetryPolicy: backoff, + MaxAttempts: 0, // retry forever; non-zero stops Run after this many consecutive failures + Topology: kafkaoutbox.TopologyConfig{ + Revision: 1, + ShardCount: 4, + }, + Observer: observer, +}, store, publisher) +if err != nil { + return err +} +return worker.Run(ctx) +``` + +Four virtual shards are the default and a good starting point for moderate +load. Shard count bounds publisher concurrency: use at least as many shards as +the maximum useful worker count, and increase to 8 or 16 only when metrics show +one worker or shard is saturated. Counts are powers of two from 1 through +1024. Changing the count requires a strictly higher topology revision. + +Workers use `FOR UPDATE SKIP LOCKED` only to lease tiny rows in +`outbox_shards`; event scans themselves need no row locks because one live +lease owns a virtual shard. Kafka I/O never holds a database transaction. A +successful finalize locks and fences the exact generation/shard/token before +deleting the acknowledged IDs. + +There is deliberately no `ready` flag. Shards are selected by durable +`next_attempt_at`, and an empty or partial scan schedules another poll. An +enqueue does not need to race with a worker that clears a readiness bit, so +there is no lost-wakeup window. The finalize transaction releases the current +lease and, while the worker is continuing, claims the oldest next due shard. +Updating `next_attempt_at` after each scan makes this atomic context switch +rotate away from a hot shard instead of pinning the worker to it. + +Delivery is **at least once**. A crash after Kafka acknowledges a batch but +before PostgreSQL deletes it sends the same event again. The UUIDv7 `id` header +is the consumer deduplication key. Whole batches retry unchanged. With +`MaxAttempts == 0`, failures retry forever; a positive limit returns +`AttemptsExhaustedError` and leaves events intact. There is no publisher DLQ: +moving an unpublished source event to another topic would turn an infrastructure +failure into data loss. The consumer-side `kafka` module owns reject/drop/DLQ +policy after an event reaches Kafka. + +## CDC profile + +Apply `CDCMigrations()`, run `PartitionManager.Maintain` on startup and on a +schedule, then pass `CDCStore` to the same enqueuer. The manager creates UTC +daily, weekly, or monthly partitions ahead of time under an advisory lock and +drops only partitions whose complete upper bound is older than retention. +Retention must exceed the worst credible connector outage and replication lag. + +[`testdata/debezium-postgres.json`](testdata/debezium-postgres.json) is a +starting connector fragment. Important choices are: + +- include only the outbox table and use `publish.via.partition.root=true`, so + child partitions appear as the root table; +- route by `topic`, key by `aggregatekey`, and copy `type` and + `aggregatetype` into headers; +- use `BinaryDataConverter` so PostgreSQL `bytea` reaches Kafka unchanged; +- read `tracingspancontext` and set `tracing.with.context.field.only=true`. + +Use one polling or CDC profile per PostgreSQL schema/search path. The embedded +SQL intentionally uses stable unqualified table names rather than adding a +runtime schema dimension to every query. + +## Observability + +Core code does not log. Compose `NewOTelObserver` and the sibling +`kafkaoutboxzap` observer with `NewMultiObserver`. + +The OTel observer emits: + +- `kafkaoutbox.operation.attempts`, `.duration`, and `.inflight`, by bounded + phase and outcome; +- `kafkaoutbox.enqueue.events`; +- `kafkaoutbox.worker.batches`, `.events`, `.batch.size`, and `.retries`; +- `kafkaoutbox.worker.fencing_conflicts`, kept separate from delivery outcome; +- `kafkaoutbox.topology.changes` and `.shards`. + +Rates such as event or batch throughput should be derived from monotonic +counters in the metrics backend. Raw errors are recorded on spans and logs, +never metric attributes. Polling publish spans are new producer roots linked +to the persisted originating W3C contexts, capped at 128 unique links by +default. CDC stores the same context in Debezium's Java Properties format. + +The zap adapter logs scheduled retries and lost claims at Warn, and actual +topology changes at Info. The application remains responsible for logging a +terminal `Run` error once. + +## Inbox decision + +No transactional inbox is included in v1. The outbox cannot provide +exactly-once business effects: consumers still need idempotency because +at-least-once delivery can repeat an event. Start with a domain uniqueness +constraint or a small application-owned processed-event table keyed by the +outbox `id`. A generic inbox becomes worthwhile only when multiple services +need the same atomic "deduplicate + mutate business state" transaction and can +share database semantics; adding it pre-emptively would couple this publisher +library to consumer storage and retention policy. + +## Tests + +```sh +go test -race ./kafkaoutbox/... ./kafkaoutboxzap/... +go test -race -tags=integration ./kafkaoutbox/... +``` diff --git a/kafkaoutbox/cdc_integration_test.go b/kafkaoutbox/cdc_integration_test.go new file mode 100644 index 0000000..42eb3f7 --- /dev/null +++ b/kafkaoutbox/cdc_integration_test.go @@ -0,0 +1,126 @@ +//go:build integration + +package kafkaoutbox_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/devctllabs/go-libs/kafkaoutbox" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +func TestCDCStoreAppendsIntoMaintainedUTCPartition(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.CDCMigrations()) + manager, err := kafkaoutbox.NewPartitionManager(db.Writer(), kafkaoutbox.PartitionManagerConfig{ + Granularity: kafkaoutbox.PartitionDaily, + AheadPartitions: 1, + Retention: 48 * time.Hour, + }) + require.NoError(t, err) + require.NoError(t, manager.Maintain(ctx, time.Now())) + store, err := kafkaoutbox.NewCDCStore(db.Writer()) + require.NoError(t, err) + event := kafkaoutbox.Event[[]byte]{ + Topic: "orders", AggregateType: "Order", AggregateID: "42", + Type: "OrderPaid", Value: []byte("wire"), + } + + err = store.Append(ctx, event) + require.ErrorIs(t, err, kafkaoutbox.ErrTransactionRequired) + require.NoError(t, db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + return store.Append(txCtx, event) + })) + + var topic, aggregateKey string + var traceContext sql.NullString + require.NoError(t, db.Writer().QueryRow(ctx, ` + SELECT topic, aggregatekey, tracingspancontext FROM outbox_events + `).Scan(&topic, &aggregateKey, &traceContext)) + require.Equal(t, "orders", topic) + require.Equal(t, "Order::42", aggregateKey) + require.False(t, traceContext.Valid) + var partitions int + require.NoError(t, db.Writer().QueryRow(ctx, ` + SELECT COUNT(*) + FROM pg_inherits + WHERE inhparent = 'outbox_events'::regclass + `).Scan(&partitions)) + require.Equal(t, 2, partitions) +} + +func TestCDCStorePersistsDebeziumTracingSpanContext(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.CDCMigrations()) + manager, err := kafkaoutbox.NewPartitionManager(db.Writer(), kafkaoutbox.PartitionManagerConfig{ + Granularity: kafkaoutbox.PartitionDaily, + Retention: 48 * time.Hour, + }) + require.NoError(t, err) + require.NoError(t, manager.Maintain(ctx, time.Now())) + store, err := kafkaoutbox.NewCDCStore(db.Writer()) + require.NoError(t, err) + traceState, err := trace.ParseTraceState("vendor=value") + require.NoError(t, err) + tracedCtx := trace.ContextWithSpanContext(ctx, trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, + SpanID: trace.SpanID{1, 2, 3, 4, 5, 6, 7, 8}, + TraceFlags: trace.FlagsSampled, + TraceState: traceState, + })) + + require.NoError(t, db.Writer().WithinTx(tracedCtx, func(txCtx context.Context) error { + return store.Append(txCtx, kafkaoutbox.Event[[]byte]{ + Topic: "orders", AggregateType: "Order", AggregateID: "42", Type: "OrderPaid", + }) + })) + + var traceContext string + require.NoError(t, db.Writer().QueryRow(ctx, ` + SELECT tracingspancontext FROM outbox_events + `).Scan(&traceContext)) + require.Equal(t, + "traceparent=00-0102030405060708090a0b0c0d0e0f10-0102030405060708-01\ntracestate=vendor=value\n", + traceContext, + ) +} + +func TestPartitionManagerDropsOnlyFullyExpiredCanonicalPartitions(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.CDCMigrations()) + manager, err := kafkaoutbox.NewPartitionManager(db.Writer(), kafkaoutbox.PartitionManagerConfig{ + Granularity: kafkaoutbox.PartitionDaily, + Retention: 48 * time.Hour, + }) + require.NoError(t, err) + require.NoError(t, manager.Maintain(ctx, time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC))) + require.NoError(t, manager.Maintain(ctx, time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC))) + + rows, err := db.Writer().Query(ctx, ` + SELECT child.relname + FROM pg_inherits + JOIN pg_class child ON child.oid = inhrelid + WHERE inhparent = 'outbox_events'::regclass + ORDER BY child.relname + `) + require.NoError(t, err) + defer rows.Close() + var names []string + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + names = append(names, name) + } + require.NoError(t, rows.Err()) + require.Equal(t, []string{"outbox_events_p20260902"}, names) +} diff --git a/kafkaoutbox/cdc_store.go b/kafkaoutbox/cdc_store.go new file mode 100644 index 0000000..4a980e6 --- /dev/null +++ b/kafkaoutbox/cdc_store.go @@ -0,0 +1,49 @@ +package kafkaoutbox + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/postgresdb" +) + +// CDCStore appends events to an append-only Debezium outbox table. +type CDCStore struct { + endpoint *postgresdb.Endpoint +} + +// NewCDCStore constructs a transaction-aware Debezium outbox store. +func NewCDCStore(endpoint *postgresdb.Endpoint) (*CDCStore, error) { + if endpoint == nil { + return nil, errors.New("kafkaoutbox: PostgreSQL endpoint must not be nil") + } + return &CDCStore{endpoint: endpoint}, nil +} + +// Append persists event in the active business transaction carried by ctx. +func (store *CDCStore) Append(ctx context.Context, event Event[[]byte]) error { + if !store.endpoint.InTransaction(ctx) { + return ErrTransactionRequired + } + if err := validateEvent(ctx, event); err != nil { + return err + } + id, aggregateKey, payload, err := prepareStoredEvent(event) + if err != nil { + return err + } + traceContext := traceContextFrom(ctx) + _, err = store.endpoint.Exec(ctx, ` + INSERT INTO outbox_events ( + id, topic, aggregatetype, aggregateid, aggregatekey, type, payload, tracingspancontext + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, id, event.Topic, event.AggregateType, event.AggregateID, aggregateKey, event.Type, payload, + traceContext.debeziumProperties()) + if err != nil { + return fmt.Errorf("kafkaoutbox: endpoint.Exec: %w", err) + } + return nil +} + +var _ Appender = (*CDCStore)(nil) diff --git a/kafkaoutbox/contracts.gen_test.go b/kafkaoutbox/contracts.gen_test.go new file mode 100644 index 0000000..02412fe --- /dev/null +++ b/kafkaoutbox/contracts.gen_test.go @@ -0,0 +1,385 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/kafkaoutbox (interfaces: Appender,BatchPublisher,Observer) +// +// Generated by this command: +// +// mockgen -destination=contracts.gen_test.go -package=kafkaoutbox -typed . Appender,BatchPublisher,Observer +// + +// Package kafkaoutbox is a generated GoMock package. +package kafkaoutbox + +import ( + context "context" + reflect "reflect" + + kafka "github.com/devctllabs/go-libs/kafka" + gomock "go.uber.org/mock/gomock" +) + +// MockAppender is a mock of Appender interface. +type MockAppender struct { + ctrl *gomock.Controller + recorder *MockAppenderMockRecorder + isgomock struct{} +} + +// MockAppenderMockRecorder is the mock recorder for MockAppender. +type MockAppenderMockRecorder struct { + mock *MockAppender +} + +// NewMockAppender creates a new mock instance. +func NewMockAppender(ctrl *gomock.Controller) *MockAppender { + mock := &MockAppender{ctrl: ctrl} + mock.recorder = &MockAppenderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAppender) EXPECT() *MockAppenderMockRecorder { + return m.recorder +} + +// Append mocks base method. +func (m *MockAppender) Append(ctx context.Context, event Event[[]byte]) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Append", ctx, event) + ret0, _ := ret[0].(error) + return ret0 +} + +// Append indicates an expected call of Append. +func (mr *MockAppenderMockRecorder) Append(ctx, event any) *MockAppenderAppendCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Append", reflect.TypeOf((*MockAppender)(nil).Append), ctx, event) + return &MockAppenderAppendCall{Call: call} +} + +// MockAppenderAppendCall wrap *gomock.Call +type MockAppenderAppendCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAppenderAppendCall) Return(arg0 error) *MockAppenderAppendCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAppenderAppendCall) Do(f func(context.Context, Event[[]byte]) error) *MockAppenderAppendCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAppenderAppendCall) DoAndReturn(f func(context.Context, Event[[]byte]) error) *MockAppenderAppendCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockBatchPublisher is a mock of BatchPublisher interface. +type MockBatchPublisher struct { + ctrl *gomock.Controller + recorder *MockBatchPublisherMockRecorder + isgomock struct{} +} + +// MockBatchPublisherMockRecorder is the mock recorder for MockBatchPublisher. +type MockBatchPublisherMockRecorder struct { + mock *MockBatchPublisher +} + +// NewMockBatchPublisher creates a new mock instance. +func NewMockBatchPublisher(ctrl *gomock.Controller) *MockBatchPublisher { + mock := &MockBatchPublisher{ctrl: ctrl} + mock.recorder = &MockBatchPublisherMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBatchPublisher) EXPECT() *MockBatchPublisherMockRecorder { + return m.recorder +} + +// SendBatch mocks base method. +func (m *MockBatchPublisher) SendBatch(ctx context.Context, messages []kafka.OutgoingMessage[[]byte]) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendBatch", ctx, messages) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendBatch indicates an expected call of SendBatch. +func (mr *MockBatchPublisherMockRecorder) SendBatch(ctx, messages any) *MockBatchPublisherSendBatchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendBatch", reflect.TypeOf((*MockBatchPublisher)(nil).SendBatch), ctx, messages) + return &MockBatchPublisherSendBatchCall{Call: call} +} + +// MockBatchPublisherSendBatchCall wrap *gomock.Call +type MockBatchPublisherSendBatchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBatchPublisherSendBatchCall) Return(arg0 error) *MockBatchPublisherSendBatchCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBatchPublisherSendBatchCall) Do(f func(context.Context, []kafka.OutgoingMessage[[]byte]) error) *MockBatchPublisherSendBatchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBatchPublisherSendBatchCall) DoAndReturn(f func(context.Context, []kafka.OutgoingMessage[[]byte]) error) *MockBatchPublisherSendBatchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockObserver is a mock of Observer interface. +type MockObserver struct { + ctrl *gomock.Controller + recorder *MockObserverMockRecorder + isgomock struct{} +} + +// MockObserverMockRecorder is the mock recorder for MockObserver. +type MockObserverMockRecorder struct { + mock *MockObserver +} + +// NewMockObserver creates a new mock instance. +func NewMockObserver(ctrl *gomock.Controller) *MockObserver { + mock := &MockObserver{ctrl: ctrl} + mock.recorder = &MockObserverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockObserver) EXPECT() *MockObserverMockRecorder { + return m.recorder +} + +// BatchCompleted mocks base method. +func (m *MockObserver) BatchCompleted(ctx context.Context, result BatchResult) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "BatchCompleted", ctx, result) +} + +// BatchCompleted indicates an expected call of BatchCompleted. +func (mr *MockObserverMockRecorder) BatchCompleted(ctx, result any) *MockObserverBatchCompletedCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCompleted", reflect.TypeOf((*MockObserver)(nil).BatchCompleted), ctx, result) + return &MockObserverBatchCompletedCall{Call: call} +} + +// MockObserverBatchCompletedCall wrap *gomock.Call +type MockObserverBatchCompletedCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObserverBatchCompletedCall) Return() *MockObserverBatchCompletedCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObserverBatchCompletedCall) Do(f func(context.Context, BatchResult)) *MockObserverBatchCompletedCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObserverBatchCompletedCall) DoAndReturn(f func(context.Context, BatchResult)) *MockObserverBatchCompletedCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Enqueued mocks base method. +func (m *MockObserver) Enqueued(ctx context.Context) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Enqueued", ctx) +} + +// Enqueued indicates an expected call of Enqueued. +func (mr *MockObserverMockRecorder) Enqueued(ctx any) *MockObserverEnqueuedCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Enqueued", reflect.TypeOf((*MockObserver)(nil).Enqueued), ctx) + return &MockObserverEnqueuedCall{Call: call} +} + +// MockObserverEnqueuedCall wrap *gomock.Call +type MockObserverEnqueuedCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObserverEnqueuedCall) Return() *MockObserverEnqueuedCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObserverEnqueuedCall) Do(f func(context.Context)) *MockObserverEnqueuedCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObserverEnqueuedCall) DoAndReturn(f func(context.Context)) *MockObserverEnqueuedCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FencingConflict mocks base method. +func (m *MockObserver) FencingConflict(ctx context.Context, event FencingEvent) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "FencingConflict", ctx, event) +} + +// FencingConflict indicates an expected call of FencingConflict. +func (mr *MockObserverMockRecorder) FencingConflict(ctx, event any) *MockObserverFencingConflictCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FencingConflict", reflect.TypeOf((*MockObserver)(nil).FencingConflict), ctx, event) + return &MockObserverFencingConflictCall{Call: call} +} + +// MockObserverFencingConflictCall wrap *gomock.Call +type MockObserverFencingConflictCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObserverFencingConflictCall) Return() *MockObserverFencingConflictCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObserverFencingConflictCall) Do(f func(context.Context, FencingEvent)) *MockObserverFencingConflictCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObserverFencingConflictCall) DoAndReturn(f func(context.Context, FencingEvent)) *MockObserverFencingConflictCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Retry mocks base method. +func (m *MockObserver) Retry(ctx context.Context, event RetryEvent) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Retry", ctx, event) +} + +// Retry indicates an expected call of Retry. +func (mr *MockObserverMockRecorder) Retry(ctx, event any) *MockObserverRetryCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Retry", reflect.TypeOf((*MockObserver)(nil).Retry), ctx, event) + return &MockObserverRetryCall{Call: call} +} + +// MockObserverRetryCall wrap *gomock.Call +type MockObserverRetryCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObserverRetryCall) Return() *MockObserverRetryCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObserverRetryCall) Do(f func(context.Context, RetryEvent)) *MockObserverRetryCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObserverRetryCall) DoAndReturn(f func(context.Context, RetryEvent)) *MockObserverRetryCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// StartOperation mocks base method. +func (m *MockObserver) StartOperation(ctx context.Context, operation Operation) (context.Context, OperationDone) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StartOperation", ctx, operation) + ret0, _ := ret[0].(context.Context) + ret1, _ := ret[1].(OperationDone) + return ret0, ret1 +} + +// StartOperation indicates an expected call of StartOperation. +func (mr *MockObserverMockRecorder) StartOperation(ctx, operation any) *MockObserverStartOperationCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartOperation", reflect.TypeOf((*MockObserver)(nil).StartOperation), ctx, operation) + return &MockObserverStartOperationCall{Call: call} +} + +// MockObserverStartOperationCall wrap *gomock.Call +type MockObserverStartOperationCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObserverStartOperationCall) Return(arg0 context.Context, arg1 OperationDone) *MockObserverStartOperationCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObserverStartOperationCall) Do(f func(context.Context, Operation) (context.Context, OperationDone)) *MockObserverStartOperationCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObserverStartOperationCall) DoAndReturn(f func(context.Context, Operation) (context.Context, OperationDone)) *MockObserverStartOperationCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// TopologyReconciled mocks base method. +func (m *MockObserver) TopologyReconciled(ctx context.Context, result TopologyResult) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "TopologyReconciled", ctx, result) +} + +// TopologyReconciled indicates an expected call of TopologyReconciled. +func (mr *MockObserverMockRecorder) TopologyReconciled(ctx, result any) *MockObserverTopologyReconciledCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TopologyReconciled", reflect.TypeOf((*MockObserver)(nil).TopologyReconciled), ctx, result) + return &MockObserverTopologyReconciledCall{Call: call} +} + +// MockObserverTopologyReconciledCall wrap *gomock.Call +type MockObserverTopologyReconciledCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObserverTopologyReconciledCall) Return() *MockObserverTopologyReconciledCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObserverTopologyReconciledCall) Do(f func(context.Context, TopologyResult)) *MockObserverTopologyReconciledCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObserverTopologyReconciledCall) DoAndReturn(f func(context.Context, TopologyResult)) *MockObserverTopologyReconciledCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/kafkaoutbox/contracts.go b/kafkaoutbox/contracts.go new file mode 100644 index 0000000..6708349 --- /dev/null +++ b/kafkaoutbox/contracts.go @@ -0,0 +1,31 @@ +package kafkaoutbox + +import ( + "context" + + "github.com/devctllabs/go-libs/kafka" +) + +//go:generate go tool mockgen -destination=contracts.gen_test.go -package=kafkaoutbox -typed . Appender,BatchPublisher,Observer +//go:generate go tool mockgen -destination=encoder.gen_test.go -package=kafkaoutbox -typed github.com/devctllabs/go-libs/kafka Encoder + +// Event describes one domain event to persist in an outbox. +type Event[T any] struct { + Topic string + AggregateType string + AggregateID string + Type string + Value T +} + +// Appender persists encoded events in the caller's active business transaction. +type Appender interface { + // Append persists event atomically with the transaction carried by ctx. + Append(ctx context.Context, event Event[[]byte]) error +} + +// BatchPublisher delivers encoded outbox messages to Kafka. +type BatchPublisher interface { + // SendBatch sends all messages and waits for their broker delivery results. + SendBatch(ctx context.Context, messages []kafka.OutgoingMessage[[]byte]) error +} diff --git a/kafkaoutbox/encoder.gen_test.go b/kafkaoutbox/encoder.gen_test.go new file mode 100644 index 0000000..8ecc0eb --- /dev/null +++ b/kafkaoutbox/encoder.gen_test.go @@ -0,0 +1,80 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/kafka (interfaces: Encoder) +// +// Generated by this command: +// +// mockgen -destination=encoder.gen_test.go -package=kafkaoutbox -typed github.com/devctllabs/go-libs/kafka Encoder +// + +// Package kafkaoutbox is a generated GoMock package. +package kafkaoutbox + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockEncoder is a mock of Encoder interface. +type MockEncoder[T any] struct { + ctrl *gomock.Controller + recorder *MockEncoderMockRecorder[T] + isgomock struct{} +} + +// MockEncoderMockRecorder is the mock recorder for MockEncoder. +type MockEncoderMockRecorder[T any] struct { + mock *MockEncoder[T] +} + +// NewMockEncoder creates a new mock instance. +func NewMockEncoder[T any](ctrl *gomock.Controller) *MockEncoder[T] { + mock := &MockEncoder[T]{ctrl: ctrl} + mock.recorder = &MockEncoderMockRecorder[T]{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEncoder[T]) EXPECT() *MockEncoderMockRecorder[T] { + return m.recorder +} + +// Encode mocks base method. +func (m *MockEncoder[T]) Encode(ctx context.Context, value T) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Encode", ctx, value) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Encode indicates an expected call of Encode. +func (mr *MockEncoderMockRecorder[T]) Encode(ctx, value any) *MockEncoderEncodeCall[T] { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Encode", reflect.TypeOf((*MockEncoder[T])(nil).Encode), ctx, value) + return &MockEncoderEncodeCall[T]{Call: call} +} + +// MockEncoderEncodeCall wrap *gomock.Call +type MockEncoderEncodeCall[T any] struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockEncoderEncodeCall[T]) Return(arg0 []byte, arg1 error) *MockEncoderEncodeCall[T] { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockEncoderEncodeCall[T]) Do(f func(context.Context, T) ([]byte, error)) *MockEncoderEncodeCall[T] { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockEncoderEncodeCall[T]) DoAndReturn(f func(context.Context, T) ([]byte, error)) *MockEncoderEncodeCall[T] { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/kafkaoutbox/enqueuer.go b/kafkaoutbox/enqueuer.go new file mode 100644 index 0000000..82e7a0b --- /dev/null +++ b/kafkaoutbox/enqueuer.go @@ -0,0 +1,95 @@ +package kafkaoutbox + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/devctllabs/go-libs/kafka" +) + +// EnqueuerConfig configures enqueue observation. +type EnqueuerConfig struct { + Observer Observer +} + +// Enqueuer serializes typed events before appending them to an outbox. +type Enqueuer[T any] struct { + appender Appender + encoder kafka.Encoder[T] + observer Observer +} + +// NewEnqueuer constructs a typed transactional outbox enqueuer. +func NewEnqueuer[T any]( + config EnqueuerConfig, + appender Appender, + encoder kafka.Encoder[T], +) (*Enqueuer[T], error) { + if appender == nil { + return nil, errors.New("kafkaoutbox: appender must not be nil") + } + if encoder == nil { + return nil, errors.New("kafkaoutbox: encoder must not be nil") + } + return &Enqueuer[T]{ + appender: appender, + encoder: encoder, + observer: effectiveObserver(config.Observer), + }, nil +} + +// Enqueue encodes event and appends it using the transaction carried by ctx. +func (enqueuer *Enqueuer[T]) Enqueue(ctx context.Context, event Event[T]) (resultErr error) { + if err := validateEvent(ctx, event); err != nil { + return err + } + ctx, done := enqueuer.observer.StartOperation(ctx, Operation{Phase: OperationEnqueue}) + if done == nil { + done = func(error) {} + } + defer func() { done(resultErr) }() + payload, err := enqueuer.encoder.Encode(ctx, event.Value) + if err != nil { + return fmt.Errorf("kafkaoutbox: encoder.Encode: %w", err) + } + if payload == nil { + payload = []byte{} + } + if err := enqueuer.appender.Append(ctx, Event[[]byte]{ + Topic: event.Topic, + AggregateType: event.AggregateType, + AggregateID: event.AggregateID, + Type: event.Type, + Value: payload, + }); err != nil { + return fmt.Errorf("kafkaoutbox: appender.Append: %w", err) + } + enqueuer.observer.Enqueued(ctx) + return nil +} + +func validateEvent[T any](ctx context.Context, event Event[T]) error { + if ctx == nil { + return errors.New("kafkaoutbox: context must not be nil") + } + fields := []struct { + name string + value string + }{ + {name: "topic", value: event.Topic}, + {name: "aggregate type", value: event.AggregateType}, + {name: "aggregate id", value: event.AggregateID}, + {name: "event type", value: event.Type}, + } + for _, field := range fields { + if strings.TrimSpace(field.value) == "" { + return fmt.Errorf("kafkaoutbox: %s must not be blank", field.name) + } + } + if strings.Contains(event.AggregateType, "::") || strings.Contains(event.AggregateID, "::") { + return errors.New("kafkaoutbox: aggregate type and id must not contain the reserved delimiter") + } + return nil +} diff --git a/kafkaoutbox/enqueuer_test.go b/kafkaoutbox/enqueuer_test.go new file mode 100644 index 0000000..5ffbcc9 --- /dev/null +++ b/kafkaoutbox/enqueuer_test.go @@ -0,0 +1,156 @@ +package kafkaoutbox + +import ( + "context" + "errors" + "testing" + + "github.com/devctllabs/go-libs/kafka" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestEnqueuerEncodesAndAppendsEvent(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + appender := NewMockAppender(ctrl) + encoder := kafka.EncoderFunc[string](func(_ context.Context, value string) ([]byte, error) { + return []byte("encoded:" + value), nil + }) + appender.EXPECT().Append(gomock.Any(), Event[[]byte]{ + Topic: "orders", + AggregateType: "Order", + AggregateID: "42", + Type: "OrderPaid", + Value: []byte("encoded:payload"), + }).Return(nil) + + enqueuer, err := NewEnqueuer(EnqueuerConfig{}, appender, encoder) + require.NoError(t, err) + + err = enqueuer.Enqueue(context.Background(), Event[string]{ + Topic: "orders", + AggregateType: "Order", + AggregateID: "42", + Type: "OrderPaid", + Value: "payload", + }) + require.NoError(t, err) +} + +func TestEnqueuerObservesSuccessfulOperation(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + appender := NewMockAppender(ctrl) + encoder := NewMockEncoder[string](ctrl) + observer := NewMockObserver(ctrl) + ctx := context.Background() + observedCtx := context.WithValue(ctx, observationContextKey{}, "observed") + done := make(chan error, 1) + event := validStringEvent() + + observer.EXPECT().StartOperation(ctx, Operation{Phase: OperationEnqueue}).Return( + observedCtx, + OperationDone(func(err error) { done <- err }), + ) + encoder.EXPECT().Encode(observedCtx, event.Value).Return([]byte("wire"), nil) + appender.EXPECT().Append(observedCtx, gomock.Any()).Return(nil) + observer.EXPECT().Enqueued(observedCtx) + enqueuer, err := NewEnqueuer(EnqueuerConfig{Observer: observer}, appender, encoder) + require.NoError(t, err) + + require.NoError(t, enqueuer.Enqueue(ctx, event)) + require.NoError(t, <-done) +} + +func TestEnqueuerRejectsInvalidEventBeforeEncoding(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ctx context.Context + event Event[string] + }{ + {name: "nil context", event: validStringEvent()}, + {name: "blank topic", ctx: context.Background(), event: eventWith(func(event *Event[string]) { event.Topic = " " })}, + {name: "blank aggregate type", ctx: context.Background(), event: eventWith(func(event *Event[string]) { event.AggregateType = "" })}, + {name: "blank aggregate id", ctx: context.Background(), event: eventWith(func(event *Event[string]) { event.AggregateID = "" })}, + {name: "blank event type", ctx: context.Background(), event: eventWith(func(event *Event[string]) { event.Type = "\t" })}, + {name: "reserved delimiter in aggregate type", ctx: context.Background(), event: eventWith(func(event *Event[string]) { event.AggregateType = "Sales::Order" })}, + {name: "reserved delimiter in aggregate id", ctx: context.Background(), event: eventWith(func(event *Event[string]) { event.AggregateID = "tenant::42" })}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + appender := NewMockAppender(ctrl) + encoder := NewMockEncoder[string](ctrl) + enqueuer, err := NewEnqueuer(EnqueuerConfig{}, appender, encoder) + require.NoError(t, err) + + err = enqueuer.Enqueue(test.ctx, test.event) + require.Error(t, err) + }) + } +} + +func TestEnqueuerPreservesEncoderAndAppenderErrors(t *testing.T) { + t.Parallel() + t.Run("encoder", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + appender := NewMockAppender(ctrl) + encoder := NewMockEncoder[string](ctrl) + encodeErr := errors.New("encode failed") + encoder.EXPECT().Encode(gomock.Any(), "payload").Return(nil, encodeErr) + enqueuer, err := NewEnqueuer(EnqueuerConfig{}, appender, encoder) + require.NoError(t, err) + + err = enqueuer.Enqueue(context.Background(), validStringEvent()) + require.ErrorIs(t, err, encodeErr) + }) + + t.Run("appender", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + appender := NewMockAppender(ctrl) + encoder := NewMockEncoder[string](ctrl) + appendErr := errors.New("append failed") + encoder.EXPECT().Encode(gomock.Any(), "payload").Return([]byte("wire"), nil) + appender.EXPECT().Append(gomock.Any(), gomock.Any()).Return(appendErr) + enqueuer, err := NewEnqueuer(EnqueuerConfig{}, appender, encoder) + require.NoError(t, err) + + err = enqueuer.Enqueue(context.Background(), validStringEvent()) + require.ErrorIs(t, err, appendErr) + }) +} + +func TestNewEnqueuerRequiresDependencies(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + appender := NewMockAppender(ctrl) + encoder := NewMockEncoder[string](ctrl) + + _, err := NewEnqueuer[string](EnqueuerConfig{}, nil, encoder) + require.Error(t, err) + _, err = NewEnqueuer[string](EnqueuerConfig{}, appender, nil) + require.Error(t, err) +} + +func validStringEvent() Event[string] { + return Event[string]{ + Topic: "orders", + AggregateType: "Order", + AggregateID: "42", + Type: "OrderPaid", + Value: "payload", + } +} + +func eventWith(change func(event *Event[string])) Event[string] { + event := validStringEvent() + change(&event) + return event +} + +type observationContextKey struct{} diff --git a/kafkaoutbox/go.mod b/kafkaoutbox/go.mod new file mode 100644 index 0000000..b31b14f --- /dev/null +++ b/kafkaoutbox/go.mod @@ -0,0 +1,85 @@ +module github.com/devctllabs/go-libs/kafkaoutbox + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/kafka v0.1.0 + github.com/devctllabs/go-libs/postgresdb v0.1.0 + github.com/devctllabs/go-libs/retry v0.1.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 + github.com/zeebo/xxh3 v1.1.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/sdk/metric v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + go.uber.org/mock v0.6.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/devctllabs/go-libs/txmanager v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/exaring/otelpgx v0.11.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/twmb/franz-go v1.21.1 // indirect + github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect + github.com/twmb/franz-go/plugin/kotel v1.7.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool go.uber.org/mock/mockgen diff --git a/kafkaoutbox/go.sum b/kafkaoutbox/go.sum new file mode 100644 index 0000000..43208f8 --- /dev/null +++ b/kafkaoutbox/go.sum @@ -0,0 +1,188 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/exaring/otelpgx v0.11.1 h1:pE79fIg/qh/Lpu00kvswFC5dKfqyJJhMJ4Y4N3w5Lj4= +github.com/exaring/otelpgx v0.11.1/go.mod h1:3OojrUKhhy3lTbYIMBijP3YjMey/jo14eHAW5cXcUdk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/georgysavva/scany/v2 v2.1.4 h1:nrzHEJ4oQVRoiKmocRqA1IyGOmM/GQOEsg9UjMR5Ip4= +github.com/georgysavva/scany/v2 v2.1.4/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/twmb/franz-go v1.21.1 h1:sp17bMRLz6OB/w+7vHtBadHGIQVymzQHwvRbEKe5c4I= +github.com/twmb/franz-go v1.21.1/go.mod h1:1o+jj5oRbItsIMoE+DGpfJIcPcPtDdtkcNFPj4bWNwU= +github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe h1:IweTEfQRTN98RFYKWLBqpXw7r1xwdiZ+vsdw7pnVcAY= +github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe/go.mod h1:9j4VxU2ng6tHgD4lIkNJ5OJ3D6vgPhhIp3tBa7dJgLA= +github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI= +github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY= +github.com/twmb/franz-go/plugin/kotel v1.7.0 h1:TAj9zmeqtnH0z4m7+ooa7EEbDIMIvvDdAqejIhNZjB4= +github.com/twmb/franz-go/plugin/kotel v1.7.0/go.mod h1:Cq5tsiazIWro0y/SNpYEwoVW0C6KK1dIYyhccDXV9bs= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/kafkaoutbox/migrations.go b/kafkaoutbox/migrations.go new file mode 100644 index 0000000..06d8bc9 --- /dev/null +++ b/kafkaoutbox/migrations.go @@ -0,0 +1,27 @@ +package kafkaoutbox + +import ( + "embed" + "io/fs" +) + +//go:embed migrations/polling/*.sql migrations/cdc/*.sql +var migrationFiles embed.FS + +// PollingMigrations returns versioned SQL migrations for the polling profile. +func PollingMigrations() fs.FS { + migrations, err := fs.Sub(migrationFiles, "migrations/polling") + if err != nil { + panic(err) + } + return migrations +} + +// CDCMigrations returns versioned SQL migrations for the Debezium CDC profile. +func CDCMigrations() fs.FS { + migrations, err := fs.Sub(migrationFiles, "migrations/cdc") + if err != nil { + panic(err) + } + return migrations +} diff --git a/kafkaoutbox/migrations/cdc/0001_events.down.sql b/kafkaoutbox/migrations/cdc/0001_events.down.sql new file mode 100644 index 0000000..9c4165c --- /dev/null +++ b/kafkaoutbox/migrations/cdc/0001_events.down.sql @@ -0,0 +1,2 @@ +DROP TABLE outbox_events; + diff --git a/kafkaoutbox/migrations/cdc/0001_events.up.sql b/kafkaoutbox/migrations/cdc/0001_events.up.sql new file mode 100644 index 0000000..ae7fef1 --- /dev/null +++ b/kafkaoutbox/migrations/cdc/0001_events.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE outbox_events ( + id uuid NOT NULL, + topic text NOT NULL, + aggregatetype text NOT NULL, + aggregateid text NOT NULL, + aggregatekey text NOT NULL, + type text NOT NULL, + payload bytea NOT NULL, + tracingspancontext text, + created_at timestamptz NOT NULL DEFAULT statement_timestamp(), + PRIMARY KEY (created_at, id) +) PARTITION BY RANGE (created_at); + diff --git a/kafkaoutbox/migrations/polling/0001_events.down.sql b/kafkaoutbox/migrations/polling/0001_events.down.sql new file mode 100644 index 0000000..9c4165c --- /dev/null +++ b/kafkaoutbox/migrations/polling/0001_events.down.sql @@ -0,0 +1,2 @@ +DROP TABLE outbox_events; + diff --git a/kafkaoutbox/migrations/polling/0001_events.up.sql b/kafkaoutbox/migrations/polling/0001_events.up.sql new file mode 100644 index 0000000..dbe775b --- /dev/null +++ b/kafkaoutbox/migrations/polling/0001_events.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE outbox_events ( + id uuid NOT NULL PRIMARY KEY, + topic text NOT NULL, + aggregatetype text NOT NULL, + aggregateid text NOT NULL, + aggregatekey text NOT NULL, + type text NOT NULL, + payload bytea NOT NULL, + routing_hash bigint NOT NULL, + traceparent text, + tracestate text, + created_at timestamptz NOT NULL DEFAULT statement_timestamp() +); + +CREATE INDEX outbox_events_routing_idx ON outbox_events (routing_hash, id); + diff --git a/kafkaoutbox/migrations/polling/0002_topology.down.sql b/kafkaoutbox/migrations/polling/0002_topology.down.sql new file mode 100644 index 0000000..dfa8d54 --- /dev/null +++ b/kafkaoutbox/migrations/polling/0002_topology.down.sql @@ -0,0 +1,3 @@ +DROP TABLE outbox_shards; +DROP TABLE outbox_topology; + diff --git a/kafkaoutbox/migrations/polling/0002_topology.up.sql b/kafkaoutbox/migrations/polling/0002_topology.up.sql new file mode 100644 index 0000000..27d8aee --- /dev/null +++ b/kafkaoutbox/migrations/polling/0002_topology.up.sql @@ -0,0 +1,24 @@ +CREATE TABLE outbox_topology ( + singleton boolean NOT NULL PRIMARY KEY DEFAULT true CHECK (singleton), + revision bigint NOT NULL CHECK (revision > 0), + generation bigint NOT NULL CHECK (generation > 0), + shard_count integer NOT NULL CHECK (shard_count > 0) +); + +CREATE TABLE outbox_shards ( + generation bigint NOT NULL, + shard_id integer NOT NULL, + hash_from bigint, + hash_to bigint, + scan_after_hash bigint, + owner_token uuid, + lease_until timestamptz, + next_attempt_at timestamptz NOT NULL DEFAULT clock_timestamp(), + failure_count bigint NOT NULL DEFAULT 0 CHECK (failure_count >= 0), + PRIMARY KEY (generation, shard_id), + CHECK (hash_from IS NULL OR hash_to IS NULL OR hash_from < hash_to), + CHECK ((owner_token IS NULL) = (lease_until IS NULL)) +); + +CREATE INDEX outbox_shards_due_idx ON outbox_shards (next_attempt_at, shard_id); + diff --git a/kafkaoutbox/observer.go b/kafkaoutbox/observer.go new file mode 100644 index 0000000..61b5249 --- /dev/null +++ b/kafkaoutbox/observer.go @@ -0,0 +1,168 @@ +package kafkaoutbox + +import ( + "context" + "errors" + "time" +) + +// OperationPhase identifies one bounded outbox operation. +type OperationPhase string + +const ( + OperationEnqueue OperationPhase = "enqueue" + OperationTopology OperationPhase = "topology" + OperationClaim OperationPhase = "claim" + OperationRead OperationPhase = "read" + OperationPublish OperationPhase = "publish" + OperationFinalize OperationPhase = "finalize" +) + +// RecordMetadata carries only the data needed to link a publish span to an +// originating trace. Context must not be retained after the observer call. +type RecordMetadata struct { + Topic string + Context context.Context +} + +// Operation describes one operation attempt. +type Operation struct { + Phase OperationPhase + Generation uint64 + ShardID uint + Records []RecordMetadata +} + +// OperationDone completes operation instrumentation. +type OperationDone func(err error) + +// BatchOutcome is the low-cardinality terminal result of a publish attempt. +type BatchOutcome string + +const ( + BatchDelivered BatchOutcome = "delivered" + BatchFailed BatchOutcome = "failed" + BatchFenced BatchOutcome = "fenced" +) + +// BatchResult reports one completed publish attempt. +type BatchResult struct { + Outcome BatchOutcome + Generation uint64 + ShardID uint + Size int +} + +// RetryEvent reports a durable retry schedule after a publish failure. +type RetryEvent struct { + Generation uint64 + ShardID uint + Attempt uint + NextDelay time.Duration + Err error +} + +// FencingEvent reports a claim that was lost before finalization. +type FencingEvent struct { + Phase OperationPhase + Generation uint64 + ShardID uint +} + +// TopologyResult reports the persisted topology observed by a worker. +type TopologyResult struct { + Revision uint64 + Generation uint64 + ShardCount uint + Changed bool +} + +// Observer receives synchronous lifecycle signals. Implementations must be +// concurrency-safe, return quickly, and must not retain supplied contexts. +type Observer interface { + StartOperation(ctx context.Context, operation Operation) (context.Context, OperationDone) + Enqueued(ctx context.Context) + BatchCompleted(ctx context.Context, result BatchResult) + Retry(ctx context.Context, event RetryEvent) + FencingConflict(ctx context.Context, event FencingEvent) + TopologyReconciled(ctx context.Context, result TopologyResult) +} + +type noopObserver struct{} + +func (noopObserver) StartOperation(ctx context.Context, _ Operation) (context.Context, OperationDone) { + return ctx, func(error) {} +} +func (noopObserver) Enqueued(context.Context) {} +func (noopObserver) BatchCompleted(context.Context, BatchResult) {} +func (noopObserver) Retry(context.Context, RetryEvent) {} +func (noopObserver) FencingConflict(context.Context, FencingEvent) {} +func (noopObserver) TopologyReconciled(context.Context, TopologyResult) {} + +func effectiveObserver(observer Observer) Observer { + if observer == nil { + return noopObserver{} + } + return observer +} + +type multiObserver struct { + observers []Observer +} + +// NewMultiObserver composes observers in registration order. +func NewMultiObserver(observers ...Observer) (Observer, error) { + for _, observer := range observers { + if observer == nil { + return nil, errors.New("kafkaoutbox: observer must not be nil") + } + } + return multiObserver{observers: append([]Observer(nil), observers...)}, nil +} + +func (observer multiObserver) StartOperation(ctx context.Context, operation Operation) (context.Context, OperationDone) { + doneCallbacks := make([]OperationDone, 0, len(observer.observers)) + for _, child := range observer.observers { + var done OperationDone + ctx, done = child.StartOperation(ctx, operation) + if done == nil { + done = func(error) {} + } + doneCallbacks = append(doneCallbacks, done) + } + return ctx, func(err error) { + for index := len(doneCallbacks) - 1; index >= 0; index-- { + doneCallbacks[index](err) + } + } +} + +func (observer multiObserver) Enqueued(ctx context.Context) { + for _, child := range observer.observers { + child.Enqueued(ctx) + } +} + +func (observer multiObserver) BatchCompleted(ctx context.Context, result BatchResult) { + for _, child := range observer.observers { + child.BatchCompleted(ctx, result) + } +} + +func (observer multiObserver) Retry(ctx context.Context, event RetryEvent) { + for _, child := range observer.observers { + child.Retry(ctx, event) + } +} + +func (observer multiObserver) FencingConflict(ctx context.Context, event FencingEvent) { + for _, child := range observer.observers { + child.FencingConflict(ctx, event) + } +} + +func (observer multiObserver) TopologyReconciled(ctx context.Context, result TopologyResult) { + for _, child := range observer.observers { + child.TopologyReconciled(ctx, result) + } +} diff --git a/kafkaoutbox/otel.go b/kafkaoutbox/otel.go new file mode 100644 index 0000000..644483d --- /dev/null +++ b/kafkaoutbox/otel.go @@ -0,0 +1,240 @@ +package kafkaoutbox + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" +) + +const instrumentationName = "github.com/devctllabs/go-libs/kafkaoutbox" + +// OTelObserverConfig configures outbox tracing and metrics. +type OTelObserverConfig struct { + MeterProvider metric.MeterProvider + TracerProvider trace.TracerProvider + MaxBatchSpanLinks int +} + +// OTelObserver implements Observer using OpenTelemetry. +type OTelObserver struct { + tracer trace.Tracer + maxLinks int + + attempts metric.Int64Counter + duration metric.Float64Histogram + inflight metric.Int64UpDownCounter + enqueued metric.Int64Counter + batches metric.Int64Counter + events metric.Int64Counter + batchSize metric.Int64Histogram + retries metric.Int64Counter + fencingConflicts metric.Int64Counter + topologyChanges metric.Int64Counter + topologyShards metric.Int64Gauge +} + +// NewOTelObserver constructs standard outbox instruments. A zero link cap +// uses 128; a negative cap is invalid. +func NewOTelObserver(config OTelObserverConfig) (*OTelObserver, error) { + if config.MaxBatchSpanLinks < 0 { + return nil, errors.New("kafkaoutbox: maximum batch span links must not be negative") + } + if config.MaxBatchSpanLinks == 0 { + config.MaxBatchSpanLinks = 128 + } + if config.MeterProvider == nil { + config.MeterProvider = otel.GetMeterProvider() + } + if config.TracerProvider == nil { + config.TracerProvider = otel.GetTracerProvider() + } + meter := config.MeterProvider.Meter(instrumentationName) + observer := &OTelObserver{ + tracer: config.TracerProvider.Tracer(instrumentationName), maxLinks: config.MaxBatchSpanLinks, + } + if err := observer.createOperationInstruments(meter); err != nil { + return nil, err + } + if err := observer.createWorkerInstruments(meter); err != nil { + return nil, err + } + if err := observer.createTopologyInstruments(meter); err != nil { + return nil, err + } + return observer, nil +} + +func (observer *OTelObserver) createOperationInstruments(meter metric.Meter) error { + var err error + observer.attempts, err = meter.Int64Counter("kafkaoutbox.operation.attempts") + if err != nil { + return fmt.Errorf("kafkaoutbox: create operation attempts counter: %w", err) + } + observer.duration, err = meter.Float64Histogram("kafkaoutbox.operation.duration", metric.WithUnit("s")) + if err != nil { + return fmt.Errorf("kafkaoutbox: create operation duration histogram: %w", err) + } + observer.inflight, err = meter.Int64UpDownCounter("kafkaoutbox.operation.inflight") + if err != nil { + return fmt.Errorf("kafkaoutbox: create operation inflight counter: %w", err) + } + observer.enqueued, err = meter.Int64Counter("kafkaoutbox.enqueue.events", metric.WithUnit("{event}")) + if err != nil { + return fmt.Errorf("kafkaoutbox: create enqueue events counter: %w", err) + } + return nil +} + +func (observer *OTelObserver) createWorkerInstruments(meter metric.Meter) error { + var err error + observer.batches, err = meter.Int64Counter("kafkaoutbox.worker.batches", metric.WithUnit("{batch}")) + if err != nil { + return fmt.Errorf("kafkaoutbox: create worker batches counter: %w", err) + } + observer.events, err = meter.Int64Counter("kafkaoutbox.worker.events", metric.WithUnit("{event}")) + if err != nil { + return fmt.Errorf("kafkaoutbox: create worker events counter: %w", err) + } + observer.batchSize, err = meter.Int64Histogram("kafkaoutbox.worker.batch.size", metric.WithUnit("{event}")) + if err != nil { + return fmt.Errorf("kafkaoutbox: create worker batch size histogram: %w", err) + } + observer.retries, err = meter.Int64Counter("kafkaoutbox.worker.retries") + if err != nil { + return fmt.Errorf("kafkaoutbox: create worker retries counter: %w", err) + } + observer.fencingConflicts, err = meter.Int64Counter("kafkaoutbox.worker.fencing_conflicts") + if err != nil { + return fmt.Errorf("kafkaoutbox: create worker fencing conflicts counter: %w", err) + } + return nil +} + +func (observer *OTelObserver) createTopologyInstruments(meter metric.Meter) error { + var err error + observer.topologyChanges, err = meter.Int64Counter("kafkaoutbox.topology.changes") + if err != nil { + return fmt.Errorf("kafkaoutbox: create topology changes counter: %w", err) + } + observer.topologyShards, err = meter.Int64Gauge("kafkaoutbox.topology.shards", metric.WithUnit("{shard}")) + if err != nil { + return fmt.Errorf("kafkaoutbox: create topology shards gauge: %w", err) + } + return nil +} + +// StartOperation starts a bounded operation span and updates attempt metrics. +func (observer *OTelObserver) StartOperation( + ctx context.Context, + operation Operation, +) (context.Context, OperationDone) { + metricAttributes := metric.WithAttributes(attribute.String("kafkaoutbox.phase", string(operation.Phase))) + observer.attempts.Add(ctx, 1, metricAttributes) + observer.inflight.Add(ctx, 1, metricAttributes) + started := time.Now() + spanOptions := []trace.SpanStartOption{ + trace.WithSpanKind(operationSpanKind(operation.Phase)), + trace.WithAttributes( + attribute.String("kafkaoutbox.phase", string(operation.Phase)), + attribute.Int64("kafkaoutbox.generation", int64(operation.Generation)), + attribute.Int("kafkaoutbox.shard.id", int(operation.ShardID)), + attribute.Int("messaging.batch.message_count", len(operation.Records)), + ), + } + if operation.Phase == OperationPublish { + spanOptions = append(spanOptions, trace.WithNewRoot(), trace.WithLinks(observer.links(operation.Records)...)) + } + spanCtx, span := observer.tracer.Start(ctx, "kafkaoutbox "+string(operation.Phase), spanOptions...) + var once sync.Once + return spanCtx, func(err error) { + once.Do(func() { + outcome := "success" + if err != nil { + outcome = "error" + span.RecordError(err) + span.SetStatus(codes.Error, "operation failed") + } + observer.inflight.Add(spanCtx, -1, metricAttributes) + observer.duration.Record(spanCtx, time.Since(started).Seconds(), metric.WithAttributes( + attribute.String("kafkaoutbox.phase", string(operation.Phase)), + attribute.String("kafkaoutbox.outcome", outcome), + )) + span.End() + }) + } +} + +// Enqueued records one event appended successfully. +func (observer *OTelObserver) Enqueued(ctx context.Context) { + observer.enqueued.Add(ctx, 1) +} + +// BatchCompleted records publish-attempt throughput and size by outcome. +func (observer *OTelObserver) BatchCompleted(ctx context.Context, result BatchResult) { + attributes := metric.WithAttributes(attribute.String("kafkaoutbox.outcome", string(result.Outcome))) + observer.batches.Add(ctx, 1, attributes) + observer.events.Add(ctx, int64(result.Size), attributes) + observer.batchSize.Record(ctx, int64(result.Size), attributes) +} + +// Retry records one durably scheduled publish retry. +func (observer *OTelObserver) Retry(ctx context.Context, _ RetryEvent) { + observer.retries.Add(ctx, 1) +} + +// FencingConflict records coordination conflicts separately from delivery outcomes. +func (observer *OTelObserver) FencingConflict(ctx context.Context, event FencingEvent) { + observer.fencingConflicts.Add(ctx, 1, metric.WithAttributes( + attribute.String("kafkaoutbox.phase", string(event.Phase)), + )) +} + +// TopologyReconciled records current shard count and actual topology changes. +func (observer *OTelObserver) TopologyReconciled(ctx context.Context, result TopologyResult) { + observer.topologyShards.Record(ctx, int64(result.ShardCount)) + if result.Changed { + observer.topologyChanges.Add(ctx, 1) + } +} + +func operationSpanKind(phase OperationPhase) trace.SpanKind { + if phase == OperationEnqueue || phase == OperationPublish { + return trace.SpanKindProducer + } + return trace.SpanKindInternal +} + +func (observer *OTelObserver) links(records []RecordMetadata) []trace.Link { + links := make([]trace.Link, 0, min(len(records), observer.maxLinks)) + type linkKey struct { + traceID trace.TraceID + spanID trace.SpanID + } + seen := make(map[linkKey]struct{}, cap(links)) + for _, record := range records { + spanContext := trace.SpanContextFromContext(record.Context) + if !spanContext.IsValid() { + continue + } + key := linkKey{traceID: spanContext.TraceID(), spanID: spanContext.SpanID()} + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + links = append(links, trace.Link{SpanContext: spanContext}) + if len(links) == observer.maxLinks { + break + } + } + return links +} + +var _ Observer = (*OTelObserver)(nil) diff --git a/kafkaoutbox/otel_test.go b/kafkaoutbox/otel_test.go new file mode 100644 index 0000000..2e2106d --- /dev/null +++ b/kafkaoutbox/otel_test.go @@ -0,0 +1,94 @@ +package kafkaoutbox + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +func TestOTelObserverRecordsOutboxMetricsAndLinkedPublishSpan(t *testing.T) { + t.Parallel() + reader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { require.NoError(t, meterProvider.Shutdown(context.Background())) }) + recorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + t.Cleanup(func() { require.NoError(t, tracerProvider.Shutdown(context.Background())) }) + observer, err := NewOTelObserver(OTelObserverConfig{ + MeterProvider: meterProvider, TracerProvider: tracerProvider, MaxBatchSpanLinks: 1, + }) + require.NoError(t, err) + first := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{1}, SpanID: trace.SpanID{1}, Remote: true, + }) + second := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{2}, SpanID: trace.SpanID{2}, Remote: true, + }) + ctx, done := observer.StartOperation(context.Background(), Operation{ + Phase: OperationPublish, Generation: 1, ShardID: 2, + Records: []RecordMetadata{ + {Topic: "orders", Context: trace.ContextWithSpanContext(context.Background(), first)}, + {Topic: "orders", Context: trace.ContextWithSpanContext(context.Background(), second)}, + }, + }) + done(nil) + observer.Enqueued(ctx) + observer.BatchCompleted(ctx, BatchResult{Outcome: BatchDelivered, Size: 2}) + observer.Retry(ctx, RetryEvent{Attempt: 1, NextDelay: time.Second, Err: errors.New("secret")}) + observer.FencingConflict(ctx, FencingEvent{Phase: OperationFinalize}) + observer.TopologyReconciled(ctx, TopologyResult{Changed: true, ShardCount: 4}) + + var data metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &data)) + metrics := outboxMetricsByName(data) + for _, name := range []string{ + "kafkaoutbox.operation.attempts", + "kafkaoutbox.operation.duration", + "kafkaoutbox.operation.inflight", + "kafkaoutbox.enqueue.events", + "kafkaoutbox.worker.batches", + "kafkaoutbox.worker.events", + "kafkaoutbox.worker.batch.size", + "kafkaoutbox.worker.retries", + "kafkaoutbox.worker.fencing_conflicts", + "kafkaoutbox.topology.changes", + "kafkaoutbox.topology.shards", + } { + require.Contains(t, metrics, name) + } + _, ok := metrics["kafkaoutbox.topology.shards"].Data.(metricdata.Gauge[int64]) + require.True(t, ok) + require.NotContains(t, fmt.Sprint(data), "secret") + + spans := recorder.Ended() + require.Len(t, spans, 1) + require.Equal(t, "kafkaoutbox publish", spans[0].Name()) + require.Equal(t, trace.SpanKindProducer, spans[0].SpanKind()) + require.Len(t, spans[0].Links(), 1) + require.Equal(t, first, spans[0].Links()[0].SpanContext) +} + +func TestNewOTelObserverRejectsNegativeLinkCap(t *testing.T) { + t.Parallel() + _, err := NewOTelObserver(OTelObserverConfig{MaxBatchSpanLinks: -1}) + require.Error(t, err) +} + +func outboxMetricsByName(data metricdata.ResourceMetrics) map[string]metricdata.Metrics { + metrics := make(map[string]metricdata.Metrics) + for _, scope := range data.ScopeMetrics { + for _, value := range scope.Metrics { + metrics[value.Name] = value + } + } + return metrics +} diff --git a/kafkaoutbox/partition_manager.go b/kafkaoutbox/partition_manager.go new file mode 100644 index 0000000..bfa2c04 --- /dev/null +++ b/kafkaoutbox/partition_manager.go @@ -0,0 +1,164 @@ +package kafkaoutbox + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/devctllabs/go-libs/postgresdb" + "github.com/jackc/pgx/v5" +) + +// PartitionGranularity selects UTC calendar boundaries for CDC partitions. +type PartitionGranularity uint8 + +const ( + // PartitionDaily creates one partition per UTC day. + PartitionDaily PartitionGranularity = iota + 1 + // PartitionWeekly creates one partition per UTC week starting Monday. + PartitionWeekly + // PartitionMonthly creates one partition per UTC calendar month. + PartitionMonthly +) + +// PartitionManagerConfig controls CDC partition creation and retention. +type PartitionManagerConfig struct { + Granularity PartitionGranularity + AheadPartitions uint + Retention time.Duration + AdvisoryLockNamespace int32 +} + +// PartitionManager maintains the caller's CDC outbox partitions. +type PartitionManager struct { + endpoint *postgresdb.Endpoint + config PartitionManagerConfig +} + +// NewPartitionManager constructs a caller-driven CDC partition manager. +func NewPartitionManager(endpoint *postgresdb.Endpoint, config PartitionManagerConfig) (*PartitionManager, error) { + if endpoint == nil { + return nil, errors.New("kafkaoutbox: PostgreSQL endpoint must not be nil") + } + if config.Granularity < PartitionDaily || config.Granularity > PartitionMonthly { + return nil, errors.New("kafkaoutbox: partition granularity is required") + } + if config.Retention <= 0 { + return nil, errors.New("kafkaoutbox: partition retention must be positive") + } + return &PartitionManager{endpoint: endpoint, config: config}, nil +} + +// Maintain creates the current and configured future UTC partitions. +func (manager *PartitionManager) Maintain(ctx context.Context, now time.Time) error { + if ctx == nil { + return errors.New("kafkaoutbox: context must not be nil") + } + if now.IsZero() { + return errors.New("kafkaoutbox: maintenance time must not be zero") + } + return manager.endpoint.WithinTx(ctx, func(txCtx context.Context) error { + if _, err := manager.endpoint.Exec(txCtx, ` + SELECT pg_advisory_xact_lock( + ($1::bigint << 32) | 'outbox_events'::regclass::oid::bigint + ) + `, manager.config.AdvisoryLockNamespace); err != nil { + return fmt.Errorf("endpoint.Exec advisory lock: %w", err) + } + start := partitionStart(now, manager.config.Granularity) + for offset := uint(0); offset <= manager.config.AheadPartitions; offset++ { + end := nextPartition(start, manager.config.Granularity) + if err := manager.createPartition(txCtx, start, end); err != nil { + return err + } + start = end + } + return manager.dropExpiredPartitions(txCtx, now.UTC().Add(-manager.config.Retention)) + }) +} + +func (manager *PartitionManager) createPartition(ctx context.Context, start, end time.Time) error { + name := "outbox_events_p" + start.Format("20060102") + query := fmt.Sprintf( + "CREATE TABLE IF NOT EXISTS %s PARTITION OF outbox_events FOR VALUES FROM ('%s') TO ('%s')", + pgx.Identifier{name}.Sanitize(), + start.Format(time.RFC3339), + end.Format(time.RFC3339), + ) + if _, err := manager.endpoint.Exec(ctx, query); err != nil { + return fmt.Errorf("endpoint.Exec create partition %s: %w", name, err) + } + return nil +} + +func (manager *PartitionManager) dropExpiredPartitions(ctx context.Context, cutoff time.Time) error { + rows, err := manager.endpoint.Query(ctx, ` + SELECT child.relname + FROM pg_inherits + JOIN pg_class child ON child.oid = inhrelid + WHERE inhparent = 'outbox_events'::regclass + `) + if err != nil { + return fmt.Errorf("endpoint.Query partitions: %w", err) + } + var expired []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return fmt.Errorf("rows.Scan partition: %w", err) + } + start, ok := canonicalPartitionStart(name) + if ok && !nextPartition(start, manager.config.Granularity).After(cutoff) { + expired = append(expired, name) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return fmt.Errorf("rows.Err partitions: %w", err) + } + rows.Close() + for _, name := range expired { + if _, err := manager.endpoint.Exec(ctx, "DROP TABLE "+pgx.Identifier{name}.Sanitize()); err != nil { + return fmt.Errorf("endpoint.Exec drop partition %s: %w", name, err) + } + } + return nil +} + +func canonicalPartitionStart(name string) (time.Time, bool) { + const prefix = "outbox_events_p" + date, ok := strings.CutPrefix(name, prefix) + if !ok || len(date) != len("20060102") { + return time.Time{}, false + } + start, err := time.Parse("20060102", date) + return start, err == nil +} + +func partitionStart(value time.Time, granularity PartitionGranularity) time.Time { + value = value.UTC() + switch granularity { + case PartitionWeekly: + start := time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC) + daysSinceMonday := (int(start.Weekday()) + 6) % 7 + return start.AddDate(0, 0, -daysSinceMonday) + case PartitionMonthly: + return time.Date(value.Year(), value.Month(), 1, 0, 0, 0, 0, time.UTC) + default: + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC) + } +} + +func nextPartition(start time.Time, granularity PartitionGranularity) time.Time { + switch granularity { + case PartitionWeekly: + return start.AddDate(0, 0, 7) + case PartitionMonthly: + return start.AddDate(0, 1, 0) + default: + return start.AddDate(0, 0, 1) + } +} diff --git a/kafkaoutbox/store.go b/kafkaoutbox/store.go new file mode 100644 index 0000000..2fd7712 --- /dev/null +++ b/kafkaoutbox/store.go @@ -0,0 +1,67 @@ +package kafkaoutbox + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/postgresdb" + "github.com/google/uuid" + "github.com/zeebo/xxh3" +) + +// ErrTransactionRequired reports an Append call without an active business transaction. +var ErrTransactionRequired = errors.New("kafkaoutbox: active transaction required") + +// PollingStore appends events and supplies polling worker storage operations. +type PollingStore struct { + endpoint *postgresdb.Endpoint +} + +// NewPollingStore constructs a transaction-aware polling outbox store. +func NewPollingStore(endpoint *postgresdb.Endpoint) (*PollingStore, error) { + if endpoint == nil { + return nil, errors.New("kafkaoutbox: PostgreSQL endpoint must not be nil") + } + return &PollingStore{endpoint: endpoint}, nil +} + +// Append persists event in the active business transaction carried by ctx. +func (store *PollingStore) Append(ctx context.Context, event Event[[]byte]) error { + if !store.endpoint.InTransaction(ctx) { + return ErrTransactionRequired + } + if err := validateEvent(ctx, event); err != nil { + return err + } + id, aggregateKey, payload, err := prepareStoredEvent(event) + if err != nil { + return err + } + traceContext := traceContextFrom(ctx) + _, err = store.endpoint.Exec(ctx, ` + INSERT INTO outbox_events ( + id, topic, aggregatetype, aggregateid, aggregatekey, type, payload, routing_hash, + traceparent, tracestate + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, id, event.Topic, event.AggregateType, event.AggregateID, aggregateKey, event.Type, payload, + int64(xxh3.HashString(aggregateKey)), traceContext.traceparent, traceContext.tracestate) + if err != nil { + return fmt.Errorf("kafkaoutbox: endpoint.Exec: %w", err) + } + return nil +} + +func prepareStoredEvent(event Event[[]byte]) (uuid.UUID, string, []byte, error) { + id, err := uuid.NewV7() + if err != nil { + return uuid.Nil, "", nil, fmt.Errorf("kafkaoutbox: uuid.NewV7: %w", err) + } + payload := event.Value + if payload == nil { + payload = []byte{} + } + return id, event.AggregateType + "::" + event.AggregateID, payload, nil +} + +var _ Appender = (*PollingStore)(nil) diff --git a/kafkaoutbox/store_integration_test.go b/kafkaoutbox/store_integration_test.go new file mode 100644 index 0000000..7662cdd --- /dev/null +++ b/kafkaoutbox/store_integration_test.go @@ -0,0 +1,155 @@ +//go:build integration + +package kafkaoutbox_test + +import ( + "context" + "database/sql" + "errors" + "io/fs" + "sort" + "testing" + + "github.com/devctllabs/go-libs/kafkaoutbox" + "github.com/devctllabs/go-libs/postgresdb" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/zeebo/xxh3" + "go.opentelemetry.io/otel/trace" +) + +func TestPollingStoreAppendsOnlyInsideBusinessTransaction(t *testing.T) { + ctx := context.Background() + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.PollingMigrations()) + store, err := kafkaoutbox.NewPollingStore(db.Writer()) + require.NoError(t, err) + event := kafkaoutbox.Event[[]byte]{ + Topic: "orders", + AggregateType: "Order", + AggregateID: "42", + Type: "OrderPaid", + Value: []byte("wire"), + } + + err = store.Append(ctx, event) + require.ErrorIs(t, err, kafkaoutbox.ErrTransactionRequired) + require.Equal(t, 0, eventCount(t, ctx, db.Writer())) + + err = db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + return store.Append(txCtx, event) + }) + require.NoError(t, err) + require.Equal(t, 1, eventCount(t, ctx, db.Writer())) + assertStoredEvent(t, ctx, db.Writer()) + + rollbackErr := errors.New("rollback") + err = db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + require.NoError(t, store.Append(txCtx, event)) + return rollbackErr + }) + require.ErrorIs(t, err, rollbackErr) + require.Equal(t, 1, eventCount(t, ctx, db.Writer())) +} + +func TestPollingStorePersistsW3CTraceContext(t *testing.T) { + ctx := context.Background() + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.PollingMigrations()) + store, err := kafkaoutbox.NewPollingStore(db.Writer()) + require.NoError(t, err) + traceState, err := trace.ParseTraceState("vendor=value") + require.NoError(t, err) + spanContext := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, + SpanID: trace.SpanID{1, 2, 3, 4, 5, 6, 7, 8}, + TraceFlags: trace.FlagsSampled, + TraceState: traceState, + }) + tracedCtx := trace.ContextWithSpanContext(ctx, spanContext) + + require.NoError(t, db.Writer().WithinTx(tracedCtx, func(txCtx context.Context) error { + return store.Append(txCtx, kafkaoutbox.Event[[]byte]{ + Topic: "orders", AggregateType: "Order", AggregateID: "42", Type: "OrderPaid", + }) + })) + + var traceparent, tracestate string + require.NoError(t, db.Writer().QueryRow(ctx, ` + SELECT traceparent, tracestate FROM outbox_events + `).Scan(&traceparent, &tracestate)) + require.Equal(t, "00-0102030405060708090a0b0c0d0e0f10-0102030405060708-01", traceparent) + require.Equal(t, "vendor=value", tracestate) +} + +func assertStoredEvent(t *testing.T, ctx context.Context, endpoint *postgresdb.Endpoint) { + t.Helper() + var idText, topic, aggregateType, aggregateID, aggregateKey, eventType string + var payload []byte + var routingHash int64 + var traceparent, tracestate sql.NullString + err := endpoint.QueryRow(ctx, ` + SELECT id::text, topic, aggregatetype, aggregateid, aggregatekey, type, + payload, routing_hash, traceparent, tracestate + FROM outbox_events + `).Scan( + &idText, &topic, &aggregateType, &aggregateID, &aggregateKey, &eventType, + &payload, &routingHash, &traceparent, &tracestate, + ) + require.NoError(t, err) + id, err := uuid.Parse(idText) + require.NoError(t, err) + require.Equal(t, uuid.Version(7), id.Version()) + require.Equal(t, "orders", topic) + require.Equal(t, "Order", aggregateType) + require.Equal(t, "42", aggregateID) + require.Equal(t, "Order::42", aggregateKey) + require.Equal(t, "OrderPaid", eventType) + require.Equal(t, []byte("wire"), payload) + require.Equal(t, int64(xxh3.HashString("Order::42")), routingHash) + require.False(t, traceparent.Valid) + require.False(t, tracestate.Valid) +} + +func openTestDatabase(t *testing.T, ctx context.Context) *postgresdb.DB { + t.Helper() + container, err := tcpostgres.Run( + ctx, + "postgres:17.5-alpine", + tcpostgres.WithDatabase("app"), + tcpostgres.WithUsername("app"), + tcpostgres.WithPassword("password"), + tcpostgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, testcontainers.TerminateContainer(container)) }) + dsn, err := container.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + db, err := postgresdb.Open(ctx, postgresdb.Config{Writer: postgresdb.EndpointConfig{DSN: dsn}}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + return db +} + +func applyMigrations(t *testing.T, ctx context.Context, endpoint *postgresdb.Endpoint, migrations fs.FS) { + t.Helper() + paths, err := fs.Glob(migrations, "*.up.sql") + require.NoError(t, err) + sort.Strings(paths) + require.NotEmpty(t, paths) + for _, path := range paths { + migration, readErr := fs.ReadFile(migrations, path) + require.NoError(t, readErr) + _, execErr := endpoint.Exec(ctx, string(migration)) + require.NoError(t, execErr) + } +} + +func eventCount(t *testing.T, ctx context.Context, endpoint *postgresdb.Endpoint) int { + t.Helper() + var count int + require.NoError(t, endpoint.QueryRow(ctx, `SELECT COUNT(*) FROM outbox_events`).Scan(&count)) + return count +} diff --git a/kafkaoutbox/testdata/debezium-postgres.json b/kafkaoutbox/testdata/debezium-postgres.json new file mode 100644 index 0000000..f3ba212 --- /dev/null +++ b/kafkaoutbox/testdata/debezium-postgres.json @@ -0,0 +1,20 @@ +{ + "connector.class": "io.debezium.connector.postgresql.PostgresConnector", + "plugin.name": "pgoutput", + "publication.autocreate.mode": "filtered", + "publish.via.partition.root": "true", + "table.include.list": "public.outbox_events", + "transforms": "outbox", + "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter", + "transforms.outbox.route.by.field": "topic", + "transforms.outbox.route.topic.replacement": "${routedByValue}", + "transforms.outbox.table.field.event.id": "id", + "transforms.outbox.table.field.event.key": "aggregatekey", + "transforms.outbox.table.field.event.payload": "payload", + "transforms.outbox.table.fields.additional.placement": "type:header:type,aggregatetype:header:aggregatetype", + "transforms.outbox.tracing.span.context.field": "tracingspancontext", + "transforms.outbox.tracing.with.context.field.only": "true", + "value.converter": "io.debezium.converters.BinaryDataConverter", + "value.converter.delegate.converter.type": "org.apache.kafka.connect.json.JsonConverter", + "value.converter.delegate.converter.type.schemas.enable": "false" +} diff --git a/kafkaoutbox/topology_integration_test.go b/kafkaoutbox/topology_integration_test.go new file mode 100644 index 0000000..6495b32 --- /dev/null +++ b/kafkaoutbox/topology_integration_test.go @@ -0,0 +1,96 @@ +//go:build integration + +package kafkaoutbox_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/devctllabs/go-libs/kafkaoutbox" + "github.com/devctllabs/go-libs/postgresdb" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestWorkerReconcilesVersionedEqualRangeTopology(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.PollingMigrations()) + store, err := kafkaoutbox.NewPollingStore(db.Writer()) + require.NoError(t, err) + + runIdleWorker(t, ctx, store, kafkaoutbox.TopologyConfig{}) + assertTopology(t, ctx, db.Writer(), 1, 1, 4) + assertFourShardBounds(t, ctx, db.Writer()) + + runIdleWorker(t, ctx, store, kafkaoutbox.TopologyConfig{Revision: 2, ShardCount: 8}) + assertTopology(t, ctx, db.Writer(), 2, 2, 8) + + runIdleWorker(t, ctx, store, kafkaoutbox.TopologyConfig{Revision: 1, ShardCount: 4}) + assertTopology(t, ctx, db.Writer(), 2, 2, 8) + + ctrl := gomock.NewController(t) + publisher := kafkaoutbox.NewMockBatchPublisher(ctrl) + worker, err := kafkaoutbox.NewWorker(workerConfig(kafkaoutbox.TopologyConfig{Revision: 2, ShardCount: 4}), store, publisher) + require.NoError(t, err) + err = worker.Run(ctx) + require.ErrorContains(t, err, "conflicts with persisted topology revision") +} + +func runIdleWorker(t *testing.T, parent context.Context, store *kafkaoutbox.PollingStore, topology kafkaoutbox.TopologyConfig) { + t.Helper() + ctrl := gomock.NewController(t) + publisher := kafkaoutbox.NewMockBatchPublisher(ctrl) + worker, err := kafkaoutbox.NewWorker(workerConfig(topology), store, publisher) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(parent, 30*time.Millisecond) + defer cancel() + require.NoError(t, worker.Run(ctx)) +} + +func workerConfig(topology kafkaoutbox.TopologyConfig) kafkaoutbox.WorkerConfig { + return kafkaoutbox.WorkerConfig{ + MaxBatchSize: 10, PollInterval: 5 * time.Millisecond, + DatabaseTimeout: time.Second, PublishTimeout: time.Second, LeaseDuration: 4 * time.Second, + MaxAttempts: 1, Topology: topology, + } +} + +func assertTopology(t *testing.T, ctx context.Context, endpoint *postgresdb.Endpoint, revision, generation uint64, count int) { + t.Helper() + var gotRevision, gotGeneration uint64 + var gotCount int + err := endpoint.QueryRow(ctx, `SELECT revision, generation, shard_count FROM outbox_topology`).Scan( + &gotRevision, + &gotGeneration, + &gotCount, + ) + require.NoError(t, err) + require.Equal(t, revision, gotRevision) + require.Equal(t, generation, gotGeneration) + require.Equal(t, count, gotCount) +} + +func assertFourShardBounds(t *testing.T, ctx context.Context, endpoint *postgresdb.Endpoint) { + t.Helper() + rows, err := endpoint.Query(ctx, `SELECT hash_from, hash_to FROM outbox_shards ORDER BY shard_id`) + require.NoError(t, err) + defer rows.Close() + want := [][2]sql.NullInt64{ + {{}, {Int64: -(int64(1) << 62), Valid: true}}, + {{Int64: -(int64(1) << 62), Valid: true}, {Int64: 0, Valid: true}}, + {{Int64: 0, Valid: true}, {Int64: int64(1) << 62, Valid: true}}, + {{Int64: int64(1) << 62, Valid: true}, {}}, + } + var got [][2]sql.NullInt64 + for rows.Next() { + var bounds [2]sql.NullInt64 + require.NoError(t, rows.Scan(&bounds[0], &bounds[1])) + got = append(got, bounds) + } + require.NoError(t, rows.Err()) + require.Equal(t, want, got) +} diff --git a/kafkaoutbox/trace_context.go b/kafkaoutbox/trace_context.go new file mode 100644 index 0000000..183b9ad --- /dev/null +++ b/kafkaoutbox/trace_context.go @@ -0,0 +1,37 @@ +package kafkaoutbox + +import ( + "context" + + "go.opentelemetry.io/otel/propagation" +) + +type persistedTraceContext struct { + traceparent *string + tracestate *string +} + +func traceContextFrom(ctx context.Context) persistedTraceContext { + carrier := propagation.MapCarrier{} + propagation.TraceContext{}.Inject(ctx, carrier) + traceparent := carrier.Get("traceparent") + if traceparent == "" { + return persistedTraceContext{} + } + result := persistedTraceContext{traceparent: &traceparent} + if tracestate := carrier.Get("tracestate"); tracestate != "" { + result.tracestate = &tracestate + } + return result +} + +func (traceContext persistedTraceContext) debeziumProperties() *string { + if traceContext.traceparent == nil { + return nil + } + properties := "traceparent=" + *traceContext.traceparent + "\n" + if traceContext.tracestate != nil { + properties += "tracestate=" + *traceContext.tracestate + "\n" + } + return &properties +} diff --git a/kafkaoutbox/worker.go b/kafkaoutbox/worker.go new file mode 100644 index 0000000..64ddff5 --- /dev/null +++ b/kafkaoutbox/worker.go @@ -0,0 +1,385 @@ +package kafkaoutbox + +import ( + "context" + "errors" + "fmt" + "math" + "sync" + "time" + + "github.com/devctllabs/go-libs/kafka" + "github.com/devctllabs/go-libs/retry" + "go.opentelemetry.io/otel/propagation" +) + +// ErrAlreadyRun reports a second Run call on one Worker instance. +var ErrAlreadyRun = errors.New("kafkaoutbox: worker has already run") + +var errWorkerStopped = errors.New("kafkaoutbox: worker stopped") + +// AttemptsExhaustedError reports a shard that reached its consecutive publish-failure limit. +type AttemptsExhaustedError struct { + Generation uint64 + ShardID uint + Attempts uint + Err error +} + +// Error implements error. +func (err *AttemptsExhaustedError) Error() string { + return fmt.Sprintf( + "kafkaoutbox: shard %d/%d exhausted %d publish attempts: %v", + err.Generation, + err.ShardID, + err.Attempts, + err.Err, + ) +} + +// Unwrap returns the last publish failure. +func (err *AttemptsExhaustedError) Unwrap() error { return err.Err } + +// TopologyConfig selects a monotonic persisted virtual-shard topology. +type TopologyConfig struct { + Revision uint64 + ShardCount uint + AdvisoryLockNamespace int32 +} + +// WorkerConfig controls polling, delivery, retries, and claim lifetime. +type WorkerConfig struct { + MaxBatchSize int + PollInterval time.Duration + DatabaseTimeout time.Duration + PublishTimeout time.Duration + LeaseDuration time.Duration + RetryPolicy retry.Policy + MaxAttempts uint + Topology TopologyConfig + Observer Observer +} + +// Worker claims one virtual shard at a time and publishes its committed events. +type Worker struct { + config WorkerConfig + store *PollingStore + publisher BatchPublisher + observer Observer + + mu sync.Mutex + started bool +} + +// NewWorker constructs a single-use polling worker without starting goroutines. +func NewWorker(config WorkerConfig, store *PollingStore, publisher BatchPublisher) (*Worker, error) { + config = defaultWorkerConfig(config) + if err := validateWorkerConfig(config); err != nil { + return nil, err + } + if store == nil { + return nil, errors.New("kafkaoutbox: polling store must not be nil") + } + if publisher == nil { + return nil, errors.New("kafkaoutbox: batch publisher must not be nil") + } + return &Worker{ + config: config, store: store, publisher: publisher, + observer: effectiveObserver(config.Observer), + }, nil +} + +// Run publishes committed events until ctx is canceled or a terminal error occurs. +// Context cancellation is a clean stop. +func (worker *Worker) Run(ctx context.Context) error { + if ctx == nil { + return errors.New("kafkaoutbox: context must not be nil") + } + if err := worker.beginRun(); err != nil { + return err + } + if err := worker.reconcileTopology(ctx); err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + var nextClaim *shardClaim + for ctx.Err() == nil { + var err error + nextClaim, err = worker.runOnce(ctx, nextClaim) + if err != nil { + if errors.Is(err, errWorkerStopped) { + return nil + } + return err + } + } + return nil +} + +func (worker *Worker) reconcileTopology(ctx context.Context) error { + topologyCtx, done := worker.startOperation(ctx, Operation{Phase: OperationTopology}) + topology, err := worker.store.reconcileTopology(topologyCtx, worker.config) + done(err) + if err != nil { + return fmt.Errorf("kafkaoutbox: reconcile topology: %w", err) + } + worker.observer.TopologyReconciled(topologyCtx, topology) + return nil +} + +func (worker *Worker) runOnce(ctx context.Context, claim *shardClaim) (*shardClaim, error) { + if claim == nil { + claimCtx, done := worker.startOperation(ctx, Operation{Phase: OperationClaim}) + var err error + claim, err = worker.store.claimShard(claimCtx, worker.config) + done(err) + if err != nil { + return nil, worker.operationError(ctx, "claim shard", err) + } + } + if claim == nil { + if waitForPoll(ctx, worker.config.PollInterval) != nil { + return nil, errWorkerStopped + } + return nil, nil + } + return worker.processClaim(ctx, *claim) +} + +func (worker *Worker) processClaim(ctx context.Context, claim shardClaim) (*shardClaim, error) { + readCtx, done := worker.startOperation(ctx, claim.operation(OperationRead, nil)) + batch, err := worker.store.readBatch(readCtx, claim, worker.config) + done(err) + if err != nil { + return nil, worker.operationError(ctx, "read batch", err) + } + if len(batch) == 0 { + return worker.finalizeEmpty(ctx, claim) + } + return worker.publishBatch(ctx, claim, batch) +} + +func (worker *Worker) publishBatch( + ctx context.Context, + claim shardClaim, + batch []storedEvent, +) (*shardClaim, error) { + publishCtx, done := worker.startOperation(ctx, claim.operation(OperationPublish, batch)) + publishCtx, cancel := context.WithTimeout(publishCtx, worker.config.PublishTimeout) + err := worker.publisher.SendBatch(publishCtx, outgoingMessages(batch)) + cancel() + done(err) + if err != nil { + worker.observeBatch(publishCtx, claim, batch, BatchFailed) + if ctx.Err() != nil { + return nil, errWorkerStopped + } + return worker.finalizeFailure(ctx, claim, err) + } + return worker.finalizeSuccess(ctx, claim, batch) +} + +func (worker *Worker) finalizeEmpty(ctx context.Context, claim shardClaim) (*shardClaim, error) { + finalizeCtx, done := worker.startFinalize(ctx, claim) + nextClaim, err := worker.store.finalizeEmpty(finalizeCtx, claim, ctx.Err() == nil, worker.config) + done(err) + if errors.Is(err, errClaimLost) { + worker.observeFencing(finalizeCtx, claim, OperationFinalize) + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("kafkaoutbox: finalize empty shard: %w", err) + } + return nextClaim, nil +} + +func (worker *Worker) finalizeFailure( + ctx context.Context, + claim shardClaim, + publishErr error, +) (*shardClaim, error) { + finalizeCtx, done := worker.startFinalize(ctx, claim) + failures, delay, nextClaim, err := worker.store.finalizeFailure(finalizeCtx, claim, worker.config) + done(err) + if errors.Is(err, errClaimLost) { + worker.observeFencing(finalizeCtx, claim, OperationFinalize) + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("kafkaoutbox: finalize failed batch: %w", err) + } + if worker.config.MaxAttempts != 0 && failures >= worker.config.MaxAttempts { + return nil, &AttemptsExhaustedError{ + Generation: claim.generation, ShardID: claim.id, Attempts: failures, Err: publishErr, + } + } + worker.observer.Retry(finalizeCtx, RetryEvent{ + Generation: claim.generation, ShardID: claim.id, + Attempt: failures, NextDelay: delay, Err: publishErr, + }) + return nextClaim, nil +} + +func (worker *Worker) finalizeSuccess( + ctx context.Context, + claim shardClaim, + batch []storedEvent, +) (*shardClaim, error) { + finalizeCtx, done := worker.startFinalize(ctx, claim) + nextClaim, err := worker.store.finalizeSuccess( + finalizeCtx, claim, batch, ctx.Err() == nil, worker.config, + ) + done(err) + if errors.Is(err, errClaimLost) { + worker.observeBatch(finalizeCtx, claim, batch, BatchFenced) + worker.observeFencing(finalizeCtx, claim, OperationFinalize) + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("kafkaoutbox: finalize successful batch: %w", err) + } + worker.observeBatch(finalizeCtx, claim, batch, BatchDelivered) + return nextClaim, nil +} + +func (worker *Worker) startFinalize(ctx context.Context, claim shardClaim) (context.Context, OperationDone) { + return worker.startOperation(context.WithoutCancel(ctx), claim.operation(OperationFinalize, nil)) +} + +func (*Worker) operationError(ctx context.Context, operation string, err error) error { + if ctx.Err() != nil { + return errWorkerStopped + } + return fmt.Errorf("kafkaoutbox: %s: %w", operation, err) +} + +func (worker *Worker) startOperation(ctx context.Context, operation Operation) (context.Context, OperationDone) { + observedCtx, done := worker.observer.StartOperation(ctx, operation) + if observedCtx == nil { + observedCtx = ctx + } + if done == nil { + done = func(error) {} + } + return observedCtx, done +} + +func (worker *Worker) observeBatch(ctx context.Context, claim shardClaim, batch []storedEvent, outcome BatchOutcome) { + worker.observer.BatchCompleted(ctx, BatchResult{ + Outcome: outcome, Generation: claim.generation, ShardID: claim.id, Size: len(batch), + }) +} + +func (worker *Worker) observeFencing(ctx context.Context, claim shardClaim, phase OperationPhase) { + worker.observer.FencingConflict(ctx, FencingEvent{ + Phase: phase, Generation: claim.generation, ShardID: claim.id, + }) +} + +func (claim shardClaim) operation(phase OperationPhase, batch []storedEvent) Operation { + return Operation{ + Phase: phase, Generation: claim.generation, ShardID: claim.id, + Records: recordMetadata(batch), + } +} + +func recordMetadata(batch []storedEvent) []RecordMetadata { + records := make([]RecordMetadata, len(batch)) + for index, event := range batch { + carrier := propagation.MapCarrier{} + if event.traceparent != nil { + carrier.Set("traceparent", *event.traceparent) + } + if event.tracestate != nil { + carrier.Set("tracestate", *event.tracestate) + } + records[index] = RecordMetadata{ + Topic: event.topic, + Context: propagation.TraceContext{}.Extract(context.Background(), carrier), + } + } + return records +} + +func (worker *Worker) beginRun() error { + worker.mu.Lock() + defer worker.mu.Unlock() + if worker.started { + return ErrAlreadyRun + } + worker.started = true + return nil +} + +func defaultWorkerConfig(config WorkerConfig) WorkerConfig { + if config.Topology.Revision == 0 { + config.Topology.Revision = 1 + } + if config.Topology.ShardCount == 0 { + config.Topology.ShardCount = 4 + } + return config +} + +func validateWorkerConfig(config WorkerConfig) error { + if config.MaxBatchSize <= 0 { + return errors.New("kafkaoutbox: maximum batch size must be positive") + } + if config.PollInterval <= 0 { + return errors.New("kafkaoutbox: poll interval must be positive") + } + if config.DatabaseTimeout <= 0 { + return errors.New("kafkaoutbox: database timeout must be positive") + } + if config.PublishTimeout <= 0 { + return errors.New("kafkaoutbox: publish timeout must be positive") + } + minimumLease := config.PublishTimeout + 2*config.DatabaseTimeout + if config.LeaseDuration <= minimumLease { + return fmt.Errorf("kafkaoutbox: lease duration must exceed %s", minimumLease) + } + if config.Topology.Revision > math.MaxInt64 { + return errors.New("kafkaoutbox: topology revision exceeds PostgreSQL bigint") + } + if !validShardCount(config.Topology.ShardCount) { + return errors.New("kafkaoutbox: shard count must be a power of two between 1 and 1024") + } + if config.MaxAttempts != 1 && config.RetryPolicy == nil { + return errors.New("kafkaoutbox: retry policy is required when retries are possible") + } + return nil +} + +func validShardCount(count uint) bool { + return count >= 1 && count <= 1024 && count&(count-1) == 0 +} + +func outgoingMessages(batch []storedEvent) []kafka.OutgoingMessage[[]byte] { + messages := make([]kafka.OutgoingMessage[[]byte], len(batch)) + for index, event := range batch { + messages[index] = kafka.OutgoingMessage[[]byte]{ + Topic: event.topic, + Key: []byte(event.aggregateKey), + Headers: []kafka.Header{ + {Key: "id", Value: []byte(event.id.String())}, + {Key: "type", Value: []byte(event.eventType)}, + {Key: "aggregatetype", Value: []byte(event.aggregateType)}, + }, + Value: event.payload, + } + } + return messages +} + +func waitForPoll(ctx context.Context, interval time.Duration) error { + timer := time.NewTimer(interval) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/kafkaoutbox/worker_integration_test.go b/kafkaoutbox/worker_integration_test.go new file mode 100644 index 0000000..abae079 --- /dev/null +++ b/kafkaoutbox/worker_integration_test.go @@ -0,0 +1,441 @@ +//go:build integration + +package kafkaoutbox_test + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/devctllabs/go-libs/kafka" + "github.com/devctllabs/go-libs/kafkaoutbox" + "github.com/devctllabs/go-libs/retry" + "github.com/stretchr/testify/require" + "github.com/zeebo/xxh3" + "go.uber.org/mock/gomock" +) + +func TestWorkerPublishesCommittedEventAndDeletesIt(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.PollingMigrations()) + store, err := kafkaoutbox.NewPollingStore(db.Writer()) + require.NoError(t, err) + require.NoError(t, db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + return store.Append(txCtx, kafkaoutbox.Event[[]byte]{ + Topic: "orders", + AggregateType: "Order", + AggregateID: "42", + Type: "OrderPaid", + Value: []byte("wire"), + }) + })) + + ctrl := gomock.NewController(t) + publisher := kafkaoutbox.NewMockBatchPublisher(ctrl) + runCtx, cancel := context.WithCancel(ctx) + publisher.EXPECT().SendBatch(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, messages []kafka.OutgoingMessage[[]byte]) error { + require.Len(t, messages, 1) + require.Equal(t, "orders", messages[0].Topic) + require.Equal(t, []byte("Order::42"), messages[0].Key) + require.Equal(t, []byte("wire"), messages[0].Value) + require.Equal(t, []string{"id", "type", "aggregatetype"}, headerKeys(messages[0].Headers)) + cancel() + return nil + }, + ) + worker, err := kafkaoutbox.NewWorker(kafkaoutbox.WorkerConfig{ + MaxBatchSize: 100, + PollInterval: 10 * time.Millisecond, + DatabaseTimeout: time.Second, + PublishTimeout: time.Second, + LeaseDuration: 4 * time.Second, + MaxAttempts: 1, + }, store, publisher) + require.NoError(t, err) + + err = worker.Run(runCtx) + require.NoError(t, err) + require.Equal(t, 0, eventCount(t, ctx, db.Writer())) +} + +func TestWorkerReportsLifecycleWithoutTreatingDeliveryAsFencing(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.PollingMigrations()) + store, err := kafkaoutbox.NewPollingStore(db.Writer()) + require.NoError(t, err) + require.NoError(t, db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + return store.Append(txCtx, kafkaoutbox.Event[[]byte]{ + Topic: "orders", AggregateType: "Order", AggregateID: "42", Type: "OrderPaid", + }) + })) + observer := &recordingOutboxObserver{} + ctrl := gomock.NewController(t) + publisher := kafkaoutbox.NewMockBatchPublisher(ctrl) + runCtx, cancel := context.WithCancel(ctx) + publisher.EXPECT().SendBatch(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ []kafka.OutgoingMessage[[]byte]) error { + cancel() + return nil + }, + ) + config := workerConfig(kafkaoutbox.TopologyConfig{}) + config.Observer = observer + worker, err := kafkaoutbox.NewWorker(config, store, publisher) + require.NoError(t, err) + + require.NoError(t, worker.Run(runCtx)) + require.Contains(t, observer.operationPhases(), kafkaoutbox.OperationTopology) + require.Contains(t, observer.operationPhases(), kafkaoutbox.OperationClaim) + require.Contains(t, observer.operationPhases(), kafkaoutbox.OperationRead) + require.Contains(t, observer.operationPhases(), kafkaoutbox.OperationPublish) + require.Contains(t, observer.operationPhases(), kafkaoutbox.OperationFinalize) + require.Equal(t, []kafkaoutbox.BatchOutcome{kafkaoutbox.BatchDelivered}, observer.batchOutcomes()) + require.Len(t, observer.topologies, 1) + require.True(t, observer.topologies[0].Changed) + require.Equal(t, uint(4), observer.topologies[0].ShardCount) + require.Empty(t, observer.fencing) +} + +func TestWorkerRetriesTheSameBatchAfterPublishFailure(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.PollingMigrations()) + store, err := kafkaoutbox.NewPollingStore(db.Writer()) + require.NoError(t, err) + for _, aggregateID := range sameShardAggregateIDs() { + require.NoError(t, db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + return store.Append(txCtx, kafkaoutbox.Event[[]byte]{ + Topic: "orders", AggregateType: "Order", AggregateID: aggregateID, + Type: "OrderPaid", Value: []byte(aggregateID), + }) + })) + } + policy, err := retry.NewExponential(retry.ExponentialConfig{ + InitialDelay: time.Millisecond, + MaxDelay: time.Millisecond, + Multiplier: 2, + }) + require.NoError(t, err) + ctrl := gomock.NewController(t) + publisher := kafkaoutbox.NewMockBatchPublisher(ctrl) + runCtx, cancel := context.WithCancel(ctx) + var firstIDs []string + gomock.InOrder( + publisher.EXPECT().SendBatch(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, messages []kafka.OutgoingMessage[[]byte]) error { + firstIDs = messageIDs(messages) + return errors.New("broker unavailable") + }, + ), + publisher.EXPECT().SendBatch(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, messages []kafka.OutgoingMessage[[]byte]) error { + require.Equal(t, firstIDs, messageIDs(messages)) + cancel() + return nil + }, + ), + ) + worker, err := kafkaoutbox.NewWorker(kafkaoutbox.WorkerConfig{ + MaxBatchSize: 2, PollInterval: time.Millisecond, + DatabaseTimeout: time.Second, PublishTimeout: time.Second, LeaseDuration: 4 * time.Second, + RetryPolicy: policy, + }, store, publisher) + require.NoError(t, err) + + require.NoError(t, worker.Run(runCtx)) + require.Equal(t, 0, eventCount(t, ctx, db.Writer())) +} + +func TestWorkerStopsAfterBoundedPublishFailuresAndPreservesEvent(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.PollingMigrations()) + store, err := kafkaoutbox.NewPollingStore(db.Writer()) + require.NoError(t, err) + require.NoError(t, db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + return store.Append(txCtx, kafkaoutbox.Event[[]byte]{ + Topic: "orders", AggregateType: "Order", AggregateID: "42", + Type: "OrderPaid", Value: []byte("wire"), + }) + })) + ctrl := gomock.NewController(t) + publisher := kafkaoutbox.NewMockBatchPublisher(ctrl) + publishErr := errors.New("authorization denied") + publisher.EXPECT().SendBatch(gomock.Any(), gomock.Any()).Return(publishErr) + worker, err := kafkaoutbox.NewWorker(kafkaoutbox.WorkerConfig{ + MaxBatchSize: 1, PollInterval: time.Millisecond, + DatabaseTimeout: time.Second, PublishTimeout: time.Second, LeaseDuration: 4 * time.Second, + MaxAttempts: 1, + }, store, publisher) + require.NoError(t, err) + + err = worker.Run(ctx) + var exhausted *kafkaoutbox.AttemptsExhaustedError + require.ErrorAs(t, err, &exhausted) + require.ErrorIs(t, err, publishErr) + require.Equal(t, uint(1), exhausted.Attempts) + require.Equal(t, 1, eventCount(t, ctx, db.Writer())) + var failures uint + require.NoError(t, db.Writer().QueryRow(ctx, `SELECT MAX(failure_count) FROM outbox_shards`).Scan(&failures)) + require.Equal(t, uint(1), failures) +} + +func TestWorkerDoesNotLoseEnqueueDuringPartialBatchPublish(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.PollingMigrations()) + store, err := kafkaoutbox.NewPollingStore(db.Writer()) + require.NoError(t, err) + ids := sameShardAggregateIDs() + appendEvent := func(aggregateID string) error { + return db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + return store.Append(txCtx, kafkaoutbox.Event[[]byte]{ + Topic: "orders", AggregateType: "Order", AggregateID: aggregateID, + Type: "OrderPaid", Value: []byte(aggregateID), + }) + }) + } + require.NoError(t, appendEvent(ids[0])) + ctrl := gomock.NewController(t) + publisher := kafkaoutbox.NewMockBatchPublisher(ctrl) + runCtx, cancel := context.WithCancel(ctx) + gomock.InOrder( + publisher.EXPECT().SendBatch(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, messages []kafka.OutgoingMessage[[]byte]) error { + require.Len(t, messages, 1) + require.NoError(t, appendEvent(ids[1])) + return nil + }, + ), + publisher.EXPECT().SendBatch(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, messages []kafka.OutgoingMessage[[]byte]) error { + require.Len(t, messages, 1) + require.Equal(t, []byte(ids[1]), messages[0].Value) + cancel() + return nil + }, + ), + ) + worker, err := kafkaoutbox.NewWorker(kafkaoutbox.WorkerConfig{ + MaxBatchSize: 2, PollInterval: 5 * time.Millisecond, + DatabaseTimeout: time.Second, PublishTimeout: time.Second, LeaseDuration: 4 * time.Second, + MaxAttempts: 1, + }, store, publisher) + require.NoError(t, err) + + require.NoError(t, worker.Run(runCtx)) + require.Equal(t, 0, eventCount(t, ctx, db.Writer())) +} + +func TestWorkerRotatesToOlderDueShardAfterFullBatch(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.PollingMigrations()) + store, err := kafkaoutbox.NewPollingStore(db.Writer()) + require.NoError(t, err) + idsByShard := aggregateIDsForShards(2, 1) + for _, aggregateID := range append(idsByShard[0], idsByShard[1]...) { + require.NoError(t, db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + return store.Append(txCtx, kafkaoutbox.Event[[]byte]{ + Topic: "orders", AggregateType: "Order", AggregateID: aggregateID, Type: "OrderPaid", + }) + })) + } + ctrl := gomock.NewController(t) + publisher := kafkaoutbox.NewMockBatchPublisher(ctrl) + runCtx, cancel := context.WithCancel(ctx) + seenShards := make([]int, 0, 2) + observer := &recordingOutboxObserver{} + publisher.EXPECT().SendBatch(gomock.Any(), gomock.Any()).Times(2).DoAndReturn( + func(_ context.Context, messages []kafka.OutgoingMessage[[]byte]) error { + require.Len(t, messages, 1) + seenShards = append(seenShards, defaultShardFor(int64(xxh3.Hash(messages[0].Key)))) + if len(seenShards) == 2 { + cancel() + } + return nil + }, + ) + worker, err := kafkaoutbox.NewWorker(kafkaoutbox.WorkerConfig{ + MaxBatchSize: 1, PollInterval: time.Second, + DatabaseTimeout: time.Second, PublishTimeout: time.Second, LeaseDuration: 4 * time.Second, + MaxAttempts: 1, Observer: observer, + }, store, publisher) + require.NoError(t, err) + + require.NoError(t, worker.Run(runCtx)) + require.Equal(t, []int{0, 1}, seenShards) + require.Equal(t, 1, countPhase(observer.operationPhases(), kafkaoutbox.OperationClaim)) +} + +func TestWorkerDoesNotDeleteAfterLosingClaimToRepartition(t *testing.T) { + ctx, stop := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(stop) + db := openTestDatabase(t, ctx) + applyMigrations(t, ctx, db.Writer(), kafkaoutbox.PollingMigrations()) + store, err := kafkaoutbox.NewPollingStore(db.Writer()) + require.NoError(t, err) + require.NoError(t, db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + return store.Append(txCtx, kafkaoutbox.Event[[]byte]{ + Topic: "orders", AggregateType: "Order", AggregateID: "42", + Type: "OrderPaid", Value: []byte("wire"), + }) + })) + ctrl := gomock.NewController(t) + publisher := kafkaoutbox.NewMockBatchPublisher(ctrl) + runCtx, cancel := context.WithCancel(ctx) + publisher.EXPECT().SendBatch(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ []kafka.OutgoingMessage[[]byte]) error { + require.NoError(t, db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + _, execErr := db.Writer().Exec(txCtx, `DELETE FROM outbox_shards`) + return execErr + })) + cancel() + return nil + }, + ) + worker, err := kafkaoutbox.NewWorker(workerConfig(kafkaoutbox.TopologyConfig{}), store, publisher) + require.NoError(t, err) + + require.NoError(t, worker.Run(runCtx)) + require.Equal(t, 1, eventCount(t, ctx, db.Writer())) +} + +func sameShardAggregateIDs() []string { + byShard := make(map[int][]string) + for candidate := 0; ; candidate++ { + id := fmt.Sprintf("candidate-%d", candidate) + shard := defaultShardFor(int64(xxh3.HashString("Order::" + id))) + byShard[shard] = append(byShard[shard], id) + if len(byShard[shard]) == 2 { + return byShard[shard] + } + } +} + +func aggregateIDsForShards(firstCount, secondCount int) map[int][]string { + result := map[int][]string{0: {}, 1: {}} + for candidate := 0; len(result[0]) < firstCount || len(result[1]) < secondCount; candidate++ { + id := fmt.Sprintf("rotation-%d", candidate) + shard := defaultShardFor(int64(xxh3.HashString("Order::" + id))) + limit := firstCount + if shard == 1 { + limit = secondCount + } + if (shard == 0 || shard == 1) && len(result[shard]) < limit { + result[shard] = append(result[shard], id) + } + } + return result +} + +func defaultShardFor(hash int64) int { + switch { + case hash < -(int64(1) << 62): + return 0 + case hash < 0: + return 1 + case hash < int64(1)<<62: + return 2 + default: + return 3 + } +} + +func headerKeys(headers []kafka.Header) []string { + keys := make([]string, len(headers)) + for index, header := range headers { + keys[index] = header.Key + } + return keys +} + +func messageIDs(messages []kafka.OutgoingMessage[[]byte]) []string { + ids := make([]string, len(messages)) + for index, message := range messages { + ids[index] = string(message.Headers[0].Value) + } + return ids +} + +type recordingOutboxObserver struct { + mu sync.Mutex + operations []kafkaoutbox.Operation + batches []kafkaoutbox.BatchResult + topologies []kafkaoutbox.TopologyResult + fencing []kafkaoutbox.FencingEvent +} + +func (observer *recordingOutboxObserver) StartOperation( + ctx context.Context, + operation kafkaoutbox.Operation, +) (context.Context, kafkaoutbox.OperationDone) { + observer.mu.Lock() + observer.operations = append(observer.operations, operation) + observer.mu.Unlock() + return ctx, func(error) {} +} + +func (*recordingOutboxObserver) Enqueued(context.Context) {} + +func (observer *recordingOutboxObserver) BatchCompleted(_ context.Context, result kafkaoutbox.BatchResult) { + observer.mu.Lock() + defer observer.mu.Unlock() + observer.batches = append(observer.batches, result) +} + +func (*recordingOutboxObserver) Retry(context.Context, kafkaoutbox.RetryEvent) {} + +func (observer *recordingOutboxObserver) FencingConflict(_ context.Context, event kafkaoutbox.FencingEvent) { + observer.mu.Lock() + defer observer.mu.Unlock() + observer.fencing = append(observer.fencing, event) +} + +func (observer *recordingOutboxObserver) TopologyReconciled(_ context.Context, result kafkaoutbox.TopologyResult) { + observer.mu.Lock() + defer observer.mu.Unlock() + observer.topologies = append(observer.topologies, result) +} + +func (observer *recordingOutboxObserver) operationPhases() []kafkaoutbox.OperationPhase { + observer.mu.Lock() + defer observer.mu.Unlock() + phases := make([]kafkaoutbox.OperationPhase, len(observer.operations)) + for index, operation := range observer.operations { + phases[index] = operation.Phase + } + return phases +} + +func (observer *recordingOutboxObserver) batchOutcomes() []kafkaoutbox.BatchOutcome { + observer.mu.Lock() + defer observer.mu.Unlock() + outcomes := make([]kafkaoutbox.BatchOutcome, len(observer.batches)) + for index, batch := range observer.batches { + outcomes[index] = batch.Outcome + } + return outcomes +} + +func countPhase(phases []kafkaoutbox.OperationPhase, want kafkaoutbox.OperationPhase) int { + count := 0 + for _, phase := range phases { + if phase == want { + count++ + } + } + return count +} diff --git a/kafkaoutbox/worker_store.go b/kafkaoutbox/worker_store.go new file mode 100644 index 0000000..0d39169 --- /dev/null +++ b/kafkaoutbox/worker_store.go @@ -0,0 +1,377 @@ +package kafkaoutbox + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +var errClaimLost = errors.New("kafkaoutbox: shard claim lost") + +type shardClaim struct { + generation uint64 + id uint + from *int64 + to *int64 + cursor *int64 + token uuid.UUID +} + +type storedEvent struct { + id uuid.UUID + topic string + aggregateType string + aggregateKey string + eventType string + payload []byte + routingHash int64 + traceparent *string + tracestate *string +} + +func (store *PollingStore) reconcileTopology(ctx context.Context, config WorkerConfig) (TopologyResult, error) { + operationCtx, cancel := context.WithTimeout(ctx, config.DatabaseTimeout) + defer cancel() + var result TopologyResult + err := store.endpoint.WithinTx(operationCtx, func(txCtx context.Context) error { + _, err := store.endpoint.Exec(txCtx, ` + SELECT pg_advisory_xact_lock( + ($1::bigint << 32) | 'outbox_topology'::regclass::oid::bigint + ) + `, config.Topology.AdvisoryLockNamespace) + if err != nil { + return fmt.Errorf("endpoint.Exec advisory lock: %w", err) + } + var revision, generation uint64 + var shardCount uint + err = store.endpoint.QueryRow(txCtx, ` + SELECT revision, generation, shard_count + FROM outbox_topology + WHERE singleton = true + FOR UPDATE + `).Scan(&revision, &generation, &shardCount) + switch { + case errors.Is(err, pgx.ErrNoRows): + result = TopologyResult{ + Revision: config.Topology.Revision, Generation: 1, + ShardCount: config.Topology.ShardCount, Changed: true, + } + return store.replaceTopology(txCtx, result.Revision, result.Generation, result.ShardCount) + case err != nil: + return fmt.Errorf("endpoint.QueryRow topology: %w", err) + case config.Topology.Revision < revision: + result = TopologyResult{Revision: revision, Generation: generation, ShardCount: shardCount} + return nil + case config.Topology.Revision == revision && config.Topology.ShardCount != shardCount: + return errors.New("configured shard count conflicts with persisted topology revision") + case config.Topology.Revision == revision: + result = TopologyResult{Revision: revision, Generation: generation, ShardCount: shardCount} + return nil + default: + result = TopologyResult{ + Revision: config.Topology.Revision, Generation: generation + 1, + ShardCount: config.Topology.ShardCount, Changed: true, + } + return store.replaceTopology(txCtx, result.Revision, result.Generation, result.ShardCount) + } + }) + return result, err +} + +func (store *PollingStore) replaceTopology(ctx context.Context, revision, generation uint64, count uint) error { + if _, err := store.endpoint.Exec(ctx, `DELETE FROM outbox_shards`); err != nil { + return fmt.Errorf("endpoint.Exec delete shards: %w", err) + } + if _, err := store.endpoint.Exec(ctx, ` + INSERT INTO outbox_topology (singleton, revision, generation, shard_count) + VALUES (true, $1, $2, $3) + ON CONFLICT (singleton) DO UPDATE + SET revision = EXCLUDED.revision, + generation = EXCLUDED.generation, + shard_count = EXCLUDED.shard_count + `, revision, generation, count); err != nil { + return fmt.Errorf("endpoint.Exec upsert topology: %w", err) + } + for shardID := uint(0); shardID < count; shardID++ { + from, to := shardBounds(shardID, count) + if _, err := store.endpoint.Exec(ctx, ` + INSERT INTO outbox_shards (generation, shard_id, hash_from, hash_to) + VALUES ($1, $2, $3, $4) + `, generation, shardID, from, to); err != nil { + return fmt.Errorf("endpoint.Exec insert shard %d: %w", shardID, err) + } + } + return nil +} + +func shardBounds(shardID, count uint) (*int64, *int64) { + var from, to *int64 + step := ^uint64(0)/uint64(count) + 1 + if shardID > 0 { + value := int64(uint64(shardID)*step ^ (uint64(1) << 63)) + from = &value + } + if shardID+1 < count { + value := int64(uint64(shardID+1)*step ^ (uint64(1) << 63)) + to = &value + } + return from, to +} + +func (store *PollingStore) claimShard(ctx context.Context, config WorkerConfig) (*shardClaim, error) { + operationCtx, cancel := context.WithTimeout(ctx, config.DatabaseTimeout) + defer cancel() + var claim *shardClaim + err := store.endpoint.WithinTx(operationCtx, func(txCtx context.Context) error { + var err error + claim, err = store.claimShardTx(txCtx, config) + return err + }) + return claim, err +} + +func (store *PollingStore) claimShardTx(ctx context.Context, config WorkerConfig) (*shardClaim, error) { + row := store.endpoint.QueryRow(ctx, ` + SELECT generation, shard_id, hash_from, hash_to, scan_after_hash + FROM outbox_shards + WHERE next_attempt_at <= clock_timestamp() + AND (owner_token IS NULL OR lease_until <= clock_timestamp()) + ORDER BY next_attempt_at, shard_id + FOR UPDATE SKIP LOCKED + LIMIT 1 + `) + candidate := shardClaim{} + if err := row.Scan(&candidate.generation, &candidate.id, &candidate.from, &candidate.to, &candidate.cursor); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("endpoint.QueryRow due shard: %w", err) + } + token, err := uuid.NewV7() + if err != nil { + return nil, fmt.Errorf("uuid.NewV7: %w", err) + } + candidate.token = token + tag, err := store.endpoint.Exec(ctx, ` + UPDATE outbox_shards + SET owner_token = $3, + lease_until = clock_timestamp() + $4 * interval '1 microsecond' + WHERE generation = $1 AND shard_id = $2 + `, candidate.generation, candidate.id, candidate.token, config.LeaseDuration.Microseconds()) + if err != nil { + return nil, fmt.Errorf("endpoint.Exec claim shard: %w", err) + } + if tag.RowsAffected() != 1 { + return nil, errClaimLost + } + return &candidate, nil +} + +func (store *PollingStore) readBatch(ctx context.Context, claim shardClaim, config WorkerConfig) ([]storedEvent, error) { + operationCtx, cancel := context.WithTimeout(ctx, config.DatabaseTimeout) + defer cancel() + batch, err := store.queryEvents(operationCtx, claim, false, config.MaxBatchSize) + if err != nil || claim.cursor == nil || len(batch) == config.MaxBatchSize { + return batch, err + } + wrapped, err := store.queryEvents(operationCtx, claim, true, config.MaxBatchSize-len(batch)) + return append(batch, wrapped...), err +} + +func (store *PollingStore) queryEvents(ctx context.Context, claim shardClaim, wrapped bool, limit int) ([]storedEvent, error) { + comparison := `($3::bigint IS NULL OR routing_hash > $3)` + if wrapped { + comparison = `routing_hash <= $3` + } + rows, err := store.endpoint.Query(ctx, ` + SELECT id, topic, aggregatetype, aggregatekey, type, payload, + routing_hash, traceparent, tracestate + FROM outbox_events + WHERE ($1::bigint IS NULL OR routing_hash >= $1) + AND ($2::bigint IS NULL OR routing_hash < $2) + AND `+comparison+` + ORDER BY routing_hash, id + LIMIT $4 + `, claim.from, claim.to, claim.cursor, limit) + if err != nil { + return nil, fmt.Errorf("endpoint.Query events: %w", err) + } + defer rows.Close() + batch := make([]storedEvent, 0, limit) + for rows.Next() { + event := storedEvent{} + if err := rows.Scan( + &event.id, &event.topic, &event.aggregateType, &event.aggregateKey, + &event.eventType, &event.payload, &event.routingHash, + &event.traceparent, &event.tracestate, + ); err != nil { + return nil, fmt.Errorf("rows.Scan event: %w", err) + } + batch = append(batch, event) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows.Err events: %w", err) + } + return batch, nil +} + +func (store *PollingStore) finalizeEmpty( + ctx context.Context, + claim shardClaim, + claimNext bool, + config WorkerConfig, +) (*shardClaim, error) { + return store.finalize(ctx, claim, nil, claim.cursor, config.PollInterval, claimNext, config) +} + +func (store *PollingStore) finalizeSuccess( + ctx context.Context, + claim shardClaim, + batch []storedEvent, + claimNext bool, + config WorkerConfig, +) (*shardClaim, error) { + delay := time.Duration(0) + if len(batch) < config.MaxBatchSize { + delay = config.PollInterval + } + return store.finalize( + ctx, claim, eventIDs(batch), &batch[len(batch)-1].routingHash, delay, claimNext, config, + ) +} + +func (store *PollingStore) finalizeFailure( + ctx context.Context, + claim shardClaim, + config WorkerConfig, +) (uint, time.Duration, *shardClaim, error) { + operationCtx, cancel := context.WithTimeout(ctx, config.DatabaseTimeout) + defer cancel() + var failures uint + var delay time.Duration + var nextClaim *shardClaim + err := store.endpoint.WithinTx(operationCtx, func(txCtx context.Context) error { + current, err := store.lockClaim(txCtx, claim) + if err != nil { + return err + } + failures = current + 1 + delay = config.PollInterval + if config.RetryPolicy != nil { + delay = config.RetryPolicy.Delay(failures) + if delay < 0 { + return errors.New("retry policy returned a negative delay") + } + } + tag, err := store.endpoint.Exec(txCtx, ` + UPDATE outbox_shards + SET owner_token = NULL, + lease_until = NULL, + next_attempt_at = clock_timestamp() + $4 * interval '1 microsecond', + failure_count = $5 + WHERE generation = $1 AND shard_id = $2 AND owner_token = $3 + `, claim.generation, claim.id, claim.token, delay.Microseconds(), failures) + if err != nil { + return fmt.Errorf("endpoint.Exec fail shard: %w", err) + } + if tag.RowsAffected() != 1 { + return errClaimLost + } + if config.MaxAttempts != 0 && failures >= config.MaxAttempts { + return nil + } + nextClaim, err = store.claimShardTx(txCtx, config) + return err + }) + return failures, delay, nextClaim, err +} + +func (store *PollingStore) finalize( + ctx context.Context, + claim shardClaim, + ids []uuid.UUID, + cursor *int64, + delay time.Duration, + claimNext bool, + config WorkerConfig, +) (*shardClaim, error) { + operationCtx, cancel := context.WithTimeout(ctx, config.DatabaseTimeout) + defer cancel() + var nextClaim *shardClaim + err := store.endpoint.WithinTx(operationCtx, func(txCtx context.Context) error { + if _, err := store.lockClaim(txCtx, claim); err != nil { + return err + } + if len(ids) > 0 { + if err := store.deleteEvents(txCtx, ids); err != nil { + return fmt.Errorf("endpoint.Exec delete events: %w", err) + } + } + tag, err := store.endpoint.Exec(txCtx, ` + UPDATE outbox_shards + SET scan_after_hash = $4, + owner_token = NULL, + lease_until = NULL, + next_attempt_at = clock_timestamp() + $5 * interval '1 microsecond', + failure_count = 0 + WHERE generation = $1 AND shard_id = $2 AND owner_token = $3 + `, claim.generation, claim.id, claim.token, cursor, delay.Microseconds()) + if err != nil { + return fmt.Errorf("endpoint.Exec finalize shard: %w", err) + } + if tag.RowsAffected() != 1 { + return errClaimLost + } + if !claimNext { + return nil + } + nextClaim, err = store.claimShardTx(txCtx, config) + return err + }) + return nextClaim, err +} + +func (store *PollingStore) lockClaim(ctx context.Context, claim shardClaim) (uint, error) { + var failures uint + err := store.endpoint.QueryRow(ctx, ` + SELECT failure_count + FROM outbox_shards + WHERE generation = $1 AND shard_id = $2 AND owner_token = $3 + FOR UPDATE + `, claim.generation, claim.id, claim.token).Scan(&failures) + if errors.Is(err, pgx.ErrNoRows) { + return 0, errClaimLost + } + if err != nil { + return 0, fmt.Errorf("endpoint.QueryRow fence: %w", err) + } + return failures, nil +} + +func (store *PollingStore) deleteEvents(ctx context.Context, ids []uuid.UUID) error { + placeholders := make([]string, len(ids)) + arguments := make([]any, len(ids)) + for index, id := range ids { + placeholders[index] = fmt.Sprintf("$%d::uuid", index+1) + arguments[index] = id.String() + } + _, err := store.endpoint.Exec( + ctx, + `DELETE FROM outbox_events WHERE id IN (`+strings.Join(placeholders, ",")+`)`, + arguments..., + ) + return err +} + +func eventIDs(batch []storedEvent) []uuid.UUID { + ids := make([]uuid.UUID, len(batch)) + for index, event := range batch { + ids[index] = event.id + } + return ids +} diff --git a/kafkaoutboxzap/go.mod b/kafkaoutboxzap/go.mod new file mode 100644 index 0000000..c0e7bc0 --- /dev/null +++ b/kafkaoutboxzap/go.mod @@ -0,0 +1,43 @@ +module github.com/devctllabs/go-libs/kafkaoutboxzap + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/kafkaoutbox v0.1.0 + github.com/stretchr/testify v1.11.1 + go.uber.org/zap v1.27.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/devctllabs/go-libs/kafka v0.1.0 // indirect + github.com/devctllabs/go-libs/postgresdb v0.1.0 // indirect + github.com/devctllabs/go-libs/retry v0.1.0 // indirect + github.com/devctllabs/go-libs/txmanager v0.1.0 // indirect + github.com/exaring/otelpgx v0.11.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/twmb/franz-go v1.21.1 // indirect + github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect + github.com/twmb/franz-go/plugin/kotel v1.7.0 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/kafkaoutboxzap/go.sum b/kafkaoutboxzap/go.sum new file mode 100644 index 0000000..e3acb3c --- /dev/null +++ b/kafkaoutboxzap/go.sum @@ -0,0 +1,165 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/exaring/otelpgx v0.11.1 h1:pE79fIg/qh/Lpu00kvswFC5dKfqyJJhMJ4Y4N3w5Lj4= +github.com/exaring/otelpgx v0.11.1/go.mod h1:3OojrUKhhy3lTbYIMBijP3YjMey/jo14eHAW5cXcUdk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/georgysavva/scany/v2 v2.1.4 h1:nrzHEJ4oQVRoiKmocRqA1IyGOmM/GQOEsg9UjMR5Ip4= +github.com/georgysavva/scany/v2 v2.1.4/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/twmb/franz-go v1.21.1 h1:sp17bMRLz6OB/w+7vHtBadHGIQVymzQHwvRbEKe5c4I= +github.com/twmb/franz-go v1.21.1/go.mod h1:1o+jj5oRbItsIMoE+DGpfJIcPcPtDdtkcNFPj4bWNwU= +github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe h1:IweTEfQRTN98RFYKWLBqpXw7r1xwdiZ+vsdw7pnVcAY= +github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe/go.mod h1:9j4VxU2ng6tHgD4lIkNJ5OJ3D6vgPhhIp3tBa7dJgLA= +github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI= +github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY= +github.com/twmb/franz-go/plugin/kotel v1.7.0 h1:TAj9zmeqtnH0z4m7+ooa7EEbDIMIvvDdAqejIhNZjB4= +github.com/twmb/franz-go/plugin/kotel v1.7.0/go.mod h1:Cq5tsiazIWro0y/SNpYEwoVW0C6KK1dIYyhccDXV9bs= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/kafkaoutboxzap/observer.go b/kafkaoutboxzap/observer.go new file mode 100644 index 0000000..35119a0 --- /dev/null +++ b/kafkaoutboxzap/observer.go @@ -0,0 +1,75 @@ +// Package kafkaoutboxzap adapts kafkaoutbox observer events to structured zap logs. +package kafkaoutboxzap + +import ( + "context" + "errors" + + "github.com/devctllabs/go-libs/kafkaoutbox" + "go.uber.org/zap" +) + +// Observer logs actionable low-volume outbox lifecycle events. +type Observer struct { + logger *zap.Logger +} + +// New constructs a kafkaoutbox Observer backed by logger. +func New(logger *zap.Logger) (*Observer, error) { + if logger == nil { + return nil, errors.New("kafkaoutboxzap: logger must not be nil") + } + return &Observer{logger: logger}, nil +} + +// StartOperation leaves terminal runtime error logging to the Run caller. +func (*Observer) StartOperation( + ctx context.Context, + _ kafkaoutbox.Operation, +) (context.Context, kafkaoutbox.OperationDone) { + return ctx, func(error) {} +} + +// Enqueued avoids per-event success logs. +func (*Observer) Enqueued(context.Context) {} + +// BatchCompleted avoids high-volume success and failure logs; Retry and the +// Run return value carry the actionable failure paths. +func (*Observer) BatchCompleted(context.Context, kafkaoutbox.BatchResult) {} + +// Retry logs every durably scheduled publish retry. +func (observer *Observer) Retry(_ context.Context, event kafkaoutbox.RetryEvent) { + observer.logger.Warn( + "kafka outbox publish retry scheduled", + zap.Uint64("generation", event.Generation), + zap.Uint("shard_id", event.ShardID), + zap.Uint("attempt", event.Attempt), + zap.Duration("next_delay", event.NextDelay), + zap.Error(event.Err), + ) +} + +// FencingConflict logs a lost shard claim separately from publish failures. +func (observer *Observer) FencingConflict(_ context.Context, event kafkaoutbox.FencingEvent) { + observer.logger.Warn( + "kafka outbox shard claim lost", + zap.String("phase", string(event.Phase)), + zap.Uint64("generation", event.Generation), + zap.Uint("shard_id", event.ShardID), + ) +} + +// TopologyReconciled logs only actual persisted topology changes. +func (observer *Observer) TopologyReconciled(_ context.Context, result kafkaoutbox.TopologyResult) { + if !result.Changed { + return + } + observer.logger.Info( + "kafka outbox topology changed", + zap.Uint64("revision", result.Revision), + zap.Uint64("generation", result.Generation), + zap.Uint("shard_count", result.ShardCount), + ) +} + +var _ kafkaoutbox.Observer = (*Observer)(nil) diff --git a/kafkaoutboxzap/observer_test.go b/kafkaoutboxzap/observer_test.go new file mode 100644 index 0000000..4ed5994 --- /dev/null +++ b/kafkaoutboxzap/observer_test.go @@ -0,0 +1,48 @@ +package kafkaoutboxzap + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/devctllabs/go-libs/kafkaoutbox" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +func TestObserverLogsRetriesFencingAndTopologyChanges(t *testing.T) { + t.Parallel() + core, logs := observer.New(zap.DebugLevel) + adapter, err := New(zap.New(core)) + require.NoError(t, err) + cause := errors.New("broker unavailable") + + adapter.Retry(context.Background(), kafkaoutbox.RetryEvent{ + Generation: 2, ShardID: 3, Attempt: 4, NextDelay: time.Second, Err: cause, + }) + adapter.FencingConflict(context.Background(), kafkaoutbox.FencingEvent{ + Phase: kafkaoutbox.OperationFinalize, Generation: 2, ShardID: 3, + }) + adapter.TopologyReconciled(context.Background(), kafkaoutbox.TopologyResult{ + Revision: 2, Generation: 2, ShardCount: 8, Changed: true, + }) + adapter.TopologyReconciled(context.Background(), kafkaoutbox.TopologyResult{ + Revision: 2, Generation: 2, ShardCount: 8, + }) + + require.Equal(t, []zapcore.Level{zap.WarnLevel, zap.WarnLevel, zap.InfoLevel}, []zapcore.Level{ + logs.All()[0].Level, logs.All()[1].Level, logs.All()[2].Level, + }) + require.Equal(t, "kafka outbox publish retry scheduled", logs.All()[0].Message) + require.Equal(t, "kafka outbox shard claim lost", logs.All()[1].Message) + require.Equal(t, "kafka outbox topology changed", logs.All()[2].Message) +} + +func TestNewRejectsNilLogger(t *testing.T) { + t.Parallel() + _, err := New(nil) + require.Error(t, err) +} diff --git a/kafkaproto/codec.go b/kafkaproto/codec.go new file mode 100644 index 0000000..34e3416 --- /dev/null +++ b/kafkaproto/codec.go @@ -0,0 +1,49 @@ +// Package kafkaproto adapts protobuf messages to kafka encoders and decoders. +package kafkaproto + +import ( + "context" + + "github.com/devctllabs/go-libs/kafka" + "google.golang.org/protobuf/proto" +) + +// ProtoPtr constrains PT to the generated protobuf pointer for T. +type ProtoPtr[T any] interface { + *T + proto.Message +} + +// NewEncoder returns a stateless protobuf value encoder. +func NewEncoder[T any, PT ProtoPtr[T]]() kafka.Encoder[PT] { + return NewMessageEncoder[PT]() +} + +type messageEncoder[T proto.Message] struct{} + +// NewMessageEncoder returns a stateless encoder using a single protobuf +// message type parameter, for example NewMessageEncoder[*mypb.Event](). +func NewMessageEncoder[T proto.Message]() kafka.Encoder[T] { + return messageEncoder[T]{} +} + +func (messageEncoder[T]) Encode(_ context.Context, message T) ([]byte, error) { + return proto.Marshal(message) +} + +type decoder[T any, PT ProtoPtr[T]] struct{} + +// NewDecoder returns a concurrency-safe decoder that allocates a fresh +// protobuf message for every record. +func NewDecoder[T any, PT ProtoPtr[T]]() kafka.Decoder[PT] { + return decoder[T, PT]{} +} + +func (decoder[T, PT]) Decode(_ context.Context, wire []byte) (PT, error) { + message := PT(new(T)) + if err := proto.Unmarshal(wire, message); err != nil { + var zero PT + return zero, err + } + return message, nil +} diff --git a/kafkaproto/codec_test.go b/kafkaproto/codec_test.go new file mode 100644 index 0000000..b170658 --- /dev/null +++ b/kafkaproto/codec_test.go @@ -0,0 +1,45 @@ +package kafkaproto + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func TestCodecRoundTrip(t *testing.T) { + t.Parallel() + + encoder := NewEncoder[wrapperspb.StringValue, *wrapperspb.StringValue]() + decoder := NewDecoder[wrapperspb.StringValue, *wrapperspb.StringValue]() + want := wrapperspb.String("invoice-42") + + wire, err := encoder.Encode(context.Background(), want) + require.NoError(t, err) + got, err := decoder.Decode(context.Background(), wire) + require.NoError(t, err) + require.True(t, proto.Equal(want, got)) +} + +func TestDecoderRejectsInvalidWireFormat(t *testing.T) { + t.Parallel() + + decoder := NewDecoder[wrapperspb.StringValue, *wrapperspb.StringValue]() + + _, err := decoder.Decode(context.Background(), []byte{0xff}) + require.Error(t, err) +} + +func TestMessageEncoderMarshalsProtoMessage(t *testing.T) { + t.Parallel() + encoder := NewMessageEncoder[*wrapperspb.StringValue]() + want := wrapperspb.String("invoice-42") + + wire, err := encoder.Encode(context.Background(), want) + require.NoError(t, err) + decoded := &wrapperspb.StringValue{} + require.NoError(t, proto.Unmarshal(wire, decoded)) + require.True(t, proto.Equal(want, decoded)) +} diff --git a/kafkaproto/go.mod b/kafkaproto/go.mod new file mode 100644 index 0000000..67e4d2a --- /dev/null +++ b/kafkaproto/go.mod @@ -0,0 +1,28 @@ +module github.com/devctllabs/go-libs/kafkaproto + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/kafka v0.1.0 + github.com/stretchr/testify v1.11.1 + google.golang.org/protobuf v1.36.10 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/devctllabs/go-libs/retry v0.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/twmb/franz-go v1.21.1 // indirect + github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect + github.com/twmb/franz-go/plugin/kotel v1.7.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/kafkaproto/go.sum b/kafkaproto/go.sum new file mode 100644 index 0000000..7159257 --- /dev/null +++ b/kafkaproto/go.sum @@ -0,0 +1,60 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twmb/franz-go v1.21.1 h1:sp17bMRLz6OB/w+7vHtBadHGIQVymzQHwvRbEKe5c4I= +github.com/twmb/franz-go v1.21.1/go.mod h1:1o+jj5oRbItsIMoE+DGpfJIcPcPtDdtkcNFPj4bWNwU= +github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe h1:IweTEfQRTN98RFYKWLBqpXw7r1xwdiZ+vsdw7pnVcAY= +github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe/go.mod h1:9j4VxU2ng6tHgD4lIkNJ5OJ3D6vgPhhIp3tBa7dJgLA= +github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI= +github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY= +github.com/twmb/franz-go/plugin/kotel v1.7.0 h1:TAj9zmeqtnH0z4m7+ooa7EEbDIMIvvDdAqejIhNZjB4= +github.com/twmb/franz-go/plugin/kotel v1.7.0/go.mod h1:Cq5tsiazIWro0y/SNpYEwoVW0C6KK1dIYyhccDXV9bs= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/kafkazap/go.mod b/kafkazap/go.mod new file mode 100644 index 0000000..1bd3390 --- /dev/null +++ b/kafkazap/go.mod @@ -0,0 +1,29 @@ +module github.com/devctllabs/go-libs/kafkazap + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/kafka v0.1.0 + github.com/stretchr/testify v1.11.1 + go.uber.org/zap v1.28.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/devctllabs/go-libs/retry v0.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/twmb/franz-go v1.21.1 // indirect + github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect + github.com/twmb/franz-go/plugin/kotel v1.7.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/kafkazap/go.sum b/kafkazap/go.sum new file mode 100644 index 0000000..9c96c74 --- /dev/null +++ b/kafkazap/go.sum @@ -0,0 +1,66 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twmb/franz-go v1.21.1 h1:sp17bMRLz6OB/w+7vHtBadHGIQVymzQHwvRbEKe5c4I= +github.com/twmb/franz-go v1.21.1/go.mod h1:1o+jj5oRbItsIMoE+DGpfJIcPcPtDdtkcNFPj4bWNwU= +github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe h1:IweTEfQRTN98RFYKWLBqpXw7r1xwdiZ+vsdw7pnVcAY= +github.com/twmb/franz-go/pkg/kfake v0.0.0-20260820024614-9b174ed31afe/go.mod h1:9j4VxU2ng6tHgD4lIkNJ5OJ3D6vgPhhIp3tBa7dJgLA= +github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI= +github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY= +github.com/twmb/franz-go/plugin/kotel v1.7.0 h1:TAj9zmeqtnH0z4m7+ooa7EEbDIMIvvDdAqejIhNZjB4= +github.com/twmb/franz-go/plugin/kotel v1.7.0/go.mod h1:Cq5tsiazIWro0y/SNpYEwoVW0C6KK1dIYyhccDXV9bs= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/kafkazap/observer.go b/kafkazap/observer.go new file mode 100644 index 0000000..4786219 --- /dev/null +++ b/kafkazap/observer.go @@ -0,0 +1,64 @@ +// Package kafkazap adapts kafka observer events to structured zap logs. +package kafkazap + +import ( + "context" + "errors" + + "github.com/devctllabs/go-libs/kafka" + "go.uber.org/zap" +) + +// Observer logs retries and explicit record dispositions. It intentionally +// does not log terminal runtime errors, which remain the Run caller's concern. +type Observer struct { + logger *zap.Logger +} + +// New constructs a kafka Observer backed by logger. +func New(logger *zap.Logger) (*Observer, error) { + if logger == nil { + return nil, errors.New("kafkazap: logger must not be nil") + } + return &Observer{logger: logger}, nil +} + +// StartAttempt leaves attempt logging to Retry and the Run caller. +func (*Observer) StartAttempt( + ctx context.Context, + _ kafka.Attempt, +) (context.Context, kafka.AttemptDone) { + return ctx, func(error) {} +} + +// Retry logs every retryable failure at Warn level. +func (observer *Observer) Retry(_ context.Context, event kafka.RetryEvent) { + observer.logger.Warn( + "kafka operation retry", + zap.String("phase", string(event.Phase)), + zap.Uint("attempt", event.Attempt), + zap.Duration("next_delay", event.NextDelay), + zap.Error(event.Err), + ) +} + +// BatchCompleted does not log successful high-volume batches. +func (*Observer) BatchCompleted(context.Context, kafka.BatchResult) {} + +// RecordDisposition logs DLQ delivery at Info and drops at Warn. +func (observer *Observer) RecordDisposition(_ context.Context, disposition kafka.RecordDisposition) { + fields := []zap.Field{ + zap.String("topic", disposition.Record.Topic), + zap.Int32("partition", disposition.Record.Partition), + zap.Int64("offset", disposition.Record.Offset), + zap.Error(disposition.Cause), + } + switch disposition.Kind { + case kafka.DispositionDLQ: + observer.logger.Info("kafka record sent to DLQ", fields...) + case kafka.DispositionDropped: + observer.logger.Warn("kafka record dropped", fields...) + } +} + +var _ kafka.Observer = (*Observer)(nil) diff --git a/kafkazap/observer_test.go b/kafkazap/observer_test.go new file mode 100644 index 0000000..7c81182 --- /dev/null +++ b/kafkazap/observer_test.go @@ -0,0 +1,49 @@ +package kafkazap + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/devctllabs/go-libs/kafka" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +func TestObserverLogsRetriesAndTerminalDispositions(t *testing.T) { + t.Parallel() + + core, logs := observer.New(zap.DebugLevel) + adapter, err := New(zap.New(core)) + require.NoError(t, err) + cause := errors.New("sensitive reason") + + adapter.Retry(context.Background(), kafka.RetryEvent{ + Phase: kafka.AttemptHandler, Attempt: 2, NextDelay: time.Second, Err: cause, + }) + adapter.RecordDisposition(context.Background(), kafka.RecordDisposition{ + Record: kafka.RecordMetadata{Topic: "invoices", Partition: 1, Offset: 10}, + Kind: kafka.DispositionDLQ, Cause: cause, + }) + adapter.RecordDisposition(context.Background(), kafka.RecordDisposition{ + Record: kafka.RecordMetadata{Topic: "invoices", Partition: 1, Offset: 11}, + Kind: kafka.DispositionDropped, Cause: cause, + }) + + require.Equal(t, []zapcore.Level{zap.WarnLevel, zap.InfoLevel, zap.WarnLevel}, []zapcore.Level{ + logs.All()[0].Level, logs.All()[1].Level, logs.All()[2].Level, + }) + require.Equal(t, "kafka operation retry", logs.All()[0].Message) + require.Equal(t, "kafka record sent to DLQ", logs.All()[1].Message) + require.Equal(t, "kafka record dropped", logs.All()[2].Message) +} + +func TestNewRejectsNilLogger(t *testing.T) { + t.Parallel() + + _, err := New(nil) + require.Error(t, err) +} diff --git a/mise.toml b/mise.toml index 54c626b..2d64545 100644 --- a/mise.toml +++ b/mise.toml @@ -23,23 +23,31 @@ run = "go test ./codexapp/..." [tasks.test] description = "Run tests for all Go modules" -run = "go test ./codexapp/... ./config/... ./debugserver/... ./di/... ./filesystem/... ./health/... ./healthotel/... ./healthserver/... ./healthzap/... ./lifecycle/... ./log/... ./oapivalidator/... ./postgresdb/... ./sqlitedb/... ./telemetry/... ./txmanager/..." +run = "go test ./buildinfo/... ./codexapp/... ./config/... ./debugserver/... ./di/... ./filesystem/... ./grpcclient/... ./grpcserver/... ./grpczap/... ./health/... ./healthgrpc/... ./healthotel/... ./healthserver/... ./healthzap/... ./kafka/... ./kafkaoutbox/... ./kafkaoutboxzap/... ./kafkaproto/... ./kafkazap/... ./lifecycle/... ./log/... ./oapivalidator/... ./oapivalidatorjwt/... ./oidcsession/... ./oidcsessionredis/... ./postgresdb/... ./retry/... ./sqlitedb/... ./telemetry/... ./txmanager/..." [tasks.lint] description = "Run golangci-lint for all Go modules" -run = "golangci-lint run ./codexapp/... ./config/... ./debugserver/... ./di/... ./filesystem/... ./health/... ./healthotel/... ./healthserver/... ./healthzap/... ./lifecycle/... ./log/... ./oapivalidator/... ./postgresdb/... ./sqlitedb/... ./telemetry/... ./txmanager/..." +run = "golangci-lint run ./buildinfo/... ./codexapp/... ./config/... ./debugserver/... ./di/... ./filesystem/... ./grpcclient/... ./grpcserver/... ./grpczap/... ./health/... ./healthgrpc/... ./healthotel/... ./healthserver/... ./healthzap/... ./kafka/... ./kafkaoutbox/... ./kafkaoutboxzap/... ./kafkaproto/... ./kafkazap/... ./lifecycle/... ./log/... ./oapivalidator/... ./oapivalidatorjwt/... ./oidcsession/... ./oidcsessionredis/... ./postgresdb/... ./retry/... ./sqlitedb/... ./telemetry/... ./txmanager/..." [tasks."test:race"] description = "Run race-enabled tests for all Go modules" -run = "go test -race ./codexapp/... ./config/... ./debugserver/... ./di/... ./filesystem/... ./health/... ./healthotel/... ./healthserver/... ./healthzap/... ./lifecycle/... ./log/... ./oapivalidator/... ./postgresdb/... ./sqlitedb/... ./telemetry/... ./txmanager/..." +run = "go test -race ./buildinfo/... ./codexapp/... ./config/... ./debugserver/... ./di/... ./filesystem/... ./grpcclient/... ./grpcserver/... ./grpczap/... ./health/... ./healthgrpc/... ./healthotel/... ./healthserver/... ./healthzap/... ./kafka/... ./kafkaoutbox/... ./kafkaoutboxzap/... ./kafkaproto/... ./kafkazap/... ./lifecycle/... ./log/... ./oapivalidator/... ./oapivalidatorjwt/... ./oidcsession/... ./oidcsessionredis/... ./postgresdb/... ./retry/... ./sqlitedb/... ./telemetry/... ./txmanager/..." + +[tasks."oidcsessionredis:test-integration"] +description = "Run race-enabled Redis session integration tests" +run = "go test -race -tags=integration ./oidcsessionredis/..." [tasks."postgresdb:test-integration"] description = "Run race-enabled PostgreSQL integration tests" run = "go test -race -tags=integration ./postgresdb/..." +[tasks."kafkaoutbox:test-integration"] +description = "Run race-enabled PostgreSQL outbox integration tests" +run = "go test -race -tags=integration ./kafkaoutbox/..." + [tasks.generate] description = "Refresh all checked-in generated Go artifacts" -run = "go generate ./codexapp/... ./config/... ./filesystem/... ./health/... ./healthserver/... ./oapivalidator/... ./txmanager/..." +run = "go generate ./codexapp/... ./config/... ./filesystem/... ./grpcserver/... ./health/... ./healthserver/... ./kafka/... ./kafkaoutbox/... ./oapivalidator/... ./oidcsession/... ./oidcsessionredis/... ./txmanager/..." [tasks."check-generated"] description = "Verify all checked-in generated Go artifacts" diff --git a/oapivalidator/authentication.go b/oapivalidator/authentication.go index 346f6f7..45bcb2d 100644 --- a/oapivalidator/authentication.go +++ b/oapivalidator/authentication.go @@ -14,6 +14,9 @@ var ErrUnauthenticated = errors.New("unauthenticated") // ErrForbidden reports valid credentials without the required access. var ErrForbidden = errors.New("forbidden") +// ErrAuthenticationUnavailable reports a temporary authentication dependency failure. +var ErrAuthenticationUnavailable = errors.New("authentication unavailable") + // AuthenticationInput describes one security scheme in the current OpenAPI // security requirement. type AuthenticationInput struct { diff --git a/oapivalidator/authentication_test.go b/oapivalidator/authentication_test.go index 1a52d9e..4e13fc5 100644 --- a/oapivalidator/authentication_test.go +++ b/oapivalidator/authentication_test.go @@ -108,6 +108,7 @@ func TestAuthenticationFailureMapping(t *testing.T) { }{ {name: "unauthenticated", authError: fmt.Errorf("wrapped: %w", oapivalidator.ErrUnauthenticated), status: http.StatusUnauthorized, challenge: "Bearer"}, {name: "forbidden", authError: fmt.Errorf("wrapped: %w", oapivalidator.ErrForbidden), status: http.StatusForbidden}, + {name: "authentication unavailable", authError: fmt.Errorf("wrapped: %w", oapivalidator.ErrAuthenticationUnavailable), status: http.StatusServiceUnavailable}, {name: "backend failure", authError: errors.New("identity provider unavailable"), status: http.StatusInternalServerError}, } @@ -142,19 +143,37 @@ func TestAuthenticatorRejectsNilSuccessContext(t *testing.T) { require.Equal(t, http.StatusInternalServerError, recorder.Code) } -func TestAuthenticationFailurePriorityIsInternalThenForbiddenThenUnauthenticated(t *testing.T) { +func TestAuthenticationFailurePriority(t *testing.T) { t.Parallel() - controller := gomock.NewController(t) - authenticator := mocks.NewMockAuthenticator(controller) - first := authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).Return(context.Background(), oapivalidator.ErrUnauthenticated) - second := authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).After(first).Return(context.Background(), oapivalidator.ErrForbidden) - authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).After(second).Return(context.Background(), errors.New("backend unavailable")) - middleware, err := oapivalidator.New(loadDocument(t, priorityDocument), oapivalidator.WithAuthenticator(authenticator)) - require.NoError(t, err) + tests := []struct { + name string + errors []error + status int + }{ + {name: "unavailable over forbidden and unauthenticated", errors: []error{oapivalidator.ErrUnauthenticated, oapivalidator.ErrForbidden, oapivalidator.ErrAuthenticationUnavailable}, status: http.StatusServiceUnavailable}, + {name: "internal over unavailable", errors: []error{oapivalidator.ErrUnauthenticated, oapivalidator.ErrAuthenticationUnavailable, errors.New("unexpected")}, status: http.StatusInternalServerError}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + authenticator := mocks.NewMockAuthenticator(controller) + var previous *gomock.Call + for _, authError := range test.errors { + call := authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).Return(context.Background(), authError) + if previous != nil { + call.After(previous) + } + previous = call + } + middleware, err := oapivalidator.New(loadDocument(t, priorityDocument), oapivalidator.WithAuthenticator(authenticator)) + require.NoError(t, err) - recorder := serve(t, middleware, http.MethodGet, "/priority", "", "", nil) + recorder := serve(t, middleware, http.MethodGet, "/priority", "", "", nil) - require.Equal(t, http.StatusInternalServerError, recorder.Code) + require.Equal(t, test.status, recorder.Code) + }) + } } func TestMiddlewareDoesNotLeakAuthenticationContextBetweenConcurrentRequests(t *testing.T) { diff --git a/oapivalidator/failure.go b/oapivalidator/failure.go index 4cf5522..7e55b1e 100644 --- a/oapivalidator/failure.go +++ b/oapivalidator/failure.go @@ -11,25 +11,27 @@ import ( type FailureKind string const ( - FailureNotFound FailureKind = "not_found" - FailureMethodNotAllowed FailureKind = "method_not_allowed" - FailureMalformedRequest FailureKind = "malformed_request" - FailureInvalidRequest FailureKind = "invalid_request" - FailureUnsupportedMediaType FailureKind = "unsupported_media_type" - FailureUnauthenticated FailureKind = "unauthenticated" - FailureForbidden FailureKind = "forbidden" - FailureInternal FailureKind = "internal" + FailureNotFound FailureKind = "not_found" + FailureMethodNotAllowed FailureKind = "method_not_allowed" + FailureMalformedRequest FailureKind = "malformed_request" + FailureInvalidRequest FailureKind = "invalid_request" + FailureUnsupportedMediaType FailureKind = "unsupported_media_type" + FailureUnauthenticated FailureKind = "unauthenticated" + FailureForbidden FailureKind = "forbidden" + FailureAuthenticationUnavailable FailureKind = "authentication_unavailable" + FailureInternal FailureKind = "internal" ) const ( - ProblemTypeNotFound = "urn:devctl:oapivalidator:problem:not-found" - ProblemTypeMethodNotAllowed = "urn:devctl:oapivalidator:problem:method-not-allowed" - ProblemTypeMalformedRequest = "urn:devctl:oapivalidator:problem:malformed-request" - ProblemTypeInvalidRequest = "urn:devctl:oapivalidator:problem:invalid-request" - ProblemTypeUnsupportedMediaType = "urn:devctl:oapivalidator:problem:unsupported-media-type" - ProblemTypeUnauthenticated = "urn:devctl:oapivalidator:problem:unauthenticated" - ProblemTypeForbidden = "urn:devctl:oapivalidator:problem:forbidden" - ProblemTypeInternal = "urn:devctl:oapivalidator:problem:internal" + ProblemTypeNotFound = "urn:devctl:oapivalidator:problem:not-found" + ProblemTypeMethodNotAllowed = "urn:devctl:oapivalidator:problem:method-not-allowed" + ProblemTypeMalformedRequest = "urn:devctl:oapivalidator:problem:malformed-request" + ProblemTypeInvalidRequest = "urn:devctl:oapivalidator:problem:invalid-request" + ProblemTypeUnsupportedMediaType = "urn:devctl:oapivalidator:problem:unsupported-media-type" + ProblemTypeUnauthenticated = "urn:devctl:oapivalidator:problem:unauthenticated" + ProblemTypeForbidden = "urn:devctl:oapivalidator:problem:forbidden" + ProblemTypeAuthenticationUnavailable = "urn:devctl:oapivalidator:problem:authentication-unavailable" + ProblemTypeInternal = "urn:devctl:oapivalidator:problem:internal" ) // Location identifies the part of the request containing an invalid value. @@ -138,6 +140,8 @@ func problemMetadata(kind FailureKind) (problemType, title, detail string) { return ProblemTypeUnauthenticated, "Authentication required", "Valid credentials are required." case FailureForbidden: return ProblemTypeForbidden, "Forbidden", "The credentials do not grant the required access." + case FailureAuthenticationUnavailable: + return ProblemTypeAuthenticationUnavailable, "Authentication unavailable", "Authentication is temporarily unavailable." default: return ProblemTypeInternal, "Internal server error", "The request could not be validated." } @@ -166,6 +170,8 @@ func statusForKind(kind FailureKind) int { return http.StatusUnauthorized case FailureForbidden: return http.StatusForbidden + case FailureAuthenticationUnavailable: + return http.StatusServiceUnavailable default: return http.StatusInternalServerError } diff --git a/oapivalidator/normalize.go b/oapivalidator/normalize.go index eaf724e..6b830ec 100644 --- a/oapivalidator/normalize.go +++ b/oapivalidator/normalize.go @@ -51,10 +51,15 @@ func authenticationFailure(cause error) (FailureKind, string, bool) { } for _, failure := range authenticationErrors { - if !errors.Is(failure.cause, ErrUnauthenticated) && !errors.Is(failure.cause, ErrForbidden) { + if !errors.Is(failure.cause, ErrUnauthenticated) && !errors.Is(failure.cause, ErrForbidden) && !errors.Is(failure.cause, ErrAuthenticationUnavailable) { return FailureInternal, "", true } } + for _, failure := range authenticationErrors { + if errors.Is(failure.cause, ErrAuthenticationUnavailable) { + return FailureAuthenticationUnavailable, "", true + } + } for _, failure := range authenticationErrors { if errors.Is(failure.cause, ErrForbidden) { return FailureForbidden, "", true diff --git a/oapivalidatorjwt/README.md b/oapivalidatorjwt/README.md new file mode 100644 index 0000000..dfe0475 --- /dev/null +++ b/oapivalidatorjwt/README.md @@ -0,0 +1,38 @@ +# oapivalidatorjwt + +`oapivalidatorjwt` is an `oapivalidator.Authenticator` for JWTs carried either as a strict +`Authorization: Bearer` credential or by an OpenAPI `apiKey` cookie security scheme. The +application still installs only `oapivalidator.New(...)` as Echo middleware; this module is the +authenticator supplied through `oapivalidator.WithAuthenticator`. + +The recommended JWKS owner is [`keyfunc/v3`](https://pkg.go.dev/github.com/MicahParks/keyfunc/v3). +Construct it in the application lifecycle with a bounded HTTP client, rate-limit unknown-`kid` +refreshes, and pass its request-aware key function: + +```go +keys, err := keyfunc.NewDefaultOverrideCtx(runCtx, []string{jwksURL}, keyfunc.Override{ + Client: &http.Client{Timeout: 3 * time.Second}, + RateLimitWaitMax: 250 * time.Millisecond, + RefreshInterval: time.Hour, + RefreshUnknownKID: rate.NewLimiter(rate.Every(5*time.Minute), 1), +}) +if err != nil { + return err +} + +authenticator, err := oapivalidatorjwt.New(config, func(ctx context.Context) jwt.Keyfunc { + resolve := keys.KeyfuncCtx(ctx) + return func(token *jwt.Token) (any, error) { + key, err := resolve(token) + if err != nil { + return nil, errors.Join(oapivalidator.ErrAuthenticationUnavailable, err) + } + return key, nil + } +}, claimsMapper) +``` + +Do not log raw tokens, raw claims, or raw `kid` values. Pass the same trusted origins and CSRF +header name to this module and to `oidcsession` when browser cookies are enabled. Bearer requests +do not require CSRF confirmation. + diff --git a/oapivalidatorjwt/authenticator.go b/oapivalidatorjwt/authenticator.go new file mode 100644 index 0000000..a00a453 --- /dev/null +++ b/oapivalidatorjwt/authenticator.go @@ -0,0 +1,230 @@ +package oapivalidatorjwt + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/devctllabs/go-libs/oapivalidator" + "github.com/golang-jwt/jwt/v5" +) + +const ( + defaultMaxTokenBytes = 16 * 1024 + defaultMaxKeyIDBytes = 256 +) + +// Config defines the accepted issuer, audiences, signing algorithms, and input limits. +type Config struct { + Issuer string + Audiences []string + AllowedAlgorithms []string + Leeway time.Duration + MaxTokenBytes int + MaxKeyIDBytes int + CookieProtection *CookieProtectionConfig +} + +// CookieProtectionConfig configures CSRF checks for JWTs transported in cookies. +type CookieProtectionConfig struct { + TrustedOrigins []string + CSRFHeaderName string +} + +// KeyfuncProvider returns the signing-key resolver for the current request context. +type KeyfuncProvider func(ctx context.Context) jwt.Keyfunc + +// ClaimsMapper converts a completely validated JWT into application request context. +type ClaimsMapper func(ctx context.Context, token *jwt.Token) (nextCtx context.Context, err error) + +// Authenticator validates JWT credentials for supported OpenAPI security schemes. +type Authenticator struct { + issuer string + audiences []string + algorithms []string + algorithmSet map[string]struct{} + leeway time.Duration + maxTokenBytes int + maxKeyIDBytes int + keyfuncProvider KeyfuncProvider + claimsMapper ClaimsMapper + cookieProtection *cookieProtection +} + +// New constructs an Authenticator without performing network I/O. +func New(config Config, keyFuncProvider KeyfuncProvider, claimsMapper ClaimsMapper) (*Authenticator, error) { + if strings.TrimSpace(config.Issuer) == "" { + return nil, errors.New("issuer is required") + } + if len(config.Audiences) == 0 || slices.ContainsFunc(config.Audiences, func(value string) bool { return strings.TrimSpace(value) == "" }) { + return nil, errors.New("at least one non-empty audience is required") + } + if len(config.AllowedAlgorithms) == 0 || slices.ContainsFunc(config.AllowedAlgorithms, func(value string) bool { return strings.TrimSpace(value) == "" }) { + return nil, errors.New("at least one non-empty allowed algorithm is required") + } + if config.Leeway < 0 { + return nil, errors.New("leeway must not be negative") + } + if keyFuncProvider == nil { + return nil, errors.New("keyfunc provider is required") + } + if claimsMapper == nil { + return nil, errors.New("claims mapper is required") + } + if config.MaxTokenBytes < 0 || config.MaxKeyIDBytes < 0 { + return nil, errors.New("JWT input limits must not be negative") + } + maxTokenBytes := config.MaxTokenBytes + if maxTokenBytes == 0 { + maxTokenBytes = defaultMaxTokenBytes + } + maxKeyIDBytes := config.MaxKeyIDBytes + if maxKeyIDBytes == 0 { + maxKeyIDBytes = defaultMaxKeyIDBytes + } + protection, err := newCookieProtection(config.CookieProtection) + if err != nil { + return nil, fmt.Errorf("configure cookie protection: %w", err) + } + algorithmSet := make(map[string]struct{}, len(config.AllowedAlgorithms)) + for _, algorithm := range config.AllowedAlgorithms { + if _, duplicate := algorithmSet[algorithm]; duplicate { + return nil, fmt.Errorf("allowed algorithm %q is duplicated", algorithm) + } + algorithmSet[algorithm] = struct{}{} + } + return &Authenticator{ + issuer: config.Issuer, + audiences: slices.Clone(config.Audiences), + algorithms: slices.Clone(config.AllowedAlgorithms), + algorithmSet: algorithmSet, + leeway: config.Leeway, + maxTokenBytes: maxTokenBytes, + maxKeyIDBytes: maxKeyIDBytes, + keyfuncProvider: keyFuncProvider, + claimsMapper: claimsMapper, + cookieProtection: protection, + }, nil +} + +// Authenticate implements oapivalidator.Authenticator. +func (authenticator *Authenticator) Authenticate(ctx context.Context, input oapivalidator.AuthenticationInput) (context.Context, error) { + if ctx == nil { + return nil, errors.New("authentication context is required") + } + if input.Request == nil || input.SecurityScheme == nil { + return nil, errors.New("OpenAPI authentication input is incomplete") + } + raw, cookieCredential, err := credential(input.Request, input.SecurityScheme.Type, input.SecurityScheme.Scheme, input.SecurityScheme.In, input.SecurityScheme.Name) + if err != nil { + return nil, err + } + if cookieCredential { + if authenticator.cookieProtection == nil { + return nil, errors.New("cookie security scheme requires cookie protection configuration") + } + if err := authenticator.cookieProtection.check(input.Request); err != nil { + return nil, fmt.Errorf("cookie request protection: %w", oapivalidator.ErrForbidden) + } + } + if len(raw) > authenticator.maxTokenBytes { + return nil, oapivalidator.ErrUnauthenticated + } + if err := authenticator.preflight(raw); err != nil { + return nil, oapivalidator.ErrUnauthenticated + } + + keyFunc := authenticator.keyfuncProvider(ctx) + if keyFunc == nil { + return nil, errors.New("keyfunc provider returned nil") + } + parser := jwt.NewParser( + jwt.WithValidMethods(authenticator.algorithms), + jwt.WithIssuer(authenticator.issuer), + jwt.WithExpirationRequired(), + jwt.WithIssuedAt(), + jwt.WithLeeway(authenticator.leeway), + ) + claims := jwt.MapClaims{} + token, err := parser.ParseWithClaims(raw, claims, keyFunc) + if err != nil { + if errors.Is(err, oapivalidator.ErrAuthenticationUnavailable) { + return nil, fmt.Errorf("resolve JWT signing key: %w", oapivalidator.ErrAuthenticationUnavailable) + } + return nil, oapivalidator.ErrUnauthenticated + } + if !token.Valid || !authenticator.acceptsAudience(claims) { + return nil, oapivalidator.ErrUnauthenticated + } + nextCtx, err := authenticator.claimsMapper(ctx, token) + if err != nil { + if errors.Is(err, oapivalidator.ErrForbidden) || errors.Is(err, oapivalidator.ErrAuthenticationUnavailable) { + return nil, err + } + return nil, errors.New("JWT claims mapping failed") + } + if nextCtx == nil { + return nil, errors.New("claims mapper returned a nil context without an error") + } + return nextCtx, nil +} + +func credential(request *http.Request, schemeType, scheme, in, name string) (raw string, cookie bool, err error) { + switch { + case strings.EqualFold(schemeType, "http") && strings.EqualFold(scheme, "bearer"): + values := request.Header.Values("Authorization") + if len(values) != 1 { + return "", false, oapivalidator.ErrUnauthenticated + } + prefix, raw, found := strings.Cut(values[0], " ") + if !found || !strings.EqualFold(prefix, "Bearer") || raw == "" || strings.ContainsAny(raw, " \t\r\n") { + return "", false, oapivalidator.ErrUnauthenticated + } + return raw, false, nil + case strings.EqualFold(schemeType, "apiKey") && strings.EqualFold(in, "cookie"): + if name == "" { + return "", false, errors.New("OpenAPI cookie security scheme has no cookie name") + } + cookies := request.CookiesNamed(name) + if len(cookies) != 1 || cookies[0].Value == "" { + return "", true, oapivalidator.ErrUnauthenticated + } + return cookies[0].Value, true, nil + default: + return "", false, errors.New("unsupported OpenAPI security scheme for JWT authentication") + } +} + +func (authenticator *Authenticator) preflight(raw string) error { + token, _, err := jwt.NewParser().ParseUnverified(raw, jwt.MapClaims{}) + if err != nil { + return err + } + if _, allowed := authenticator.algorithmSet[token.Method.Alg()]; !allowed { + return errors.New("JWT signing algorithm is not allowed") + } + kid, ok := token.Header["kid"].(string) + if !ok || kid == "" || len(kid) > authenticator.maxKeyIDBytes { + return errors.New("JWT key identifier is missing or invalid") + } + return nil +} + +func (authenticator *Authenticator) acceptsAudience(claims jwt.MapClaims) bool { + tokenAudiences, err := claims.GetAudience() + if err != nil || len(tokenAudiences) == 0 { + return false + } + for _, expected := range authenticator.audiences { + if slices.Contains(tokenAudiences, expected) { + return true + } + } + return false +} + +var _ oapivalidator.Authenticator = (*Authenticator)(nil) diff --git a/oapivalidatorjwt/authenticator_test.go b/oapivalidatorjwt/authenticator_test.go new file mode 100644 index 0000000..a68580e --- /dev/null +++ b/oapivalidatorjwt/authenticator_test.go @@ -0,0 +1,272 @@ +package oapivalidatorjwt_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/devctllabs/go-libs/oapivalidator" + "github.com/devctllabs/go-libs/oapivalidatorjwt" + "github.com/getkin/kin-openapi/openapi3" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +type contextKey string + +func TestBearerAuthenticationMapsValidatedClaims(t *testing.T) { + t.Parallel() + privateKey := newKey(t) + raw := sign(t, privateKey, jwt.MapClaims{ + "iss": "https://issuer.example", + "aud": []string{"another-api", "orders-api"}, + "sub": "user-42", + "exp": time.Now().Add(time.Minute).Unix(), + "iat": time.Now().Add(-time.Minute).Unix(), + }, "active") + authenticator := newAuthenticator(t, privateKey, func(ctx context.Context, token *jwt.Token) (context.Context, error) { + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + return context.WithValue(ctx, contextKey("subject"), claims["sub"]), nil + }) + request := httptest.NewRequest(http.MethodGet, "https://api.example/orders", nil) + request.Header.Set("Authorization", "Bearer "+raw) + + next, err := authenticator.Authenticate(request.Context(), oapivalidator.AuthenticationInput{ + Request: request, + SecurityScheme: &openapi3.SecurityScheme{Type: "http", Scheme: "bearer"}, + }) + + require.NoError(t, err) + require.Equal(t, "user-42", next.Value(contextKey("subject"))) +} + +func TestBearerRejectsAmbiguousOrInvalidCredentialsBeforeKeyLookup(t *testing.T) { + t.Parallel() + privateKey := newKey(t) + valid := sign(t, privateKey, validClaims(), "active") + tests := []struct { + name string + headers []string + }{ + {name: "missing"}, + {name: "duplicate", headers: []string{"Bearer " + valid, "Bearer " + valid}}, + {name: "wrong scheme", headers: []string{"Basic " + valid}}, + {name: "extra spacing", headers: []string{"Bearer " + valid}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + lookups := 0 + authenticator, err := oapivalidatorjwt.New(baseConfig(), func(context.Context) jwt.Keyfunc { + return func(*jwt.Token) (any, error) { lookups++; return &privateKey.PublicKey, nil } + }, passthroughMapper) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodGet, "https://api.example/orders", nil) + request.Header["Authorization"] = test.headers + + _, err = authenticator.Authenticate(request.Context(), bearerInput(request)) + + require.ErrorIs(t, err, oapivalidator.ErrUnauthenticated) + require.Zero(t, lookups) + }) + } +} + +func TestJWTRejectsAlgorithmKidAndTokenLimitsBeforeKeyLookup(t *testing.T) { + t.Parallel() + privateKey := newKey(t) + none := jwt.NewWithClaims(jwt.SigningMethodNone, validClaims()) + none.Header["kid"] = "active" + unsigned, err := none.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + oversizedKid := sign(t, privateKey, validClaims(), "too-long") + valid := sign(t, privateKey, validClaims(), "active") + + tests := []struct { + name string + raw string + config oapivalidatorjwt.Config + }{ + {name: "algorithm", raw: unsigned, config: baseConfig()}, + {name: "kid", raw: oversizedKid, config: func() oapivalidatorjwt.Config { c := baseConfig(); c.MaxKeyIDBytes = 3; return c }()}, + {name: "token size", raw: valid, config: func() oapivalidatorjwt.Config { c := baseConfig(); c.MaxTokenBytes = 8; return c }()}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + lookups := 0 + authenticator, err := oapivalidatorjwt.New(test.config, func(context.Context) jwt.Keyfunc { + return func(*jwt.Token) (any, error) { lookups++; return &privateKey.PublicKey, nil } + }, passthroughMapper) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodGet, "https://api.example/orders", nil) + request.Header.Set("Authorization", "Bearer "+test.raw) + + _, err = authenticator.Authenticate(request.Context(), bearerInput(request)) + + require.ErrorIs(t, err, oapivalidator.ErrUnauthenticated) + require.Zero(t, lookups) + }) + } +} + +func TestJWTValidatesRequiredClaims(t *testing.T) { + t.Parallel() + privateKey := newKey(t) + tests := []struct { + name string + claims jwt.MapClaims + }{ + {name: "expiration required", claims: jwt.MapClaims{"iss": "https://issuer.example", "aud": "orders-api"}}, + {name: "issuer", claims: jwt.MapClaims{"iss": "other", "aud": "orders-api", "exp": time.Now().Add(time.Minute).Unix()}}, + {name: "audience", claims: jwt.MapClaims{"iss": "https://issuer.example", "aud": "other", "exp": time.Now().Add(time.Minute).Unix()}}, + {name: "future issued at", claims: jwt.MapClaims{"iss": "https://issuer.example", "aud": "orders-api", "exp": time.Now().Add(time.Minute).Unix(), "iat": time.Now().Add(time.Minute).Unix()}}, + {name: "not before", claims: jwt.MapClaims{"iss": "https://issuer.example", "aud": "orders-api", "exp": time.Now().Add(time.Minute).Unix(), "nbf": time.Now().Add(time.Minute).Unix()}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + authenticator := newAuthenticator(t, privateKey, passthroughMapper) + request := httptest.NewRequest(http.MethodGet, "https://api.example/orders", nil) + request.Header.Set("Authorization", "Bearer "+sign(t, privateKey, test.claims, "active")) + _, err := authenticator.Authenticate(request.Context(), bearerInput(request)) + require.ErrorIs(t, err, oapivalidator.ErrUnauthenticated) + }) + } +} + +func TestCookieAuthenticationRequiresCSRFOnUnsafeMethods(t *testing.T) { + t.Parallel() + privateKey := newKey(t) + authenticator := newCookieAuthenticator(t, privateKey) + raw := sign(t, privateKey, validClaims(), "active") + + request := httptest.NewRequest(http.MethodPost, "https://api.example/orders", nil) + request.AddCookie(&http.Cookie{Name: "access", Value: raw}) + _, err := authenticator.Authenticate(request.Context(), cookieInput(request)) + require.ErrorIs(t, err, oapivalidator.ErrForbidden) + + request.Header.Set("X-CSRF-Protection", "1") + next, err := authenticator.Authenticate(request.Context(), cookieInput(request)) + require.NoError(t, err) + require.NotNil(t, next) +} + +func TestCookieAuthenticationRejectsDuplicateCookiesAndCrossOriginRequests(t *testing.T) { + t.Parallel() + privateKey := newKey(t) + authenticator := newCookieAuthenticator(t, privateKey) + raw := sign(t, privateKey, validClaims(), "active") + + duplicate := httptest.NewRequest(http.MethodGet, "https://api.example/orders", nil) + duplicate.Header.Add("Cookie", "access="+raw) + duplicate.Header.Add("Cookie", "access="+raw) + _, err := authenticator.Authenticate(duplicate.Context(), cookieInput(duplicate)) + require.ErrorIs(t, err, oapivalidator.ErrUnauthenticated) + + crossOrigin := httptest.NewRequest(http.MethodPost, "https://api.example/orders", nil) + crossOrigin.AddCookie(&http.Cookie{Name: "access", Value: raw}) + crossOrigin.Header.Set("X-CSRF-Protection", "1") + crossOrigin.Header.Set("Origin", "https://evil.example") + _, err = authenticator.Authenticate(crossOrigin.Context(), cookieInput(crossOrigin)) + require.ErrorIs(t, err, oapivalidator.ErrForbidden) +} + +func TestAuthenticationDependencyAndMapperErrorsKeepTheirCategory(t *testing.T) { + t.Parallel() + privateKey := newKey(t) + raw := sign(t, privateKey, validClaims(), "active") + request := httptest.NewRequest(http.MethodGet, "https://api.example/orders", nil) + request.Header.Set("Authorization", "Bearer "+raw) + + unavailable, err := oapivalidatorjwt.New(baseConfig(), func(context.Context) jwt.Keyfunc { + return func(*jwt.Token) (any, error) { return nil, oapivalidator.ErrAuthenticationUnavailable } + }, passthroughMapper) + require.NoError(t, err) + _, err = unavailable.Authenticate(request.Context(), bearerInput(request)) + require.ErrorIs(t, err, oapivalidator.ErrAuthenticationUnavailable) + + forbidden := newAuthenticator(t, privateKey, func(context.Context, *jwt.Token) (context.Context, error) { + return nil, oapivalidator.ErrForbidden + }) + _, err = forbidden.Authenticate(request.Context(), bearerInput(request)) + require.ErrorIs(t, err, oapivalidator.ErrForbidden) + + unexpected := newAuthenticator(t, privateKey, func(context.Context, *jwt.Token) (context.Context, error) { + return nil, oapivalidator.ErrUnauthenticated + }) + _, err = unexpected.Authenticate(request.Context(), bearerInput(request)) + require.Error(t, err) + require.NotErrorIs(t, err, oapivalidator.ErrUnauthenticated) +} + +func newAuthenticator(t *testing.T, privateKey *rsa.PrivateKey, mapper oapivalidatorjwt.ClaimsMapper) *oapivalidatorjwt.Authenticator { + t.Helper() + authenticator, err := oapivalidatorjwt.New(baseConfig(), func(context.Context) jwt.Keyfunc { + return func(*jwt.Token) (any, error) { return &privateKey.PublicKey, nil } + }, mapper) + require.NoError(t, err) + return authenticator +} + +func newCookieAuthenticator(t *testing.T, privateKey *rsa.PrivateKey) *oapivalidatorjwt.Authenticator { + t.Helper() + config := baseConfig() + config.CookieProtection = &oapivalidatorjwt.CookieProtectionConfig{TrustedOrigins: []string{"https://ui.example"}} + authenticator, err := oapivalidatorjwt.New(config, func(context.Context) jwt.Keyfunc { + return func(*jwt.Token) (any, error) { return &privateKey.PublicKey, nil } + }, passthroughMapper) + require.NoError(t, err) + return authenticator +} + +func newKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return key +} + +func sign(t *testing.T, key *rsa.PrivateKey, claims jwt.MapClaims, kid string) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = kid + raw, err := token.SignedString(key) + require.NoError(t, err) + return raw +} + +func validClaims() jwt.MapClaims { + return jwt.MapClaims{ + "iss": "https://issuer.example", + "aud": "orders-api", + "exp": time.Now().Add(time.Minute).Unix(), + "iat": time.Now().Add(-time.Minute).Unix(), + } +} + +func baseConfig() oapivalidatorjwt.Config { + return oapivalidatorjwt.Config{ + Issuer: "https://issuer.example", + Audiences: []string{"orders-api"}, + AllowedAlgorithms: []string{"RS256"}, + } +} + +func bearerInput(request *http.Request) oapivalidator.AuthenticationInput { + return oapivalidator.AuthenticationInput{Request: request, SecurityScheme: &openapi3.SecurityScheme{Type: "http", Scheme: "bearer"}} +} + +func cookieInput(request *http.Request) oapivalidator.AuthenticationInput { + return oapivalidator.AuthenticationInput{Request: request, SecurityScheme: &openapi3.SecurityScheme{Type: "apiKey", In: "cookie", Name: "access"}} +} + +func passthroughMapper(ctx context.Context, _ *jwt.Token) (context.Context, error) { return ctx, nil } diff --git a/oapivalidatorjwt/csrf.go b/oapivalidatorjwt/csrf.go new file mode 100644 index 0000000..7aaa5fe --- /dev/null +++ b/oapivalidatorjwt/csrf.go @@ -0,0 +1,82 @@ +package oapivalidatorjwt + +import ( + "errors" + "fmt" + "net/http" + "strings" +) + +const defaultCSRFHeaderName = "X-CSRF-Protection" + +type cookieProtection struct { + crossOrigin *http.CrossOriginProtection + headerName string +} + +func newCookieProtection(config *CookieProtectionConfig) (*cookieProtection, error) { + if config == nil { + return nil, nil + } + headerName := config.CSRFHeaderName + if headerName == "" { + headerName = defaultCSRFHeaderName + } + if !validCSRFHeaderName(headerName) { + return nil, fmt.Errorf("invalid or unsafe CSRF header name %q", headerName) + } + protection := http.NewCrossOriginProtection() + for _, origin := range config.TrustedOrigins { + if err := protection.AddTrustedOrigin(origin); err != nil { + return nil, fmt.Errorf("add trusted origin: %w", err) + } + } + return &cookieProtection{crossOrigin: protection, headerName: http.CanonicalHeaderKey(headerName)}, nil +} + +func (protection *cookieProtection) check(request *http.Request) error { + if isSafeMethod(request.Method) { + return nil + } + if err := protection.crossOrigin.Check(request); err != nil { + return err + } + values := request.Header.Values(protection.headerName) + if len(values) != 1 || values[0] != "1" { + return errors.New("CSRF confirmation header is missing or invalid") + } + return nil +} + +func isSafeMethod(method string) bool { + return method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions +} + +func validCSRFHeaderName(name string) bool { + if name == "" { + return false + } + for index := 0; index < len(name); index++ { + if !isTokenCharacter(name[index]) { + return false + } + } + lower := strings.ToLower(name) + if strings.HasPrefix(lower, "sec-") || strings.HasPrefix(lower, "proxy-") { + return false + } + switch lower { + case "accept", "accept-language", "content-language", "content-type", "range", + "authorization", "cookie", "host", "origin", "referer", "user-agent": + return false + default: + return true + } +} + +func isTokenCharacter(character byte) bool { + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' { + return true + } + return strings.ContainsRune("!#$%&'*+-.^_`|~", rune(character)) +} diff --git a/oapivalidatorjwt/doc.go b/oapivalidatorjwt/doc.go new file mode 100644 index 0000000..3bec51e --- /dev/null +++ b/oapivalidatorjwt/doc.go @@ -0,0 +1,2 @@ +// Package oapivalidatorjwt authenticates JWT bearer headers and OpenAPI cookie schemes. +package oapivalidatorjwt diff --git a/oapivalidatorjwt/go.mod b/oapivalidatorjwt/go.mod new file mode 100644 index 0000000..a116230 --- /dev/null +++ b/oapivalidatorjwt/go.mod @@ -0,0 +1,24 @@ +module github.com/devctllabs/go-libs/oapivalidatorjwt + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/oapivalidator v0.1.0 + github.com/getkin/kin-openapi v0.142.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/labstack/echo/v5 v5.1.1 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/oapivalidatorjwt/go.sum b/oapivalidatorjwt/go.sum new file mode 100644 index 0000000..f5ae33d --- /dev/null +++ b/oapivalidatorjwt/go.sum @@ -0,0 +1,51 @@ +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w= +github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I= +github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= +github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= +github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/oidcsession/README.md b/oidcsession/README.md new file mode 100644 index 0000000..bacca77 --- /dev/null +++ b/oidcsession/README.md @@ -0,0 +1,30 @@ +# oidcsession + +`oidcsession` provides an explicit OIDC authorization-code flow for same-site browser applications: + +- `NewProvider` performs local validation only; run `Provider.Run(ctx)` as a lifecycle task so the + service may start degraded and recover when discovery succeeds. +- `NewHandlers` returns framework-neutral `net/http` handlers. The application owns route paths, + CORS, request deadlines, and server lifecycle. +- Login state uses an AES-256-GCM envelope containing PKCE, nonce, return path, login method, expiry, + and a digest of a stable browser-binding cookie. Use a dedicated state encryption key. +- The access cookie contains the provider JWT. The session cookie contains only an opaque + `SessionCredential`; a `SessionBackend` owns refresh tokens. + +Register `Login`, `Callback`, `Refresh`, `Logout`, and `Session` at application-chosen paths. +Refresh and logout require the configured CSRF header with value `1` and pass Go's +`http.CrossOriginProtection`. Production cookies are host-only `__Host-...`, Secure, HttpOnly, and +SameSite=Lax. `InsecureDevCookies` is an explicit local-HTTP opt-in. + +The browser-binding cookie ties each encrypted login state to the browser that initiated the flow. +This prevents a valid state created in one browser from being replayed as login CSRF in another; +the cookie is short-lived and contains neither provider tokens nor a session credential. + +`LoginAuthorizer` is only a quick post-verification login admission decision. Provisioning and +onboarding remain application flows after redirect, and current authorization/block status must +still be checked for every API request. Local logout does not end the upstream broker SSO session; +without an access-token blacklist, an already issued short-lived JWT remains valid until expiry. + +For encryption-key rotation, deploy `[new, old]` so new envelopes use the new key while old ones +remain readable, wait out the maximum envelope/session lifetime, then remove the old key. Use +separate keyrings for login state and persisted provider tokens. diff --git a/oidcsession/callback_test.go b/oidcsession/callback_test.go new file mode 100644 index 0000000..9cedbde --- /dev/null +++ b/oidcsession/callback_test.go @@ -0,0 +1,141 @@ +package oidcsession_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/devctllabs/go-libs/oidcsession" + "github.com/devctllabs/go-libs/oidcsession/mocks" + "github.com/devctllabs/go-libs/retry" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/oauth2" +) + +func TestCallbackVerifiesOIDCTokensAndCreatesSession(t *testing.T) { + t.Parallel() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + var issuer string + var expectedNonce string + var expectedChallenge string + providerErrors := make(chan error, 3) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(writer).Encode(map[string]any{ + "issuer": issuer, "authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/keys", + }) + case "/keys": + _ = json.NewEncoder(writer).Encode(map[string]any{"keys": []any{rsaJWK(&key.PublicKey)}}) + case "/token": + if err := request.ParseForm(); err != nil { + providerErrors <- err + http.Error(writer, "invalid form", http.StatusBadRequest) + return + } + if actual := oauth2.S256ChallengeFromVerifier(request.Form.Get("code_verifier")); actual != expectedChallenge { + providerErrors <- fmt.Errorf("PKCE challenge = %q, want %q", actual, expectedChallenge) + http.Error(writer, "invalid verifier", http.StatusBadRequest) + return + } + accessToken := "verified-access" + digest := sha256.Sum256([]byte(accessToken)) + idToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuer, "aud": "client", "sub": "user-42", "exp": time.Now().Add(time.Minute).Unix(), + "iat": time.Now().Unix(), "nonce": expectedNonce, "at_hash": base64.RawURLEncoding.EncodeToString(digest[:len(digest)/2]), + }) + idToken.Header["kid"] = "test-key" + rawIDToken, signErr := idToken.SignedString(key) + if signErr != nil { + providerErrors <- signErr + http.Error(writer, "signing failed", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(writer).Encode(map[string]any{ + "access_token": accessToken, "refresh_token": "verified-refresh", "token_type": "Bearer", "expires_in": 300, "id_token": rawIDToken, + }) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + issuer = server.URL + policy, err := retry.NewExponential(retry.ExponentialConfig{InitialDelay: time.Millisecond, MaxDelay: time.Second, Multiplier: 2}) + require.NoError(t, err) + provider, err := oidcsession.NewProvider(oidcsession.ProviderConfig{ + IssuerURL: issuer, ClientID: "client", ClientSecret: "secret", RedirectURL: "https://api.example/auth/callback", + Scopes: []string{"openid"}, HTTPClient: &http.Client{Timeout: time.Second}, DiscoveryRetry: policy, + }) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- provider.Run(ctx) }() + require.Eventually(t, func() bool { return provider.Check(context.Background()) == nil }, time.Second, time.Millisecond) + defer func() { cancel(); require.NoError(t, receiveWithin(t, done, time.Second)) }() + + controller := gomock.NewController(t) + sessions := mocks.NewMockSessionBackend(controller) + sessionExpiry := time.Now().Add(time.Hour) + sessions.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, params oidcsession.CreateSessionParams) (oidcsession.CreateSessionResult, error) { + require.Equal(t, "verified-access", params.AccessToken) + require.Equal(t, "verified-refresh", params.RefreshToken) + return oidcsession.CreateSessionResult{Credential: "opaque-session", SessionExpiresAt: sessionExpiry}, nil + }) + uiURL, err := url.Parse("https://ui.example") + require.NoError(t, err) + handlers, err := oidcsession.NewHandlers(oidcsession.HTTPConfig{ + UIBaseURL: uiURL, DefaultReturnPath: "/", ErrorPath: "/auth/error", CookiePrefix: "example", + }, provider, sessions, oidcsession.InsecureNoopEncryptor()) + require.NoError(t, err) + + loginRequest := httptest.NewRequest(http.MethodGet, "https://api.example/auth/login?return=/onboarding", nil) + loginRecorder := httptest.NewRecorder() + handlers.Login(loginRecorder, loginRequest) + require.Equal(t, http.StatusFound, loginRecorder.Code) + authorizationURL, err := url.Parse(loginRecorder.Header().Get("Location")) + require.NoError(t, err) + expectedNonce = authorizationURL.Query().Get("nonce") + expectedChallenge = authorizationURL.Query().Get("code_challenge") + require.NotEmpty(t, expectedNonce) + require.NotEmpty(t, expectedChallenge) + + callbackRequest := httptest.NewRequest(http.MethodGet, "https://api.example/auth/callback?code=one-time-code&state="+url.QueryEscape(authorizationURL.Query().Get("state")), nil) + callbackRequest.AddCookie(loginRecorder.Result().Cookies()[0]) + callbackRecorder := httptest.NewRecorder() + handlers.Callback(callbackRecorder, callbackRequest) + close(providerErrors) + for providerErr := range providerErrors { + require.NoError(t, providerErr) + } + + require.Equal(t, http.StatusSeeOther, callbackRecorder.Code) + require.Equal(t, "https://ui.example/onboarding", callbackRecorder.Header().Get("Location")) + cookies := callbackRecorder.Result().Cookies() + require.Len(t, cookies, 2) + require.Equal(t, "__Host-example-access", cookies[0].Name) + require.Equal(t, "verified-access", cookies[0].Value) + require.Equal(t, "__Host-example-session", cookies[1].Name) + require.Equal(t, "opaque-session", cookies[1].Value) +} + +func rsaJWK(key *rsa.PublicKey) map[string]any { + exponent := big.NewInt(int64(key.E)).Bytes() + return map[string]any{ + "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "test-key", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(exponent), + } +} diff --git a/oidcsession/contracts.go b/oidcsession/contracts.go new file mode 100644 index 0000000..918a7cd --- /dev/null +++ b/oidcsession/contracts.go @@ -0,0 +1,68 @@ +package oidcsession + +import ( + "context" + "encoding/json" + "net/url" + "time" +) + +//go:generate go tool mockgen -destination mocks/interfaces.gen.go -package mocks . SessionBackend,TokenService,Encryptor + +// SessionCredential is an opaque server-side session credential. +type SessionCredential string + +// CreateSessionParams contains the complete provider token set for a new session. +type CreateSessionParams struct { + AccessToken string + RefreshToken string + AccessExpiresAt time.Time +} + +// CreateSessionResult contains the opaque credential and its current idle/absolute expiry. +type CreateSessionResult struct { + Credential SessionCredential + SessionExpiresAt time.Time +} + +// SessionStatus contains only safe session expiry metadata. +type SessionStatus struct { + AccessExpiresAt time.Time + SessionExpiresAt time.Time +} + +// RefreshSessionResult contains the current access token and renewed session expiry. +type RefreshSessionResult struct { + AccessToken string + AccessExpiresAt time.Time + SessionExpiresAt time.Time +} + +// SessionBackend owns opaque refresh credentials and provider token persistence. +type SessionBackend interface { + // Create persists params and returns a newly generated opaque credential. + Create(ctx context.Context, params CreateSessionParams) (result CreateSessionResult, err error) + // Status returns safe expiry metadata without extending idle lifetime. + Status(ctx context.Context, credential SessionCredential) (status SessionStatus, err error) + // Refresh returns a usable access token and extends idle lifetime on success. + Refresh(ctx context.Context, credential SessionCredential) (result RefreshSessionResult, err error) + // Revoke invalidates credential locally and attempts provider revocation. + Revoke(ctx context.Context, credential SessionCredential) error +} + +// LoginMethod adds validated broker-specific authorization parameters. +type LoginMethod struct { + ID string + AuthorizationParameters url.Values +} + +// VerifiedIdentity is a fully verified identity presented to local login admission policy. +type VerifiedIdentity struct { + Issuer string + Subject string + LoginMethodID string + Claims json.RawMessage +} + +// LoginAuthorizer performs a quick local allow/deny decision after OIDC verification. +type LoginAuthorizer func(ctx context.Context, identity VerifiedIdentity) error diff --git a/oidcsession/doc.go b/oidcsession/doc.go new file mode 100644 index 0000000..c190704 --- /dev/null +++ b/oidcsession/doc.go @@ -0,0 +1,6 @@ +// Package oidcsession implements explicit OIDC login flows and browser session handlers. +// +// Provider discovery has an explicit Run lifecycle. Browser access tokens and opaque refresh +// credentials are stored in separate host-only cookies. Applications remain responsible for +// resource-level authorization and post-login onboarding. +package oidcsession diff --git a/oidcsession/encryptor.go b/oidcsession/encryptor.go new file mode 100644 index 0000000..170bcff --- /dev/null +++ b/oidcsession/encryptor.go @@ -0,0 +1,135 @@ +package oidcsession + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" +) + +const ( + envelopeVersion = byte(1) + keyIDSize = 8 +) + +// Encryptor protects opaque session and OAuth state payloads. +type Encryptor interface { + // Encrypt authenticates and encrypts plaintext. The returned slice is caller-owned. + Encrypt(ctx context.Context, plaintext []byte) (ciphertext []byte, err error) + // Decrypt authenticates and decrypts ciphertext. The returned slice is caller-owned. + Decrypt(ctx context.Context, ciphertext []byte) (plaintext []byte, err error) +} + +type aesGCMEncryptor struct { + primaryID [keyIDSize]byte + primary cipher.AEAD + keyring map[[keyIDSize]byte]cipher.AEAD +} + +// NewAESGCMEncryptor creates an AES-256-GCM ordered keyring. primary encrypts new data; +// fallbacks only decrypt existing envelopes. +func NewAESGCMEncryptor(primary []byte, fallbacks ...[]byte) (Encryptor, error) { + keys := append([][]byte{primary}, fallbacks...) + keyring := make(map[[keyIDSize]byte]cipher.AEAD, len(keys)) + var primaryID [keyIDSize]byte + var primaryAEAD cipher.AEAD + for index, key := range keys { + if len(key) != 32 { + return nil, fmt.Errorf("key %d must contain exactly 32 bytes", index) + } + owned := append([]byte(nil), key...) + block, err := aes.NewCipher(owned) + clear(owned) + if err != nil { + return nil, fmt.Errorf("construct key %d: %w", index, err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("construct GCM key %d: %w", index, err) + } + digest := sha256.Sum256(key) + var id [keyIDSize]byte + copy(id[:], digest[:keyIDSize]) + if _, duplicate := keyring[id]; duplicate { + return nil, errors.New("encryption keyring contains duplicate keys") + } + keyring[id] = aead + if index == 0 { + primaryID = id + primaryAEAD = aead + } + } + return &aesGCMEncryptor{primaryID: primaryID, primary: primaryAEAD, keyring: keyring}, nil +} + +func (encryptor *aesGCMEncryptor) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + nonce := make([]byte, encryptor.primary.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("generate encryption nonce: %w", err) + } + headerSize := 1 + keyIDSize + 2 + len(nonce) + envelope := make([]byte, headerSize) + envelope[0] = envelopeVersion + copy(envelope[1:1+keyIDSize], encryptor.primaryID[:]) + binary.BigEndian.PutUint16(envelope[1+keyIDSize:], uint16(len(nonce))) + copy(envelope[1+keyIDSize+2:], nonce) + return encryptor.primary.Seal(envelope, nonce, plaintext, envelope[:1+keyIDSize]), nil +} + +func (encryptor *aesGCMEncryptor) Decrypt(ctx context.Context, envelope []byte) ([]byte, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + if len(envelope) < 1+keyIDSize+2 || envelope[0] != envelopeVersion { + return nil, errors.New("invalid encrypted envelope") + } + var id [keyIDSize]byte + copy(id[:], envelope[1:1+keyIDSize]) + aead, found := encryptor.keyring[id] + if !found { + return nil, errors.New("encrypted envelope uses an unknown key") + } + nonceSize := int(binary.BigEndian.Uint16(envelope[1+keyIDSize:])) + headerSize := 1 + keyIDSize + 2 + nonceSize + if nonceSize != aead.NonceSize() || len(envelope) < headerSize+aead.Overhead() { + return nil, errors.New("invalid encrypted envelope") + } + plaintext, err := aead.Open(nil, envelope[1+keyIDSize+2:headerSize], envelope[headerSize:], envelope[:1+keyIDSize]) + if err != nil { + return nil, errors.New("encrypted envelope authentication failed") + } + return plaintext, nil +} + +type noopEncryptor struct{} + +// InsecureNoopEncryptor returns an explicitly insecure plaintext implementation for tests and local development. +func InsecureNoopEncryptor() Encryptor { return noopEncryptor{} } + +func (noopEncryptor) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + return append([]byte(nil), plaintext...), nil +} + +func (noopEncryptor) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + return append([]byte(nil), ciphertext...), nil +} + +func contextError(ctx context.Context) error { + if ctx == nil { + return errors.New("context is required") + } + return ctx.Err() +} diff --git a/oidcsession/encryptor_test.go b/oidcsession/encryptor_test.go new file mode 100644 index 0000000..288aeda --- /dev/null +++ b/oidcsession/encryptor_test.go @@ -0,0 +1,72 @@ +package oidcsession_test + +import ( + "context" + "crypto/rand" + "testing" + + "github.com/devctllabs/go-libs/oidcsession" + "github.com/stretchr/testify/require" +) + +func TestAESGCMEncryptorRoundTripAndTamperDetection(t *testing.T) { + t.Parallel() + key := randomKey(t) + encryptor, err := oidcsession.NewAESGCMEncryptor(key) + require.NoError(t, err) + + encrypted, err := encryptor.Encrypt(context.Background(), []byte("provider tokens")) + require.NoError(t, err) + require.NotContains(t, string(encrypted), "provider tokens") + + decrypted, err := encryptor.Decrypt(context.Background(), encrypted) + require.NoError(t, err) + require.Equal(t, []byte("provider tokens"), decrypted) + + encrypted[len(encrypted)-1] ^= 1 + _, err = encryptor.Decrypt(context.Background(), encrypted) + require.Error(t, err) +} + +func TestAESGCMEncryptorSupportsOrderedKeyRotation(t *testing.T) { + t.Parallel() + oldKey := randomKey(t) + newKey := randomKey(t) + oldEncryptor, err := oidcsession.NewAESGCMEncryptor(oldKey) + require.NoError(t, err) + rotated, err := oidcsession.NewAESGCMEncryptor(newKey, oldKey) + require.NoError(t, err) + + oldCiphertext, err := oldEncryptor.Encrypt(context.Background(), []byte("old")) + require.NoError(t, err) + plaintext, err := rotated.Decrypt(context.Background(), oldCiphertext) + require.NoError(t, err) + require.Equal(t, []byte("old"), plaintext) + + newCiphertext, err := rotated.Encrypt(context.Background(), []byte("new")) + require.NoError(t, err) + _, err = oldEncryptor.Decrypt(context.Background(), newCiphertext) + require.Error(t, err) +} + +func TestAESGCMEncryptorCopiesCallerKeys(t *testing.T) { + t.Parallel() + key := randomKey(t) + encryptor, err := oidcsession.NewAESGCMEncryptor(key) + require.NoError(t, err) + clear(key) + + encrypted, err := encryptor.Encrypt(context.Background(), []byte("still works")) + require.NoError(t, err) + decrypted, err := encryptor.Decrypt(context.Background(), encrypted) + require.NoError(t, err) + require.Equal(t, []byte("still works"), decrypted) +} + +func randomKey(t *testing.T) []byte { + t.Helper() + value := make([]byte, 32) + _, err := rand.Read(value) + require.NoError(t, err) + return value +} diff --git a/oidcsession/errors.go b/oidcsession/errors.go new file mode 100644 index 0000000..9318963 --- /dev/null +++ b/oidcsession/errors.go @@ -0,0 +1,14 @@ +package oidcsession + +import "errors" + +var ( + // ErrProviderUnavailable reports that OIDC metadata or a provider operation is temporarily unavailable. + ErrProviderUnavailable = errors.New("OIDC provider unavailable") + // ErrInvalidSession reports a missing, expired, revoked, or malformed session credential. + ErrInvalidSession = errors.New("invalid session") + // ErrInvalidGrant reports that the provider rejected a refresh grant permanently. + ErrInvalidGrant = errors.New("invalid grant") + // ErrLoginDenied reports that local admission policy rejected an otherwise verified identity. + ErrLoginDenied = errors.New("login denied") +) diff --git a/oidcsession/go.mod b/oidcsession/go.mod new file mode 100644 index 0000000..a59a4c4 --- /dev/null +++ b/oidcsession/go.mod @@ -0,0 +1,24 @@ +module github.com/devctllabs/go-libs/oidcsession + +go 1.25.0 + +require ( + github.com/coreos/go-oidc/v3 v3.20.0 + github.com/devctllabs/go-libs/retry v0.1.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 + golang.org/x/oauth2 v0.36.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool go.uber.org/mock/mockgen diff --git a/oidcsession/go.sum b/oidcsession/go.sum new file mode 100644 index 0000000..6866c7f --- /dev/null +++ b/oidcsession/go.sum @@ -0,0 +1,28 @@ +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/oidcsession/handler_adapter_test.go b/oidcsession/handler_adapter_test.go new file mode 100644 index 0000000..a20a4e2 --- /dev/null +++ b/oidcsession/handler_adapter_test.go @@ -0,0 +1,148 @@ +package oidcsession + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestLoginMapsHTTPRequestAndServiceResult(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + application := NewMocksessionApplication(controller) + expiresAt := time.Now().Add(time.Minute) + application.EXPECT().BeginLogin(gomock.Any(), beginLoginCommand{ + methodID: "sso", returnPath: "/projects/", browserBinding: "", + }).Return(beginLoginResult{ + location: "https://issuer.example/authorize", bindingToStore: &browserBinding{value: "binding", expiresAt: expiresAt}, + }, nil) + handlers := testHTTPAdapter(application) + request := httptest.NewRequest(http.MethodGet, "https://api.example/login?method=sso&return=/projects/", nil) + response := httptest.NewRecorder() + + handlers.Login(response, request) + + require.Equal(t, http.StatusFound, response.Code) + require.Equal(t, "https://issuer.example/authorize", response.Header().Get("Location")) + cookies := response.Result().Cookies() + require.Len(t, cookies, 1) + require.Equal(t, "test-login", cookies[0].Name) + require.Equal(t, "binding", cookies[0].Value) + require.WithinDuration(t, expiresAt, cookies[0].Expires, time.Second) +} + +func TestLoginDoesNotReplaceExistingBrowserBinding(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + application := NewMocksessionApplication(controller) + application.EXPECT().BeginLogin(gomock.Any(), beginLoginCommand{ + methodID: "", returnPath: "", browserBinding: "existing-binding", + }).Return(beginLoginResult{location: "https://issuer.example/authorize"}, nil) + handlers := testHTTPAdapter(application) + request := httptest.NewRequest(http.MethodGet, "https://api.example/login", nil) + request.AddCookie(&http.Cookie{Name: "test-login", Value: "existing-binding"}) + response := httptest.NewRecorder() + + handlers.Login(response, request) + + require.Equal(t, http.StatusFound, response.Code) + require.Empty(t, response.Result().Cookies()) +} + +func TestCallbackRejectsMissingBrowserBinding(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + handlers := testHTTPAdapter(NewMocksessionApplication(controller)) + request := httptest.NewRequest(http.MethodGet, "https://api.example/callback?code=code&state=state", nil) + response := httptest.NewRecorder() + + handlers.Callback(response, request) + + require.Equal(t, http.StatusSeeOther, response.Code) + require.Equal(t, "https://ui.example/app/error?error=login_failed", response.Header().Get("Location")) +} + +func TestUILocationJoinsBaseAndPreservesTrailingSlash(t *testing.T) { + t.Parallel() + handlers := testHTTPAdapter(nil) + tests := []struct { + name string + returnPath string + errorCode string + expected string + }{ + {name: "root", returnPath: "/", expected: "https://ui.example/app/"}, + {name: "path", returnPath: "/projects", expected: "https://ui.example/app/projects"}, + {name: "trailing slash", returnPath: "/projects/", expected: "https://ui.example/app/projects/"}, + {name: "error", returnPath: "/error", errorCode: "login_failed", expected: "https://ui.example/app/error?error=login_failed"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.expected, handlers.uiLocation(test.returnPath, test.errorCode)) + }) + } +} + +func TestRefreshMapsInvalidSessionAndClearsCredentialCookies(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + application := NewMocksessionApplication(controller) + application.EXPECT().Refresh(gomock.Any(), SessionCredential("credential")).Return(RefreshSessionResult{}, ErrInvalidGrant) + handlers := testHTTPAdapter(application) + request := httptest.NewRequest(http.MethodPost, "https://api.example/refresh", nil) + request.Header.Set(defaultCSRFHeaderName, "1") + request.AddCookie(&http.Cookie{Name: "test-session", Value: "credential"}) + response := httptest.NewRecorder() + + handlers.Refresh(response, request) + + require.Equal(t, http.StatusUnauthorized, response.Code) + cookies := response.Result().Cookies() + require.Len(t, cookies, 2) + require.Equal(t, "test-access", cookies[0].Name) + require.Equal(t, -1, cookies[0].MaxAge) + require.Equal(t, "test-session", cookies[1].Name) + require.Equal(t, -1, cookies[1].MaxAge) +} + +func TestValidConfirmationHeader(t *testing.T) { + t.Parallel() + tests := []struct { + name string + header string + expected bool + }{ + {name: "custom token", header: "X-CSRF-Protection", expected: true}, + {name: "invalid token character", header: "Bad Header", expected: false}, + {name: "browser controlled", header: "Sec-Fetch-Site", expected: false}, + {name: "credential header", header: "Authorization", expected: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.expected, validConfirmationHeader(test.header)) + }) + } +} + +func testHTTPAdapter(application sessionApplication) *Handlers { + base, err := url.Parse("https://ui.example/app/") + if err != nil { + panic(err) + } + return &Handlers{ + application: application, + uiBaseURL: base, + errorPath: "/error", + cookies: cookieNames{access: "test-access", session: "test-session", login: "test-login"}, + crossOrigin: http.NewCrossOriginProtection(), + csrfHeader: defaultCSRFHeaderName, + insecure: true, + } +} diff --git a/oidcsession/handlers.go b/oidcsession/handlers.go new file mode 100644 index 0000000..75391af --- /dev/null +++ b/oidcsession/handlers.go @@ -0,0 +1,350 @@ +package oidcsession + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "slices" + "strings" + "time" +) + +const ( + defaultStateTTL = 10 * time.Minute + defaultCSRFHeaderName = "X-CSRF-Protection" + errorCodeLoginFailed = "login_failed" +) + +// HTTPConfig configures the framework-neutral OIDC HTTP handlers. +type HTTPConfig struct { + UIBaseURL *url.URL + DefaultReturnPath string + ErrorPath string + LoginMethods []LoginMethod + TrustedOrigins []string + CSRFHeaderName string + CookiePrefix string + InsecureDevCookies bool + StateTTL time.Duration + LoginAuthorizer LoginAuthorizer + Observer Observer +} + +type cookieNames struct { + access string + session string + login string +} + +// Handlers exposes login, callback, refresh, logout, and session endpoints for caller-owned routing. +type Handlers struct { + application sessionApplication + uiBaseURL *url.URL + errorPath string + cookies cookieNames + crossOrigin *http.CrossOriginProtection + csrfHeader string + insecure bool +} + +// NewHandlers validates HTTP policy and constructs handlers without registering routes. +func NewHandlers(config HTTPConfig, provider *Provider, sessions SessionBackend, stateEncryptor Encryptor) (*Handlers, error) { + if provider == nil || sessions == nil || stateEncryptor == nil { + return nil, errors.New("provider, session backend, and state encryptor are required") + } + if config.UIBaseURL == nil || !config.UIBaseURL.IsAbs() || config.UIBaseURL.Host == "" || config.UIBaseURL.RawQuery != "" || config.UIBaseURL.Fragment != "" { + return nil, errors.New("UI base URL must be absolute and contain no query or fragment") + } + if !validReturnPath(config.DefaultReturnPath) || !validReturnPath(config.ErrorPath) { + return nil, errors.New("default return path and error path must be local absolute paths") + } + if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*$`).MatchString(config.CookiePrefix) { + return nil, errors.New("cookie prefix must contain only letters, digits, and hyphens") + } + if config.StateTTL < 0 { + return nil, errors.New("state TTL must not be negative") + } + if config.StateTTL == 0 { + config.StateTTL = defaultStateTTL + } + header := config.CSRFHeaderName + if header == "" { + header = defaultCSRFHeaderName + } + if !validConfirmationHeader(header) { + return nil, errors.New("CSRF header name is invalid or unsafe") + } + protection := http.NewCrossOriginProtection() + for _, origin := range config.TrustedOrigins { + if err := protection.AddTrustedOrigin(origin); err != nil { + return nil, fmt.Errorf("add trusted origin: %w", err) + } + } + methods, err := cloneLoginMethods(config.LoginMethods) + if err != nil { + return nil, err + } + prefix := config.CookiePrefix + "-" + if !config.InsecureDevCookies { + prefix = "__Host-" + prefix + } + application := newSessionService(sessionServiceConfig{ + defaultReturnPath: config.DefaultReturnPath, + stateTTL: config.StateTTL, + loginAuthorizer: config.LoginAuthorizer, + observer: config.Observer, + methods: methods, + }, provider, sessions, stateEncryptor) + return &Handlers{ + application: application, + uiBaseURL: cloneURL(config.UIBaseURL), + errorPath: config.ErrorPath, + cookies: cookieNames{access: prefix + "access", session: prefix + "session", login: prefix + "login"}, + crossOrigin: protection, + csrfHeader: http.CanonicalHeaderKey(header), + insecure: config.InsecureDevCookies, + }, nil +} + +// Login starts an OIDC authorization-code flow with PKCE, nonce, and encrypted state. +func (handlers *Handlers) Login(writer http.ResponseWriter, request *http.Request) { + if !requireMethod(writer, request, http.MethodGet) { + return + } + binding, err := optionalCookie(request, handlers.cookies.login) + if err != nil { + handlers.redirectError(writer, request, errorCodeLoginFailed) + return + } + result, err := handlers.application.BeginLogin(request.Context(), beginLoginCommand{ + methodID: request.URL.Query().Get("method"), returnPath: request.URL.Query().Get("return"), browserBinding: binding, + }) + if result.bindingToStore != nil { + http.SetCookie(writer, handlers.cookie(handlers.cookies.login, result.bindingToStore.value, result.bindingToStore.expiresAt)) + } + if err != nil { + if errors.Is(err, errUnknownLoginMethod) { + http.Error(writer, "unknown login method", http.StatusBadRequest) + return + } + code := errorCodeLoginFailed + if errors.Is(err, ErrProviderUnavailable) { + code = "provider_unavailable" + } + handlers.redirectError(writer, request, code) + return + } + http.Redirect(writer, request, result.location, http.StatusFound) +} + +// Callback completes OIDC verification and creates a server-side session. +func (handlers *Handlers) Callback(writer http.ResponseWriter, request *http.Request) { + if !requireMethod(writer, request, http.MethodGet) { + return + } + if request.URL.Query().Get("error") != "" { + handlers.redirectError(writer, request, "login_cancelled") + return + } + binding, err := strictCookie(request, handlers.cookies.login) + if err != nil { + handlers.redirectError(writer, request, errorCodeLoginFailed) + return + } + result, err := handlers.application.CompleteLogin(request.Context(), completeLoginCommand{ + code: request.URL.Query().Get("code"), state: request.URL.Query().Get("state"), browserBinding: binding, + }) + if err != nil { + code := errorCodeLoginFailed + switch { + case errors.Is(err, ErrProviderUnavailable): + code = "provider_unavailable" + case errors.Is(err, ErrLoginDenied): + code = "access_denied" + } + handlers.redirectError(writer, request, code) + return + } + handlers.setCredentialCookies(writer, result.tokens.AccessToken, result.tokens.AccessExpiresAt, result.credential, result.sessionExpiresAt) + http.Redirect(writer, request, handlers.uiLocation(result.returnPath, ""), http.StatusSeeOther) +} + +// Refresh renews or reuses an access token through the server-side session. +func (handlers *Handlers) Refresh(writer http.ResponseWriter, request *http.Request) { + if !requireMethod(writer, request, http.MethodPost) || !handlers.checkUnsafe(writer, request) { + return + } + credential, err := strictCookie(request, handlers.cookies.session) + if err != nil { + http.Error(writer, "authentication required", http.StatusUnauthorized) + return + } + refreshed, err := handlers.application.Refresh(request.Context(), SessionCredential(credential)) + if err != nil { + if errors.Is(err, ErrInvalidSession) || errors.Is(err, ErrInvalidGrant) { + handlers.clearCredentialCookies(writer) + http.Error(writer, "authentication required", http.StatusUnauthorized) + return + } + http.Error(writer, "authentication unavailable", http.StatusServiceUnavailable) + return + } + handlers.setCredentialCookies(writer, refreshed.AccessToken, refreshed.AccessExpiresAt, SessionCredential(credential), refreshed.SessionExpiresAt) + writeJSON(writer, refreshResponse{AccessExpiresAt: refreshed.AccessExpiresAt, SessionExpiresAt: refreshed.SessionExpiresAt}) +} + +// Logout invalidates the local session and clears browser credentials. Broker SSO is unchanged. +func (handlers *Handlers) Logout(writer http.ResponseWriter, request *http.Request) { + if !requireMethod(writer, request, http.MethodPost) || !handlers.checkUnsafe(writer, request) { + return + } + credential, err := strictCookie(request, handlers.cookies.session) + handlers.clearCredentialCookies(writer) + if err == nil { + handlers.application.Logout(request.Context(), SessionCredential(credential)) + } + writer.WriteHeader(http.StatusNoContent) +} + +// Session returns safe session expiry metadata without exposing credentials or extending idle TTL. +func (handlers *Handlers) Session(writer http.ResponseWriter, request *http.Request) { + if !requireMethod(writer, request, http.MethodGet) { + return + } + credential, err := strictCookie(request, handlers.cookies.session) + if err != nil { + writeJSON(writer, sessionResponse{Authenticated: false}) + return + } + status, err := handlers.application.Session(request.Context(), SessionCredential(credential)) + if err != nil { + if errors.Is(err, ErrInvalidSession) { + handlers.clearCredentialCookies(writer) + writeJSON(writer, sessionResponse{Authenticated: false}) + return + } + http.Error(writer, "authentication unavailable", http.StatusServiceUnavailable) + return + } + writeJSON(writer, sessionResponse{ + Authenticated: true, AccessExpiresAt: &status.AccessExpiresAt, SessionExpiresAt: &status.SessionExpiresAt, + }) +} + +type refreshResponse struct { + AccessExpiresAt time.Time `json:"accessExpiresAt"` + SessionExpiresAt time.Time `json:"sessionExpiresAt"` +} + +type sessionResponse struct { + Authenticated bool `json:"authenticated"` + AccessExpiresAt *time.Time `json:"accessExpiresAt,omitempty"` + SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` +} + +func (handlers *Handlers) setCredentialCookies(writer http.ResponseWriter, accessToken string, accessExpiry time.Time, credential SessionCredential, sessionExpiry time.Time) { + http.SetCookie(writer, handlers.cookie(handlers.cookies.access, accessToken, accessExpiry)) + http.SetCookie(writer, handlers.cookie(handlers.cookies.session, string(credential), sessionExpiry)) +} + +func (handlers *Handlers) clearCredentialCookies(writer http.ResponseWriter) { + for _, name := range []string{handlers.cookies.access, handlers.cookies.session} { + //nolint:gosec // The helper applies HttpOnly, SameSite=Lax, and Secure unless explicit dev mode is configured. + cookie := handlers.cookie(name, "", time.Unix(1, 0)) + cookie.MaxAge = -1 + http.SetCookie(writer, cookie) + } +} + +func (handlers *Handlers) cookie(name, value string, expires time.Time) *http.Cookie { + //nolint:gosec // InsecureDevCookies is an explicit opt-in for local HTTP development only. + return &http.Cookie{Name: name, Value: value, Path: "/", Expires: expires, Secure: !handlers.insecure, HttpOnly: true, SameSite: http.SameSiteLaxMode} +} + +func (handlers *Handlers) checkUnsafe(writer http.ResponseWriter, request *http.Request) bool { + if err := handlers.crossOrigin.Check(request); err != nil { + http.Error(writer, "forbidden", http.StatusForbidden) + return false + } + values := request.Header.Values(handlers.csrfHeader) + if len(values) != 1 || values[0] != "1" { + http.Error(writer, "forbidden", http.StatusForbidden) + return false + } + return true +} + +func (handlers *Handlers) redirectError(writer http.ResponseWriter, request *http.Request, code string) { + http.Redirect(writer, request, handlers.uiLocation(handlers.errorPath, code), http.StatusSeeOther) +} + +func (handlers *Handlers) uiLocation(returnPath, errorCode string) string { + destination := handlers.uiBaseURL.JoinPath(returnPath) + if errorCode != "" { + destination.RawQuery = url.Values{"error": {errorCode}}.Encode() + } + return destination.String() +} + +func requireMethod(writer http.ResponseWriter, request *http.Request, method string) bool { + if request.Method == method { + return true + } + writer.Header().Set("Allow", method) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return false +} + +func writeJSON(writer http.ResponseWriter, value any) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _ = json.NewEncoder(writer).Encode(value) +} + +func optionalCookie(request *http.Request, name string) (string, error) { + cookies := request.CookiesNamed(name) + if len(cookies) > 1 { + return "", errors.New("duplicate cookies") + } + if len(cookies) == 0 { + return "", nil + } + return cookies[0].Value, nil +} + +func strictCookie(request *http.Request, name string) (string, error) { + value, err := optionalCookie(request, name) + if err != nil || value == "" { + return "", ErrInvalidSession + } + return value, nil +} + +func cloneURL(value *url.URL) *url.URL { + copy := *value + return © +} + +func validConfirmationHeader(name string) bool { + if name == "" { + return false + } + for index := range len(name) { + if !isHeaderTokenCharacter(name[index]) { + return false + } + } + lower := strings.ToLower(name) + if strings.HasPrefix(lower, "sec-") || strings.HasPrefix(lower, "proxy-") { + return false + } + return !slices.Contains([]string{"accept", "accept-language", "content-language", "content-type", "range", "authorization", "cookie", "host", "origin", "referer", "user-agent"}, lower) +} + +func isHeaderTokenCharacter(character byte) bool { + return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || + strings.ContainsRune("!#$%&'*+-.^_`|~", rune(character)) +} diff --git a/oidcsession/handlers_test.go b/oidcsession/handlers_test.go new file mode 100644 index 0000000..962f135 --- /dev/null +++ b/oidcsession/handlers_test.go @@ -0,0 +1,150 @@ +package oidcsession_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/devctllabs/go-libs/oidcsession" + "github.com/devctllabs/go-libs/oidcsession/mocks" + "github.com/devctllabs/go-libs/retry" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestLoginBuildsOIDCAuthorizationRequestWithPKCENonceAndBrokerHint(t *testing.T) { + t.Parallel() + provider, stop := readyProvider(t) + defer stop() + controller := gomock.NewController(t) + sessions := mocks.NewMockSessionBackend(controller) + uiURL, err := url.Parse("https://ui.example") + require.NoError(t, err) + handlers, err := oidcsession.NewHandlers(oidcsession.HTTPConfig{ + UIBaseURL: uiURL, + DefaultReturnPath: "/", + ErrorPath: "/auth/error", + CookiePrefix: "example", + LoginMethods: []oidcsession.LoginMethod{{ + ID: "google", + AuthorizationParameters: url.Values{"provider_hint": {"google"}}, + }}, + }, provider, sessions, oidcsession.InsecureNoopEncryptor()) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodGet, "https://api.example/auth/login?method=google&return=/onboarding", nil) + recorder := httptest.NewRecorder() + + handlers.Login(recorder, request) + + require.Equal(t, http.StatusFound, recorder.Code) + location, err := url.Parse(recorder.Header().Get("Location")) + require.NoError(t, err) + require.Equal(t, "S256", location.Query().Get("code_challenge_method")) + require.NotEmpty(t, location.Query().Get("code_challenge")) + require.NotEmpty(t, location.Query().Get("nonce")) + require.NotEmpty(t, location.Query().Get("state")) + require.Equal(t, "google", location.Query().Get("provider_hint")) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + require.Equal(t, "__Host-example-login", cookies[0].Name) + require.True(t, cookies[0].Secure) + require.True(t, cookies[0].HttpOnly) +} + +func TestLoginRejectsUnknownMethod(t *testing.T) { + t.Parallel() + provider, stop := readyProvider(t) + defer stop() + controller := gomock.NewController(t) + handlers := newTestHandlers(t, provider, mocks.NewMockSessionBackend(controller)) + request := httptest.NewRequest(http.MethodGet, "https://api.example/auth/login?method=unknown", nil) + recorder := httptest.NewRecorder() + + handlers.Login(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code) +} + +func TestRefreshRequiresCrossOriginAndConfirmationChecks(t *testing.T) { + t.Parallel() + provider, stop := readyProvider(t) + defer stop() + controller := gomock.NewController(t) + handlers := newTestHandlers(t, provider, mocks.NewMockSessionBackend(controller)) + request := httptest.NewRequest(http.MethodPost, "https://api.example/auth/refresh", nil) + request.AddCookie(&http.Cookie{Name: "__Host-example-session", Value: "credential"}) + recorder := httptest.NewRecorder() + + handlers.Refresh(recorder, request) + + require.Equal(t, http.StatusForbidden, recorder.Code) +} + +func TestSessionWithoutCredentialIsAnonymous(t *testing.T) { + t.Parallel() + provider, stop := readyProvider(t) + defer stop() + controller := gomock.NewController(t) + handlers := newTestHandlers(t, provider, mocks.NewMockSessionBackend(controller)) + request := httptest.NewRequest(http.MethodGet, "https://api.example/auth/session", nil) + recorder := httptest.NewRecorder() + + handlers.Session(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `{"authenticated":false}`, recorder.Body.String()) +} + +func newTestHandlers(t *testing.T, provider *oidcsession.Provider, sessions oidcsession.SessionBackend) *oidcsession.Handlers { + t.Helper() + uiURL, err := url.Parse("https://ui.example") + require.NoError(t, err) + handlers, err := oidcsession.NewHandlers(oidcsession.HTTPConfig{ + UIBaseURL: uiURL, + DefaultReturnPath: "/", + ErrorPath: "/auth/error", + CookiePrefix: "example", + TrustedOrigins: []string{"https://ui.example"}, + }, provider, sessions, oidcsession.InsecureNoopEncryptor()) + require.NoError(t, err) + return handlers +} + +func readyProvider(t *testing.T) (*oidcsession.Provider, func()) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + issuer := "http://" + r.Host + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer, + "authorization_endpoint": issuer + "/authorize", + "token_endpoint": issuer + "/token", + "jwks_uri": issuer + "/keys", + }) + })) + policy, err := retry.NewExponential(retry.ExponentialConfig{InitialDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond, Multiplier: 2}) + require.NoError(t, err) + provider, err := oidcsession.NewProvider(oidcsession.ProviderConfig{ + IssuerURL: server.URL, + ClientID: "client", + ClientSecret: "secret", + RedirectURL: "https://api.example/auth/callback", + Scopes: []string{"openid"}, + HTTPClient: &http.Client{Timeout: time.Second}, + DiscoveryRetry: policy, + }) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- provider.Run(ctx) }() + require.Eventually(t, func() bool { return provider.Check(context.Background()) == nil }, time.Second, time.Millisecond) + return provider, func() { + cancel() + require.NoError(t, receiveWithin(t, done, time.Second)) + server.Close() + } +} diff --git a/oidcsession/internal_contracts.gen_test.go b/oidcsession/internal_contracts.gen_test.go new file mode 100644 index 0000000..737d844 --- /dev/null +++ b/oidcsession/internal_contracts.gen_test.go @@ -0,0 +1,337 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal_contracts.go +// +// Generated by this command: +// +// mockgen -source=internal_contracts.go -destination=internal_contracts.gen_test.go -package=oidcsession -typed +// + +// Package oidcsession is a generated GoMock package. +package oidcsession + +import ( + context "context" + url "net/url" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MocksessionApplication is a mock of sessionApplication interface. +type MocksessionApplication struct { + ctrl *gomock.Controller + recorder *MocksessionApplicationMockRecorder + isgomock struct{} +} + +// MocksessionApplicationMockRecorder is the mock recorder for MocksessionApplication. +type MocksessionApplicationMockRecorder struct { + mock *MocksessionApplication +} + +// NewMocksessionApplication creates a new mock instance. +func NewMocksessionApplication(ctrl *gomock.Controller) *MocksessionApplication { + mock := &MocksessionApplication{ctrl: ctrl} + mock.recorder = &MocksessionApplicationMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MocksessionApplication) EXPECT() *MocksessionApplicationMockRecorder { + return m.recorder +} + +// BeginLogin mocks base method. +func (m *MocksessionApplication) BeginLogin(ctx context.Context, command beginLoginCommand) (beginLoginResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BeginLogin", ctx, command) + ret0, _ := ret[0].(beginLoginResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BeginLogin indicates an expected call of BeginLogin. +func (mr *MocksessionApplicationMockRecorder) BeginLogin(ctx, command any) *MocksessionApplicationBeginLoginCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginLogin", reflect.TypeOf((*MocksessionApplication)(nil).BeginLogin), ctx, command) + return &MocksessionApplicationBeginLoginCall{Call: call} +} + +// MocksessionApplicationBeginLoginCall wrap *gomock.Call +type MocksessionApplicationBeginLoginCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionApplicationBeginLoginCall) Return(arg0 beginLoginResult, arg1 error) *MocksessionApplicationBeginLoginCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionApplicationBeginLoginCall) Do(f func(context.Context, beginLoginCommand) (beginLoginResult, error)) *MocksessionApplicationBeginLoginCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionApplicationBeginLoginCall) DoAndReturn(f func(context.Context, beginLoginCommand) (beginLoginResult, error)) *MocksessionApplicationBeginLoginCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CompleteLogin mocks base method. +func (m *MocksessionApplication) CompleteLogin(ctx context.Context, command completeLoginCommand) (completeLoginResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CompleteLogin", ctx, command) + ret0, _ := ret[0].(completeLoginResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CompleteLogin indicates an expected call of CompleteLogin. +func (mr *MocksessionApplicationMockRecorder) CompleteLogin(ctx, command any) *MocksessionApplicationCompleteLoginCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CompleteLogin", reflect.TypeOf((*MocksessionApplication)(nil).CompleteLogin), ctx, command) + return &MocksessionApplicationCompleteLoginCall{Call: call} +} + +// MocksessionApplicationCompleteLoginCall wrap *gomock.Call +type MocksessionApplicationCompleteLoginCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionApplicationCompleteLoginCall) Return(arg0 completeLoginResult, arg1 error) *MocksessionApplicationCompleteLoginCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionApplicationCompleteLoginCall) Do(f func(context.Context, completeLoginCommand) (completeLoginResult, error)) *MocksessionApplicationCompleteLoginCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionApplicationCompleteLoginCall) DoAndReturn(f func(context.Context, completeLoginCommand) (completeLoginResult, error)) *MocksessionApplicationCompleteLoginCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Logout mocks base method. +func (m *MocksessionApplication) Logout(ctx context.Context, credential SessionCredential) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Logout", ctx, credential) +} + +// Logout indicates an expected call of Logout. +func (mr *MocksessionApplicationMockRecorder) Logout(ctx, credential any) *MocksessionApplicationLogoutCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logout", reflect.TypeOf((*MocksessionApplication)(nil).Logout), ctx, credential) + return &MocksessionApplicationLogoutCall{Call: call} +} + +// MocksessionApplicationLogoutCall wrap *gomock.Call +type MocksessionApplicationLogoutCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionApplicationLogoutCall) Return() *MocksessionApplicationLogoutCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionApplicationLogoutCall) Do(f func(context.Context, SessionCredential)) *MocksessionApplicationLogoutCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionApplicationLogoutCall) DoAndReturn(f func(context.Context, SessionCredential)) *MocksessionApplicationLogoutCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Refresh mocks base method. +func (m *MocksessionApplication) Refresh(ctx context.Context, credential SessionCredential) (RefreshSessionResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Refresh", ctx, credential) + ret0, _ := ret[0].(RefreshSessionResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Refresh indicates an expected call of Refresh. +func (mr *MocksessionApplicationMockRecorder) Refresh(ctx, credential any) *MocksessionApplicationRefreshCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Refresh", reflect.TypeOf((*MocksessionApplication)(nil).Refresh), ctx, credential) + return &MocksessionApplicationRefreshCall{Call: call} +} + +// MocksessionApplicationRefreshCall wrap *gomock.Call +type MocksessionApplicationRefreshCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionApplicationRefreshCall) Return(arg0 RefreshSessionResult, arg1 error) *MocksessionApplicationRefreshCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionApplicationRefreshCall) Do(f func(context.Context, SessionCredential) (RefreshSessionResult, error)) *MocksessionApplicationRefreshCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionApplicationRefreshCall) DoAndReturn(f func(context.Context, SessionCredential) (RefreshSessionResult, error)) *MocksessionApplicationRefreshCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Session mocks base method. +func (m *MocksessionApplication) Session(ctx context.Context, credential SessionCredential) (SessionStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Session", ctx, credential) + ret0, _ := ret[0].(SessionStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Session indicates an expected call of Session. +func (mr *MocksessionApplicationMockRecorder) Session(ctx, credential any) *MocksessionApplicationSessionCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Session", reflect.TypeOf((*MocksessionApplication)(nil).Session), ctx, credential) + return &MocksessionApplicationSessionCall{Call: call} +} + +// MocksessionApplicationSessionCall wrap *gomock.Call +type MocksessionApplicationSessionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionApplicationSessionCall) Return(arg0 SessionStatus, arg1 error) *MocksessionApplicationSessionCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionApplicationSessionCall) Do(f func(context.Context, SessionCredential) (SessionStatus, error)) *MocksessionApplicationSessionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionApplicationSessionCall) DoAndReturn(f func(context.Context, SessionCredential) (SessionStatus, error)) *MocksessionApplicationSessionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockloginProvider is a mock of loginProvider interface. +type MockloginProvider struct { + ctrl *gomock.Controller + recorder *MockloginProviderMockRecorder + isgomock struct{} +} + +// MockloginProviderMockRecorder is the mock recorder for MockloginProvider. +type MockloginProviderMockRecorder struct { + mock *MockloginProvider +} + +// NewMockloginProvider creates a new mock instance. +func NewMockloginProvider(ctrl *gomock.Controller) *MockloginProvider { + mock := &MockloginProvider{ctrl: ctrl} + mock.recorder = &MockloginProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockloginProvider) EXPECT() *MockloginProviderMockRecorder { + return m.recorder +} + +// authorizationURL mocks base method. +func (m *MockloginProvider) authorizationURL(state, nonce, verifier string, parameters url.Values) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "authorizationURL", state, nonce, verifier, parameters) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// authorizationURL indicates an expected call of authorizationURL. +func (mr *MockloginProviderMockRecorder) authorizationURL(state, nonce, verifier, parameters any) *MockloginProviderauthorizationURLCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "authorizationURL", reflect.TypeOf((*MockloginProvider)(nil).authorizationURL), state, nonce, verifier, parameters) + return &MockloginProviderauthorizationURLCall{Call: call} +} + +// MockloginProviderauthorizationURLCall wrap *gomock.Call +type MockloginProviderauthorizationURLCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockloginProviderauthorizationURLCall) Return(arg0 string, arg1 error) *MockloginProviderauthorizationURLCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockloginProviderauthorizationURLCall) Do(f func(string, string, string, url.Values) (string, error)) *MockloginProviderauthorizationURLCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockloginProviderauthorizationURLCall) DoAndReturn(f func(string, string, string, url.Values) (string, error)) *MockloginProviderauthorizationURLCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// exchange mocks base method. +func (m *MockloginProvider) exchange(ctx context.Context, code, nonce, verifier, methodID string) (ProviderTokens, VerifiedIdentity, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "exchange", ctx, code, nonce, verifier, methodID) + ret0, _ := ret[0].(ProviderTokens) + ret1, _ := ret[1].(VerifiedIdentity) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// exchange indicates an expected call of exchange. +func (mr *MockloginProviderMockRecorder) exchange(ctx, code, nonce, verifier, methodID any) *MockloginProviderexchangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "exchange", reflect.TypeOf((*MockloginProvider)(nil).exchange), ctx, code, nonce, verifier, methodID) + return &MockloginProviderexchangeCall{Call: call} +} + +// MockloginProviderexchangeCall wrap *gomock.Call +type MockloginProviderexchangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockloginProviderexchangeCall) Return(arg0 ProviderTokens, arg1 VerifiedIdentity, arg2 error) *MockloginProviderexchangeCall { + c.Call = c.Call.Return(arg0, arg1, arg2) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockloginProviderexchangeCall) Do(f func(context.Context, string, string, string, string) (ProviderTokens, VerifiedIdentity, error)) *MockloginProviderexchangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockloginProviderexchangeCall) DoAndReturn(f func(context.Context, string, string, string, string) (ProviderTokens, VerifiedIdentity, error)) *MockloginProviderexchangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/oidcsession/internal_contracts.go b/oidcsession/internal_contracts.go new file mode 100644 index 0000000..1b6e3e0 --- /dev/null +++ b/oidcsession/internal_contracts.go @@ -0,0 +1,29 @@ +package oidcsession + +import ( + "context" + "net/url" +) + +//go:generate go tool mockgen -source=internal_contracts.go -destination=internal_contracts.gen_test.go -package=oidcsession -typed +//go:generate go tool mockgen -destination=internal_dependencies.gen_test.go -package=oidcsession -typed -self_package=github.com/devctllabs/go-libs/oidcsession . SessionBackend,Encryptor + +type sessionApplication interface { + // BeginLogin prepares the provider redirect and browser-binding state for a login attempt. + BeginLogin(ctx context.Context, command beginLoginCommand) (beginLoginResult, error) + // CompleteLogin verifies the callback and creates a server-side session. + CompleteLogin(ctx context.Context, command completeLoginCommand) (completeLoginResult, error) + // Refresh returns current credentials for an existing server-side session. + Refresh(ctx context.Context, credential SessionCredential) (RefreshSessionResult, error) + // Logout invalidates credential and records non-terminal provider failures. + Logout(ctx context.Context, credential SessionCredential) + // Session returns safe status metadata for credential. + Session(ctx context.Context, credential SessionCredential) (SessionStatus, error) +} + +type loginProvider interface { + // authorizationURL builds a provider authorization URL from verified flow state. + authorizationURL(state string, nonce string, verifier string, parameters url.Values) (string, error) + // exchange verifies an authorization response and returns its provider tokens and identity. + exchange(ctx context.Context, code string, nonce string, verifier string, methodID string) (ProviderTokens, VerifiedIdentity, error) +} diff --git a/oidcsession/internal_dependencies.gen_test.go b/oidcsession/internal_dependencies.gen_test.go new file mode 100644 index 0000000..e04b459 --- /dev/null +++ b/oidcsession/internal_dependencies.gen_test.go @@ -0,0 +1,298 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/oidcsession (interfaces: SessionBackend,Encryptor) +// +// Generated by this command: +// +// mockgen -destination=internal_dependencies.gen_test.go -package=oidcsession -typed -self_package=github.com/devctllabs/go-libs/oidcsession . SessionBackend,Encryptor +// + +// Package oidcsession is a generated GoMock package. +package oidcsession + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockSessionBackend is a mock of SessionBackend interface. +type MockSessionBackend struct { + ctrl *gomock.Controller + recorder *MockSessionBackendMockRecorder + isgomock struct{} +} + +// MockSessionBackendMockRecorder is the mock recorder for MockSessionBackend. +type MockSessionBackendMockRecorder struct { + mock *MockSessionBackend +} + +// NewMockSessionBackend creates a new mock instance. +func NewMockSessionBackend(ctrl *gomock.Controller) *MockSessionBackend { + mock := &MockSessionBackend{ctrl: ctrl} + mock.recorder = &MockSessionBackendMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSessionBackend) EXPECT() *MockSessionBackendMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockSessionBackend) Create(ctx context.Context, params CreateSessionParams) (CreateSessionResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, params) + ret0, _ := ret[0].(CreateSessionResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockSessionBackendMockRecorder) Create(ctx, params any) *MockSessionBackendCreateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSessionBackend)(nil).Create), ctx, params) + return &MockSessionBackendCreateCall{Call: call} +} + +// MockSessionBackendCreateCall wrap *gomock.Call +type MockSessionBackendCreateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSessionBackendCreateCall) Return(result CreateSessionResult, err error) *MockSessionBackendCreateCall { + c.Call = c.Call.Return(result, err) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSessionBackendCreateCall) Do(f func(context.Context, CreateSessionParams) (CreateSessionResult, error)) *MockSessionBackendCreateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSessionBackendCreateCall) DoAndReturn(f func(context.Context, CreateSessionParams) (CreateSessionResult, error)) *MockSessionBackendCreateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Refresh mocks base method. +func (m *MockSessionBackend) Refresh(ctx context.Context, credential SessionCredential) (RefreshSessionResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Refresh", ctx, credential) + ret0, _ := ret[0].(RefreshSessionResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Refresh indicates an expected call of Refresh. +func (mr *MockSessionBackendMockRecorder) Refresh(ctx, credential any) *MockSessionBackendRefreshCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Refresh", reflect.TypeOf((*MockSessionBackend)(nil).Refresh), ctx, credential) + return &MockSessionBackendRefreshCall{Call: call} +} + +// MockSessionBackendRefreshCall wrap *gomock.Call +type MockSessionBackendRefreshCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSessionBackendRefreshCall) Return(result RefreshSessionResult, err error) *MockSessionBackendRefreshCall { + c.Call = c.Call.Return(result, err) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSessionBackendRefreshCall) Do(f func(context.Context, SessionCredential) (RefreshSessionResult, error)) *MockSessionBackendRefreshCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSessionBackendRefreshCall) DoAndReturn(f func(context.Context, SessionCredential) (RefreshSessionResult, error)) *MockSessionBackendRefreshCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Revoke mocks base method. +func (m *MockSessionBackend) Revoke(ctx context.Context, credential SessionCredential) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Revoke", ctx, credential) + ret0, _ := ret[0].(error) + return ret0 +} + +// Revoke indicates an expected call of Revoke. +func (mr *MockSessionBackendMockRecorder) Revoke(ctx, credential any) *MockSessionBackendRevokeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Revoke", reflect.TypeOf((*MockSessionBackend)(nil).Revoke), ctx, credential) + return &MockSessionBackendRevokeCall{Call: call} +} + +// MockSessionBackendRevokeCall wrap *gomock.Call +type MockSessionBackendRevokeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSessionBackendRevokeCall) Return(arg0 error) *MockSessionBackendRevokeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSessionBackendRevokeCall) Do(f func(context.Context, SessionCredential) error) *MockSessionBackendRevokeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSessionBackendRevokeCall) DoAndReturn(f func(context.Context, SessionCredential) error) *MockSessionBackendRevokeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Status mocks base method. +func (m *MockSessionBackend) Status(ctx context.Context, credential SessionCredential) (SessionStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Status", ctx, credential) + ret0, _ := ret[0].(SessionStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Status indicates an expected call of Status. +func (mr *MockSessionBackendMockRecorder) Status(ctx, credential any) *MockSessionBackendStatusCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockSessionBackend)(nil).Status), ctx, credential) + return &MockSessionBackendStatusCall{Call: call} +} + +// MockSessionBackendStatusCall wrap *gomock.Call +type MockSessionBackendStatusCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSessionBackendStatusCall) Return(status SessionStatus, err error) *MockSessionBackendStatusCall { + c.Call = c.Call.Return(status, err) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSessionBackendStatusCall) Do(f func(context.Context, SessionCredential) (SessionStatus, error)) *MockSessionBackendStatusCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSessionBackendStatusCall) DoAndReturn(f func(context.Context, SessionCredential) (SessionStatus, error)) *MockSessionBackendStatusCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockEncryptor is a mock of Encryptor interface. +type MockEncryptor struct { + ctrl *gomock.Controller + recorder *MockEncryptorMockRecorder + isgomock struct{} +} + +// MockEncryptorMockRecorder is the mock recorder for MockEncryptor. +type MockEncryptorMockRecorder struct { + mock *MockEncryptor +} + +// NewMockEncryptor creates a new mock instance. +func NewMockEncryptor(ctrl *gomock.Controller) *MockEncryptor { + mock := &MockEncryptor{ctrl: ctrl} + mock.recorder = &MockEncryptorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEncryptor) EXPECT() *MockEncryptorMockRecorder { + return m.recorder +} + +// Decrypt mocks base method. +func (m *MockEncryptor) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Decrypt", ctx, ciphertext) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Decrypt indicates an expected call of Decrypt. +func (mr *MockEncryptorMockRecorder) Decrypt(ctx, ciphertext any) *MockEncryptorDecryptCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Decrypt", reflect.TypeOf((*MockEncryptor)(nil).Decrypt), ctx, ciphertext) + return &MockEncryptorDecryptCall{Call: call} +} + +// MockEncryptorDecryptCall wrap *gomock.Call +type MockEncryptorDecryptCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockEncryptorDecryptCall) Return(plaintext []byte, err error) *MockEncryptorDecryptCall { + c.Call = c.Call.Return(plaintext, err) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockEncryptorDecryptCall) Do(f func(context.Context, []byte) ([]byte, error)) *MockEncryptorDecryptCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockEncryptorDecryptCall) DoAndReturn(f func(context.Context, []byte) ([]byte, error)) *MockEncryptorDecryptCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Encrypt mocks base method. +func (m *MockEncryptor) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Encrypt", ctx, plaintext) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Encrypt indicates an expected call of Encrypt. +func (mr *MockEncryptorMockRecorder) Encrypt(ctx, plaintext any) *MockEncryptorEncryptCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Encrypt", reflect.TypeOf((*MockEncryptor)(nil).Encrypt), ctx, plaintext) + return &MockEncryptorEncryptCall{Call: call} +} + +// MockEncryptorEncryptCall wrap *gomock.Call +type MockEncryptorEncryptCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockEncryptorEncryptCall) Return(ciphertext []byte, err error) *MockEncryptorEncryptCall { + c.Call = c.Call.Return(ciphertext, err) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockEncryptorEncryptCall) Do(f func(context.Context, []byte) ([]byte, error)) *MockEncryptorEncryptCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockEncryptorEncryptCall) DoAndReturn(f func(context.Context, []byte) ([]byte, error)) *MockEncryptorEncryptCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/oidcsession/mocks/interfaces.gen.go b/oidcsession/mocks/interfaces.gen.go new file mode 100644 index 0000000..315f2b8 --- /dev/null +++ b/oidcsession/mocks/interfaces.gen.go @@ -0,0 +1,208 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/oidcsession (interfaces: SessionBackend,TokenService,Encryptor) +// +// Generated by this command: +// +// mockgen -destination mocks/interfaces.gen.go -package mocks . SessionBackend,TokenService,Encryptor +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + oidcsession "github.com/devctllabs/go-libs/oidcsession" + gomock "go.uber.org/mock/gomock" +) + +// MockSessionBackend is a mock of SessionBackend interface. +type MockSessionBackend struct { + ctrl *gomock.Controller + recorder *MockSessionBackendMockRecorder + isgomock struct{} +} + +// MockSessionBackendMockRecorder is the mock recorder for MockSessionBackend. +type MockSessionBackendMockRecorder struct { + mock *MockSessionBackend +} + +// NewMockSessionBackend creates a new mock instance. +func NewMockSessionBackend(ctrl *gomock.Controller) *MockSessionBackend { + mock := &MockSessionBackend{ctrl: ctrl} + mock.recorder = &MockSessionBackendMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSessionBackend) EXPECT() *MockSessionBackendMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockSessionBackend) Create(ctx context.Context, params oidcsession.CreateSessionParams) (oidcsession.CreateSessionResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, params) + ret0, _ := ret[0].(oidcsession.CreateSessionResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockSessionBackendMockRecorder) Create(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSessionBackend)(nil).Create), ctx, params) +} + +// Refresh mocks base method. +func (m *MockSessionBackend) Refresh(ctx context.Context, credential oidcsession.SessionCredential) (oidcsession.RefreshSessionResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Refresh", ctx, credential) + ret0, _ := ret[0].(oidcsession.RefreshSessionResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Refresh indicates an expected call of Refresh. +func (mr *MockSessionBackendMockRecorder) Refresh(ctx, credential any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Refresh", reflect.TypeOf((*MockSessionBackend)(nil).Refresh), ctx, credential) +} + +// Revoke mocks base method. +func (m *MockSessionBackend) Revoke(ctx context.Context, credential oidcsession.SessionCredential) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Revoke", ctx, credential) + ret0, _ := ret[0].(error) + return ret0 +} + +// Revoke indicates an expected call of Revoke. +func (mr *MockSessionBackendMockRecorder) Revoke(ctx, credential any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Revoke", reflect.TypeOf((*MockSessionBackend)(nil).Revoke), ctx, credential) +} + +// Status mocks base method. +func (m *MockSessionBackend) Status(ctx context.Context, credential oidcsession.SessionCredential) (oidcsession.SessionStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Status", ctx, credential) + ret0, _ := ret[0].(oidcsession.SessionStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Status indicates an expected call of Status. +func (mr *MockSessionBackendMockRecorder) Status(ctx, credential any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockSessionBackend)(nil).Status), ctx, credential) +} + +// MockTokenService is a mock of TokenService interface. +type MockTokenService struct { + ctrl *gomock.Controller + recorder *MockTokenServiceMockRecorder + isgomock struct{} +} + +// MockTokenServiceMockRecorder is the mock recorder for MockTokenService. +type MockTokenServiceMockRecorder struct { + mock *MockTokenService +} + +// NewMockTokenService creates a new mock instance. +func NewMockTokenService(ctrl *gomock.Controller) *MockTokenService { + mock := &MockTokenService{ctrl: ctrl} + mock.recorder = &MockTokenServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTokenService) EXPECT() *MockTokenServiceMockRecorder { + return m.recorder +} + +// Refresh mocks base method. +func (m *MockTokenService) Refresh(ctx context.Context, refreshToken string) (oidcsession.ProviderTokens, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Refresh", ctx, refreshToken) + ret0, _ := ret[0].(oidcsession.ProviderTokens) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Refresh indicates an expected call of Refresh. +func (mr *MockTokenServiceMockRecorder) Refresh(ctx, refreshToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Refresh", reflect.TypeOf((*MockTokenService)(nil).Refresh), ctx, refreshToken) +} + +// Revoke mocks base method. +func (m *MockTokenService) Revoke(ctx context.Context, refreshToken string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Revoke", ctx, refreshToken) + ret0, _ := ret[0].(error) + return ret0 +} + +// Revoke indicates an expected call of Revoke. +func (mr *MockTokenServiceMockRecorder) Revoke(ctx, refreshToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Revoke", reflect.TypeOf((*MockTokenService)(nil).Revoke), ctx, refreshToken) +} + +// MockEncryptor is a mock of Encryptor interface. +type MockEncryptor struct { + ctrl *gomock.Controller + recorder *MockEncryptorMockRecorder + isgomock struct{} +} + +// MockEncryptorMockRecorder is the mock recorder for MockEncryptor. +type MockEncryptorMockRecorder struct { + mock *MockEncryptor +} + +// NewMockEncryptor creates a new mock instance. +func NewMockEncryptor(ctrl *gomock.Controller) *MockEncryptor { + mock := &MockEncryptor{ctrl: ctrl} + mock.recorder = &MockEncryptorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEncryptor) EXPECT() *MockEncryptorMockRecorder { + return m.recorder +} + +// Decrypt mocks base method. +func (m *MockEncryptor) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Decrypt", ctx, ciphertext) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Decrypt indicates an expected call of Decrypt. +func (mr *MockEncryptorMockRecorder) Decrypt(ctx, ciphertext any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Decrypt", reflect.TypeOf((*MockEncryptor)(nil).Decrypt), ctx, ciphertext) +} + +// Encrypt mocks base method. +func (m *MockEncryptor) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Encrypt", ctx, plaintext) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Encrypt indicates an expected call of Encrypt. +func (mr *MockEncryptorMockRecorder) Encrypt(ctx, plaintext any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Encrypt", reflect.TypeOf((*MockEncryptor)(nil).Encrypt), ctx, plaintext) +} diff --git a/oidcsession/observer.go b/oidcsession/observer.go new file mode 100644 index 0000000..5205c97 --- /dev/null +++ b/oidcsession/observer.go @@ -0,0 +1,46 @@ +package oidcsession + +import ( + "context" + "time" +) + +// Operation identifies a bounded OIDC/session operation for diagnostics. +type Operation string + +const ( + OperationDiscovery Operation = "discovery" + OperationLogin Operation = "login" + OperationCallback Operation = "callback" + OperationRefresh Operation = "refresh" + OperationLogout Operation = "logout" + OperationSession Operation = "session" +) + +// Observation contains operational diagnostics. Callers must not attach credentials or claims. +type Observation struct { + Operation Operation + Err error + Duration time.Duration + Retry bool +} + +// Observer receives operational failures and retries. +type Observer interface { + // Observe records observation promptly and must not retain request credentials. + Observe(ctx context.Context, observation Observation) +} + +// ObserverFunc adapts a function to Observer. +type ObserverFunc func(ctx context.Context, observation Observation) + +// Observe implements Observer. +func (function ObserverFunc) Observe(ctx context.Context, observation Observation) { + function(ctx, observation) +} + +func observe(ctx context.Context, observer Observer, observation Observation) { + if observer != nil { + observer.Observe(ctx, observation) + } +} diff --git a/oidcsession/provider.go b/oidcsession/provider.go new file mode 100644 index 0000000..e6338ce --- /dev/null +++ b/oidcsession/provider.go @@ -0,0 +1,238 @@ +package oidcsession + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/devctllabs/go-libs/retry" + "golang.org/x/oauth2" +) + +// ProviderConfig configures OIDC discovery and OAuth operations. +type ProviderConfig struct { + IssuerURL string + ClientID string + ClientSecret string + RedirectURL string + Scopes []string + HTTPClient *http.Client + DiscoveryRetry retry.Policy + Observer Observer +} + +// ProviderTokens is the provider token set persisted by a session backend. +type ProviderTokens struct { + AccessToken string + RefreshToken string + AccessExpiresAt time.Time +} + +// TokenService refreshes and revokes provider token sets for a session backend. +type TokenService interface { + // Refresh exchanges refreshToken and returns a complete current token set. + Refresh(ctx context.Context, refreshToken string) (tokens ProviderTokens, err error) + // Revoke makes a best-effort provider revocation request for refreshToken. + Revoke(ctx context.Context, refreshToken string) error +} + +type providerRuntime struct { + provider *oidc.Provider + oauth oauth2.Config + revocationURL string +} + +// Provider owns discovered OIDC metadata and provider token operations. +type Provider struct { + config ProviderConfig + mu sync.RWMutex + runtime *providerRuntime + running bool +} + +// NewProvider validates local configuration without contacting the issuer. +func NewProvider(config ProviderConfig) (*Provider, error) { + if strings.TrimSpace(config.IssuerURL) == "" || strings.TrimSpace(config.ClientID) == "" || strings.TrimSpace(config.ClientSecret) == "" || strings.TrimSpace(config.RedirectURL) == "" { + return nil, errors.New("issuer URL, client ID, client secret, and redirect URL are required") + } + issuer, err := url.Parse(config.IssuerURL) + if err != nil || issuer.Scheme == "" || issuer.Host == "" { + return nil, errors.New("issuer URL must be absolute") + } + redirect, err := url.Parse(config.RedirectURL) + if err != nil || redirect.Scheme == "" || redirect.Host == "" { + return nil, errors.New("redirect URL must be absolute") + } + if !slices.Contains(config.Scopes, oidc.ScopeOpenID) { + return nil, errors.New("OIDC scopes must contain openid") + } + if config.HTTPClient == nil || config.HTTPClient.Timeout <= 0 { + return nil, errors.New("a bounded HTTP client with a positive timeout is required") + } + if config.DiscoveryRetry == nil { + return nil, errors.New("discovery retry policy is required") + } + config.Scopes = slices.Clone(config.Scopes) + return &Provider{config: config}, nil +} + +// Run discovers provider metadata with retries and then remains alive until ctx is canceled. +// It is a long-lived lifecycle task: returning after discovery would signal an unexpected stop to +// lifecycle.Run. Signing-key rotation does not require repeated discovery because go-oidc refreshes +// its remote JWKS cache when it encounters an unknown key ID. +func (provider *Provider) Run(ctx context.Context) error { + if ctx == nil { + return errors.New("context is required") + } + provider.mu.Lock() + if provider.running { + provider.mu.Unlock() + return errors.New("provider is already running") + } + provider.running = true + provider.mu.Unlock() + defer func() { + provider.mu.Lock() + provider.running = false + provider.mu.Unlock() + }() + + err := provider.discoverUntilReady(ctx) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil + } + return fmt.Errorf("discover OIDC provider: %w", err) + } + <-ctx.Done() + return nil +} + +func (provider *Provider) discoverUntilReady(ctx context.Context) error { + var duration time.Duration + return retry.Do(ctx, provider.config.DiscoveryRetry, func(attemptCtx context.Context) error { + started := time.Now() + err := provider.discover(attemptCtx) + duration = time.Since(started) + return err + }, retry.WithNotify(func(_ uint, err error, _ time.Duration) { + observe(ctx, provider.config.Observer, Observation{Operation: OperationDiscovery, Err: err, Duration: duration, Retry: true}) + })) +} + +// Check reports local discovery readiness without network I/O. +func (provider *Provider) Check(ctx context.Context) error { + if err := contextError(ctx); err != nil { + return err + } + provider.mu.RLock() + ready := provider.runtime != nil + provider.mu.RUnlock() + if !ready { + return ErrProviderUnavailable + } + return nil +} + +func (provider *Provider) discover(ctx context.Context) error { + ctx = oidc.ClientContext(ctx, provider.config.HTTPClient) + discovered, err := oidc.NewProvider(ctx, provider.config.IssuerURL) + if err != nil { + return fmt.Errorf("OIDC discovery failed: %w", err) + } + var metadata struct { + RevocationEndpoint string `json:"revocation_endpoint"` + } + if err := discovered.Claims(&metadata); err != nil { + return fmt.Errorf("decode OIDC provider metadata: %w", err) + } + runtime := &providerRuntime{ + provider: discovered, + oauth: oauth2.Config{ + ClientID: provider.config.ClientID, + ClientSecret: provider.config.ClientSecret, + RedirectURL: provider.config.RedirectURL, + Endpoint: discovered.Endpoint(), + Scopes: slices.Clone(provider.config.Scopes), + }, + revocationURL: metadata.RevocationEndpoint, + } + provider.mu.Lock() + provider.runtime = runtime + provider.mu.Unlock() + return nil +} + +func (provider *Provider) snapshot() (*providerRuntime, error) { + provider.mu.RLock() + runtime := provider.runtime + provider.mu.RUnlock() + if runtime == nil { + return nil, ErrProviderUnavailable + } + return runtime, nil +} + +// Refresh implements TokenService. +func (provider *Provider) Refresh(ctx context.Context, refreshToken string) (ProviderTokens, error) { + if strings.TrimSpace(refreshToken) == "" { + return ProviderTokens{}, ErrInvalidGrant + } + runtime, err := provider.snapshot() + if err != nil { + return ProviderTokens{}, err + } + ctx = oidc.ClientContext(ctx, provider.config.HTTPClient) + token, err := runtime.oauth.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}).Token() + if err != nil { + var retrieveError *oauth2.RetrieveError + if errors.As(err, &retrieveError) && retrieveError.ErrorCode == "invalid_grant" { + return ProviderTokens{}, ErrInvalidGrant + } + return ProviderTokens{}, fmt.Errorf("refresh provider token: %w", ErrProviderUnavailable) + } + if token.AccessToken == "" || token.Expiry.IsZero() || !token.Expiry.After(time.Now()) { + return ProviderTokens{}, fmt.Errorf("provider returned an incomplete token set: %w", ErrProviderUnavailable) + } + nextRefreshToken := token.RefreshToken + if nextRefreshToken == "" { + nextRefreshToken = refreshToken + } + return ProviderTokens{AccessToken: token.AccessToken, RefreshToken: nextRefreshToken, AccessExpiresAt: token.Expiry}, nil +} + +// Revoke implements TokenService. Providers without a revocation endpoint are treated as unsupported. +func (provider *Provider) Revoke(ctx context.Context, refreshToken string) error { + runtime, err := provider.snapshot() + if err != nil { + return err + } + if runtime.revocationURL == "" || refreshToken == "" { + return nil + } + values := url.Values{"token": {refreshToken}, "token_type_hint": {"refresh_token"}} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, runtime.revocationURL, strings.NewReader(values.Encode())) + if err != nil { + return fmt.Errorf("build revocation request: %w", err) + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.SetBasicAuth(provider.config.ClientID, provider.config.ClientSecret) + response, err := provider.config.HTTPClient.Do(request) + if err != nil { + return fmt.Errorf("revoke provider token: %w", ErrProviderUnavailable) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("revoke provider token: %w", ErrProviderUnavailable) + } + return nil +} + +var _ TokenService = (*Provider)(nil) diff --git a/oidcsession/provider_flow.go b/oidcsession/provider_flow.go new file mode 100644 index 0000000..ec16755 --- /dev/null +++ b/oidcsession/provider_flow.go @@ -0,0 +1,82 @@ +package oidcsession + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +func (provider *Provider) authorizationURL(state, nonce, verifier string, parameters url.Values) (string, error) { + runtime, err := provider.snapshot() + if err != nil { + return "", err + } + options := []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(verifier), oidc.Nonce(nonce), oauth2.AccessTypeOffline} + for key, values := range parameters { + if len(values) == 1 { + options = append(options, oauth2.SetAuthURLParam(key, values[0])) + } + } + return runtime.oauth.AuthCodeURL(state, options...), nil +} + +func (provider *Provider) exchange(ctx context.Context, code, nonce, verifier, methodID string) (ProviderTokens, VerifiedIdentity, error) { + runtime, err := provider.snapshot() + if err != nil { + return ProviderTokens{}, VerifiedIdentity{}, err + } + ctx = oidc.ClientContext(ctx, provider.config.HTTPClient) + token, err := runtime.oauth.Exchange(ctx, code, oauth2.VerifierOption(verifier)) + if err != nil { + if providerNetworkError(err) { + return ProviderTokens{}, VerifiedIdentity{}, fmt.Errorf("exchange authorization code: %w", ErrProviderUnavailable) + } + return ProviderTokens{}, VerifiedIdentity{}, errors.New("authorization code exchange failed") + } + if token.AccessToken == "" || token.RefreshToken == "" || token.Expiry.IsZero() || !token.Expiry.After(time.Now()) { + return ProviderTokens{}, VerifiedIdentity{}, errors.New("provider returned an incomplete token set") + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return ProviderTokens{}, VerifiedIdentity{}, errors.New("provider did not return an ID token") + } + verified, err := runtime.provider.Verifier(&oidc.Config{ClientID: provider.config.ClientID}).Verify(ctx, rawIDToken) + if err != nil { + if providerNetworkError(err) { + return ProviderTokens{}, VerifiedIdentity{}, fmt.Errorf("verify ID token: %w", ErrProviderUnavailable) + } + return ProviderTokens{}, VerifiedIdentity{}, errors.New("ID token verification failed") + } + if subtle.ConstantTimeCompare([]byte(verified.Nonce), []byte(nonce)) != 1 { + return ProviderTokens{}, VerifiedIdentity{}, errors.New("ID token nonce mismatch") + } + if verified.AccessTokenHash != "" { + if err := verified.VerifyAccessToken(token.AccessToken); err != nil { + return ProviderTokens{}, VerifiedIdentity{}, errors.New("ID token access token hash mismatch") + } + } + var claims json.RawMessage + if err := verified.Claims(&claims); err != nil { + return ProviderTokens{}, VerifiedIdentity{}, errors.New("decode verified ID token claims") + } + return ProviderTokens{ + AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, AccessExpiresAt: token.Expiry, + }, VerifiedIdentity{ + Issuer: verified.Issuer, Subject: verified.Subject, LoginMethodID: methodID, Claims: append(json.RawMessage(nil), claims...), + }, nil +} + +func providerNetworkError(err error) bool { + var networkError net.Error + return errors.As(err, &networkError) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || + strings.Contains(strings.ToLower(err.Error()), "fetching keys") +} diff --git a/oidcsession/provider_test.go b/oidcsession/provider_test.go new file mode 100644 index 0000000..8499c18 --- /dev/null +++ b/oidcsession/provider_test.go @@ -0,0 +1,93 @@ +package oidcsession_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/devctllabs/go-libs/oidcsession" + "github.com/devctllabs/go-libs/retry" + "github.com/stretchr/testify/require" +) + +func TestProviderStartsDegradedAndRecoversThroughRun(t *testing.T) { + t.Parallel() + var requests atomic.Int64 + available := atomic.Bool{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if !available.Load() { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + issuer := "http://" + r.Host + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer, + "authorization_endpoint": issuer + "/authorize", + "token_endpoint": issuer + "/token", + "jwks_uri": issuer + "/keys", + }) + })) + defer server.Close() + policy, err := retry.NewExponential(retry.ExponentialConfig{InitialDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond, Multiplier: 2}) + require.NoError(t, err) + observations := make(chan oidcsession.Observation, 8) + provider, err := oidcsession.NewProvider(oidcsession.ProviderConfig{ + IssuerURL: server.URL, + ClientID: "client", + ClientSecret: "secret", + RedirectURL: "https://api.example/auth/callback", + Scopes: []string{"openid"}, + HTTPClient: &http.Client{Timeout: time.Second}, + DiscoveryRetry: policy, + Observer: oidcsession.ObserverFunc(func(_ context.Context, observation oidcsession.Observation) { observations <- observation }), + }) + require.NoError(t, err) + require.ErrorIs(t, provider.Check(context.Background()), oidcsession.ErrProviderUnavailable) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- provider.Run(ctx) }() + require.Eventually(t, func() bool { return requests.Load() >= 2 }, time.Second, time.Millisecond) + available.Store(true) + require.Eventually(t, func() bool { return provider.Check(context.Background()) == nil }, time.Second, time.Millisecond) + readyRequests := requests.Load() + select { + case runErr := <-done: + require.Fail(t, "provider Run returned before cancellation", "error: %v", runErr) + case <-time.After(20 * time.Millisecond): + } + require.Equal(t, readyRequests, requests.Load(), "discovery must not repeat after readiness") + cancel() + require.NoError(t, receiveWithin(t, done, time.Second)) + + select { + case observation := <-observations: + require.Equal(t, oidcsession.OperationDiscovery, observation.Operation) + require.True(t, observation.Retry) + require.Error(t, observation.Err) + case <-time.After(time.Second): + require.Fail(t, "expected discovery retry observation") + } +} + +func TestNewProviderPerformsOnlyLocalValidation(t *testing.T) { + t.Parallel() + policy, err := retry.NewExponential(retry.ExponentialConfig{InitialDelay: time.Millisecond, MaxDelay: time.Second, Multiplier: 2}) + require.NoError(t, err) + provider, err := oidcsession.NewProvider(oidcsession.ProviderConfig{ + IssuerURL: "https://unreachable.invalid", + ClientID: "client", + ClientSecret: "secret", + RedirectURL: "https://api.example/auth/callback", + Scopes: []string{"openid"}, + HTTPClient: &http.Client{Timeout: time.Second}, + DiscoveryRetry: policy, + }) + require.NoError(t, err) + require.NotNil(t, provider) +} diff --git a/oidcsession/service.go b/oidcsession/service.go new file mode 100644 index 0000000..b9d559b --- /dev/null +++ b/oidcsession/service.go @@ -0,0 +1,168 @@ +package oidcsession + +import ( + "context" + "errors" + "fmt" + "net/url" + "time" + + "golang.org/x/oauth2" +) + +var errUnknownLoginMethod = errors.New("unknown login method") + +type sessionServiceConfig struct { + defaultReturnPath string + stateTTL time.Duration + loginAuthorizer LoginAuthorizer + observer Observer + methods map[string]url.Values +} + +type sessionService struct { + config sessionServiceConfig + provider loginProvider + sessions SessionBackend + stateEncryptor Encryptor +} + +type beginLoginCommand struct { + methodID string + returnPath string + browserBinding string +} + +// browserBinding ties an OIDC login transaction to the browser that initiated it. +type browserBinding struct { + value string + expiresAt time.Time +} + +type beginLoginResult struct { + location string + bindingToStore *browserBinding +} + +type completeLoginCommand struct { + code string + state string + browserBinding string +} + +type completeLoginResult struct { + tokens ProviderTokens + credential SessionCredential + sessionExpiresAt time.Time + returnPath string +} + +func newSessionService(config sessionServiceConfig, provider loginProvider, sessions SessionBackend, stateEncryptor Encryptor) *sessionService { + return &sessionService{config: config, provider: provider, sessions: sessions, stateEncryptor: stateEncryptor} +} + +func (service *sessionService) BeginLogin(ctx context.Context, command beginLoginCommand) (beginLoginResult, error) { + started := time.Now() + parameters, found := service.config.methods[command.methodID] + if command.methodID != "" && !found { + return beginLoginResult{}, errUnknownLoginMethod + } + result := beginLoginResult{} + binding := command.browserBinding + if binding == "" { + generated, err := randomValue(32) + if err != nil { + return result, err + } + binding = generated + result.bindingToStore = &browserBinding{value: binding, expiresAt: time.Now().Add(service.config.stateTTL)} + } + nonce, err := randomValue(32) + if err != nil { + service.observeFailure(ctx, OperationLogin, started, err) + return result, err + } + now := time.Now() + verifier := oauth2.GenerateVerifier() + state, err := service.encodeState(ctx, statePayload{ + IssuedAt: now, ExpiresAt: now.Add(service.config.stateTTL), Nonce: nonce, + PKCEVerifier: verifier, LoginMethodID: command.methodID, + ReturnPath: normalizedReturnPath(command.returnPath, service.config.defaultReturnPath), + BrowserBindingDigest: bindingDigest(binding), + }) + if err != nil { + service.observeFailure(ctx, OperationLogin, started, err) + return result, err + } + result.location, err = service.provider.authorizationURL(state, nonce, verifier, parameters) + if err != nil { + service.observeFailure(ctx, OperationLogin, started, err) + return result, err + } + return result, nil +} + +func (service *sessionService) CompleteLogin(ctx context.Context, command completeLoginCommand) (completeLoginResult, error) { + started := time.Now() + state, err := service.decodeState(ctx, command.state, command.browserBinding) + if err != nil || command.code == "" { + return completeLoginResult{}, errors.New("invalid login callback") + } + tokens, identity, err := service.provider.exchange(ctx, command.code, state.Nonce, state.PKCEVerifier, state.LoginMethodID) + if err != nil { + service.observeFailure(ctx, OperationCallback, started, err) + return completeLoginResult{}, err + } + if service.config.loginAuthorizer != nil { + if err := service.config.loginAuthorizer(ctx, identity); err != nil { + if !errors.Is(err, ErrLoginDenied) { + service.observeFailure(ctx, OperationCallback, started, err) + } + return completeLoginResult{}, fmt.Errorf("%w: %v", ErrLoginDenied, err) + } + } + created, err := service.sessions.Create(ctx, CreateSessionParams(tokens)) + if err != nil { + service.observeFailure(ctx, OperationCallback, started, err) + return completeLoginResult{}, err + } + return completeLoginResult{ + tokens: tokens, credential: created.Credential, sessionExpiresAt: created.SessionExpiresAt, returnPath: state.ReturnPath, + }, nil +} + +func (service *sessionService) Refresh(ctx context.Context, credential SessionCredential) (RefreshSessionResult, error) { + started := time.Now() + refreshed, err := service.sessions.Refresh(ctx, credential) + if err == nil { + return refreshed, nil + } + if errors.Is(err, ErrInvalidGrant) || errors.Is(err, ErrInvalidSession) { + _ = service.sessions.Revoke(ctx, credential) + return RefreshSessionResult{}, err + } + service.observeFailure(ctx, OperationRefresh, started, err) + return RefreshSessionResult{}, err +} + +func (service *sessionService) Logout(ctx context.Context, credential SessionCredential) { + started := time.Now() + if err := service.sessions.Revoke(ctx, credential); err != nil && !errors.Is(err, ErrInvalidSession) { + service.observeFailure(ctx, OperationLogout, started, err) + } +} + +func (service *sessionService) Session(ctx context.Context, credential SessionCredential) (SessionStatus, error) { + started := time.Now() + status, err := service.sessions.Status(ctx, credential) + if err != nil && !errors.Is(err, ErrInvalidSession) { + service.observeFailure(ctx, OperationSession, started, err) + } + return status, err +} + +func (service *sessionService) observeFailure(ctx context.Context, operation Operation, started time.Time, err error) { + observe(ctx, service.config.observer, Observation{Operation: operation, Err: err, Duration: time.Since(started)}) +} + +var _ sessionApplication = (*sessionService)(nil) diff --git a/oidcsession/service_test.go b/oidcsession/service_test.go new file mode 100644 index 0000000..c87ca5a --- /dev/null +++ b/oidcsession/service_test.go @@ -0,0 +1,84 @@ +package oidcsession + +import ( + "context" + "errors" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestSessionServiceRefreshRevokesInvalidSession(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + provider := NewMockloginProvider(controller) + sessions := NewMockSessionBackend(controller) + encryptor := NewMockEncryptor(controller) + credential := SessionCredential("credential") + sessions.EXPECT().Refresh(gomock.Any(), credential).Return(RefreshSessionResult{}, ErrInvalidGrant) + sessions.EXPECT().Revoke(gomock.Any(), credential).Return(nil) + service := newSessionService(sessionServiceConfig{}, provider, sessions, encryptor) + + _, err := service.Refresh(context.Background(), credential) + + require.ErrorIs(t, err, ErrInvalidGrant) +} + +func TestSessionServiceRejectsUnknownLoginMethodBeforeCreatingState(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + service := newSessionService(sessionServiceConfig{methods: map[string]url.Values{"": nil}}, + NewMockloginProvider(controller), NewMockSessionBackend(controller), NewMockEncryptor(controller)) + + _, err := service.BeginLogin(context.Background(), beginLoginCommand{methodID: "missing"}) + + require.ErrorIs(t, err, errUnknownLoginMethod) +} + +func TestSessionServiceRejectsStateFromAnotherBrowser(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + provider := NewMockloginProvider(controller) + var state string + provider.EXPECT().authorizationURL(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(encodedState string, _ string, _ string, _ url.Values) (string, error) { + state = encodedState + return "https://issuer.example/authorize", nil + }, + ) + service := newSessionService(sessionServiceConfig{ + defaultReturnPath: "/", stateTTL: time.Minute, methods: map[string]url.Values{"": nil}, + }, provider, NewMockSessionBackend(controller), InsecureNoopEncryptor()) + + started, err := service.BeginLogin(context.Background(), beginLoginCommand{browserBinding: "browser-a"}) + require.NoError(t, err) + require.Nil(t, started.bindingToStore) + require.NotEmpty(t, state) + + _, err = service.CompleteLogin(context.Background(), completeLoginCommand{ + code: "authorization-code", state: state, browserBinding: "browser-b", + }) + + require.Error(t, err) +} + +func TestSessionServiceObservesSessionBackendFailure(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + sessions := NewMockSessionBackend(controller) + backendErr := errors.New("backend unavailable") + sessions.EXPECT().Status(gomock.Any(), SessionCredential("credential")).Return(SessionStatus{}, backendErr) + var observation Observation + service := newSessionService(sessionServiceConfig{observer: ObserverFunc(func(_ context.Context, observed Observation) { + observation = observed + })}, NewMockloginProvider(controller), sessions, NewMockEncryptor(controller)) + + _, err := service.Session(context.Background(), SessionCredential("credential")) + + require.ErrorIs(t, err, backendErr) + require.Equal(t, OperationSession, observation.Operation) + require.ErrorIs(t, observation.Err, backendErr) +} diff --git a/oidcsession/state.go b/oidcsession/state.go new file mode 100644 index 0000000..2ffccd3 --- /dev/null +++ b/oidcsession/state.go @@ -0,0 +1,126 @@ +package oidcsession + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +type statePayload struct { + IssuedAt time.Time `json:"iat"` + ExpiresAt time.Time `json:"exp"` + Nonce string `json:"nonce"` + PKCEVerifier string `json:"pkce"` + LoginMethodID string `json:"method,omitempty"` + ReturnPath string `json:"return"` + BrowserBindingDigest string `json:"binding"` +} + +func (service *sessionService) encodeState(ctx context.Context, state statePayload) (string, error) { + plaintext, err := json.Marshal(state) + if err != nil { + return "", err + } + ciphertext, err := service.stateEncryptor.Encrypt(ctx, plaintext) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(ciphertext), nil +} + +func (service *sessionService) decodeState(ctx context.Context, raw string, binding string) (statePayload, error) { + if raw == "" || binding == "" { + return statePayload{}, errors.New("state or browser binding is missing") + } + ciphertext, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return statePayload{}, errors.New("state is malformed") + } + plaintext, err := service.stateEncryptor.Decrypt(ctx, ciphertext) + if err != nil { + return statePayload{}, errors.New("state cannot be authenticated") + } + var state statePayload + if err := json.Unmarshal(plaintext, &state); err != nil { + return statePayload{}, errors.New("state is malformed") + } + now := time.Now() + if state.IssuedAt.After(now) || !state.ExpiresAt.After(now) || state.ExpiresAt.Sub(state.IssuedAt) > service.config.stateTTL || + state.Nonce == "" || state.PKCEVerifier == "" || !validReturnPath(state.ReturnPath) || + subtle.ConstantTimeCompare([]byte(state.BrowserBindingDigest), []byte(bindingDigest(binding))) != 1 { + return statePayload{}, errors.New("state is invalid or expired") + } + if state.LoginMethodID != "" { + if _, found := service.config.methods[state.LoginMethodID]; !found { + return statePayload{}, errors.New("state login method is unknown") + } + } + return state, nil +} + +func cloneLoginMethods(methods []LoginMethod) (map[string]url.Values, error) { + result := make(map[string]url.Values, len(methods)+1) + result[""] = nil + for _, method := range methods { + if strings.TrimSpace(method.ID) == "" { + return nil, errors.New("login method ID is required") + } + if _, duplicate := result[method.ID]; duplicate { + return nil, fmt.Errorf("duplicate login method %q", method.ID) + } + parameters := make(url.Values, len(method.AuthorizationParameters)) + for key, values := range method.AuthorizationParameters { + if reservedAuthorizationParameter(key) || len(values) != 1 || values[0] == "" { + return nil, fmt.Errorf("login method %q has invalid authorization parameter %q", method.ID, key) + } + parameters[key] = []string{values[0]} + } + result[method.ID] = parameters + } + return result, nil +} + +func reservedAuthorizationParameter(key string) bool { + switch strings.ToLower(key) { + case "state", "nonce", "code_challenge", "code_challenge_method", "redirect_uri", "client_id", "response_type", "scope": + return true + default: + return false + } +} + +func validReturnPath(value string) bool { + if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") { + return false + } + parsed, err := url.Parse(value) + return err == nil && !parsed.IsAbs() && parsed.Host == "" && parsed.RawQuery == "" && parsed.Fragment == "" +} + +func normalizedReturnPath(value string, fallback string) string { + if validReturnPath(value) { + return value + } + return fallback +} + +func randomValue(bytes int) (string, error) { + value := make([]byte, bytes) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func bindingDigest(binding string) string { + digest := sha256.Sum256([]byte(binding)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} diff --git a/oidcsession/test_helpers_test.go b/oidcsession/test_helpers_test.go new file mode 100644 index 0000000..f9f2936 --- /dev/null +++ b/oidcsession/test_helpers_test.go @@ -0,0 +1,22 @@ +package oidcsession_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func receiveWithin[T any](t *testing.T, values <-chan T, timeout time.Duration) T { + t.Helper() + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case value := <-values: + return value + case <-timer.C: + require.FailNow(t, "timed out waiting for channel value") + var zero T + return zero + } +} diff --git a/oidcsessionredis/README.md b/oidcsessionredis/README.md new file mode 100644 index 0000000..50fcafe --- /dev/null +++ b/oidcsessionredis/README.md @@ -0,0 +1,18 @@ +# oidcsessionredis + +`oidcsessionredis` implements `oidcsession.SessionBackend` with a caller-owned +`redis.UniversalClient` and requires Redis 6.2 or newer. It generates a 256-bit opaque session +credential and addresses the session by its SHA-256 digest, so the credential itself is never +stored. The complete provider token set is encrypted with the supplied `oidcsession.Encryptor`. + +Redis TTL is the earlier of idle and absolute expiry. `Status` never extends idle lifetime; +successful `Refresh` calls do, including calls that reuse a still-valid cached access token. When a +provider refresh is required, a single-key Lua lease coordinates replicas and atomically commits +the rotated token set. Provider revocation after atomic local deletion is best effort. + +The 15-second refresh lease provides best-effort single-flight behavior. A provider call that +outlives the lease may be repeated by another replica, while lease-owner fencing still prevents a +stale Redis commit. + +A crash after the provider rotates a refresh token but before Redis commit can invalidate that +session. The backend deliberately fails closed and requires login again. diff --git a/oidcsessionredis/backend.go b/oidcsessionredis/backend.go new file mode 100644 index 0000000..af4ad5b --- /dev/null +++ b/oidcsessionredis/backend.go @@ -0,0 +1,267 @@ +package oidcsessionredis + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/devctllabs/go-libs/oidcsession" + "github.com/redis/go-redis/v9" +) + +const ( + defaultRefreshWindow = time.Minute + refreshLeaseDuration = 15 * time.Second + refreshPollInterval = 20 * time.Millisecond +) + +// BackendConfig controls Redis key ownership and idle/absolute session policy. +type BackendConfig struct { + KeyPrefix string + IdleTimeout time.Duration + AbsoluteLifetime time.Duration + RefreshWindow time.Duration + Observer oidcsession.Observer +} + +// Backend persists encrypted provider tokens and coordinates refresh across replicas. +// The Redis client remains caller-owned and is never closed by Backend. +type Backend struct { + config BackendConfig + tokens oidcsession.TokenService + encryptor oidcsession.Encryptor + store sessionStore +} + +// NewBackend validates policy and constructs a Redis session backend without performing I/O. +func NewBackend(client redis.UniversalClient, config BackendConfig, tokens oidcsession.TokenService, encryptor oidcsession.Encryptor) (*Backend, error) { + if client == nil || tokens == nil || encryptor == nil { + return nil, errors.New("redis client, token service, and encryptor are required") + } + if err := validateConfig(&config); err != nil { + return nil, err + } + return newBackend(config, tokens, encryptor, newRedisSessionStore(client, config.KeyPrefix)), nil +} + +func newBackend(config BackendConfig, tokens oidcsession.TokenService, encryptor oidcsession.Encryptor, store sessionStore) *Backend { + return &Backend{config: config, tokens: tokens, encryptor: encryptor, store: store} +} + +func validateConfig(config *BackendConfig) error { + if strings.TrimSpace(config.KeyPrefix) == "" { + return errors.New("key prefix is required") + } + if config.IdleTimeout <= 0 || config.AbsoluteLifetime <= 0 { + return errors.New("idle timeout and absolute lifetime must be positive") + } + if config.IdleTimeout > config.AbsoluteLifetime { + return errors.New("idle timeout must not exceed absolute lifetime") + } + if config.RefreshWindow < 0 { + return errors.New("refresh window must not be negative") + } + if config.RefreshWindow == 0 { + config.RefreshWindow = defaultRefreshWindow + } + return nil +} + +// Create implements oidcsession.SessionBackend. +func (backend *Backend) Create(ctx context.Context, params oidcsession.CreateSessionParams) (oidcsession.CreateSessionResult, error) { + if err := validateContext(ctx); err != nil { + return oidcsession.CreateSessionResult{}, err + } + if params.AccessToken == "" || params.RefreshToken == "" || params.AccessExpiresAt.IsZero() || !params.AccessExpiresAt.After(time.Now()) { + return oidcsession.CreateSessionResult{}, errors.New("complete future-dated provider tokens are required") + } + payload, err := backend.encryptTokens(ctx, tokenPayload(params)) + if err != nil { + return oidcsession.CreateSessionResult{}, fmt.Errorf("encrypt provider tokens: %w", err) + } + for range 3 { + secret, err := randomEncoded(32) + if err != nil { + return oidcsession.CreateSessionResult{}, err + } + credential := oidcsession.SessionCredential(secret) + key, err := parseCredential(credential) + if err != nil { + return oidcsession.CreateSessionResult{}, err + } + now := time.Now() + absoluteExpiry := now.Add(backend.config.AbsoluteLifetime) + sessionExpiry := minTime(now.Add(backend.config.IdleTimeout), absoluteExpiry) + record := storedRecord{ + Format: recordFormat, Payload: payload, LastRefreshAt: now.UnixMilli(), + AccessExpiresAt: params.AccessExpiresAt.UnixMilli(), AbsoluteExpiresAt: absoluteExpiry.UnixMilli(), + } + created, err := backend.store.Create(ctx, key, record, sessionExpiry) + if err != nil { + return oidcsession.CreateSessionResult{}, err + } + if created { + return oidcsession.CreateSessionResult{Credential: credential, SessionExpiresAt: sessionExpiry}, nil + } + } + return oidcsession.CreateSessionResult{}, errors.New("generate a unique session credential") +} + +// Status implements oidcsession.SessionBackend without extending the idle timeout. +func (backend *Backend) Status(ctx context.Context, credential oidcsession.SessionCredential) (oidcsession.SessionStatus, error) { + key, err := parseCredential(credential) + if err != nil { + return oidcsession.SessionStatus{}, oidcsession.ErrInvalidSession + } + record, err := backend.store.Status(ctx, key) + if err != nil { + return oidcsession.SessionStatus{}, err + } + return oidcsession.SessionStatus{ + AccessExpiresAt: time.UnixMilli(record.AccessExpiresAt), SessionExpiresAt: backend.sessionExpiry(record), + }, nil +} + +// Refresh implements oidcsession.SessionBackend and extends idle lifetime after every success. +func (backend *Backend) Refresh(ctx context.Context, credential oidcsession.SessionCredential) (oidcsession.RefreshSessionResult, error) { + key, err := parseCredential(credential) + if err != nil { + return oidcsession.RefreshSessionResult{}, oidcsession.ErrInvalidSession + } + owner, err := randomEncoded(16) + if err != nil { + return oidcsession.RefreshSessionResult{}, err + } + for { + state, record, err := backend.store.GateRefresh(ctx, refreshGateParams{ + key: key, now: time.Now(), refreshWindow: backend.config.RefreshWindow, + idleTimeout: backend.config.IdleTimeout, owner: owner, leaseDuration: refreshLeaseDuration, + }) + if err != nil { + return oidcsession.RefreshSessionResult{}, err + } + switch state { + case refreshReady: + return backend.refreshResult(ctx, record) + case refreshWaiting: + if err := wait(ctx, refreshPollInterval); err != nil { + return oidcsession.RefreshSessionResult{}, err + } + case refreshOwned: + return backend.refreshOwned(ctx, key, owner, record) + default: + return oidcsession.RefreshSessionResult{}, errors.New("redis returned an unknown refresh state") + } + } +} + +func (backend *Backend) refreshOwned(ctx context.Context, key sessionKey, owner string, record storedRecord) (oidcsession.RefreshSessionResult, error) { + payload, err := backend.decryptTokens(ctx, record.Payload) + if err != nil { + backend.releaseLease(ctx, key, owner) + return oidcsession.RefreshSessionResult{}, fmt.Errorf("decrypt provider tokens: %w", err) + } + started := time.Now() + tokens, err := backend.tokens.Refresh(ctx, payload.RefreshToken) + if err != nil { + backend.releaseLease(ctx, key, owner) + if !errors.Is(err, oidcsession.ErrInvalidGrant) { + backend.observe(ctx, oidcsession.OperationRefresh, started, err) + } + return oidcsession.RefreshSessionResult{}, err + } + if tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.AccessExpiresAt.IsZero() || !tokens.AccessExpiresAt.After(time.Now()) { + backend.releaseLease(ctx, key, owner) + return oidcsession.RefreshSessionResult{}, errors.New("token service returned an incomplete token set") + } + encrypted, err := backend.encryptTokens(ctx, tokenPayload(tokens)) + if err != nil { + backend.releaseLease(ctx, key, owner) + return oidcsession.RefreshSessionResult{}, err + } + committedAt := time.Now() + if err := backend.store.CommitRefresh(ctx, refreshCommitParams{ + key: key, owner: owner, payload: encrypted, accessExpiresAt: tokens.AccessExpiresAt, + now: committedAt, idleTimeout: backend.config.IdleTimeout, + }); err != nil { + return oidcsession.RefreshSessionResult{}, err + } + record.LastRefreshAt = committedAt.UnixMilli() + return oidcsession.RefreshSessionResult{ + AccessToken: tokens.AccessToken, AccessExpiresAt: tokens.AccessExpiresAt, SessionExpiresAt: backend.sessionExpiry(record), + }, nil +} + +func (backend *Backend) refreshResult(ctx context.Context, record storedRecord) (oidcsession.RefreshSessionResult, error) { + payload, err := backend.decryptTokens(ctx, record.Payload) + if err != nil { + return oidcsession.RefreshSessionResult{}, fmt.Errorf("decrypt provider tokens: %w", err) + } + return oidcsession.RefreshSessionResult{ + AccessToken: payload.AccessToken, AccessExpiresAt: payload.AccessExpiresAt, SessionExpiresAt: backend.sessionExpiry(record), + }, nil +} + +// Revoke implements oidcsession.SessionBackend. Provider revocation is best effort after atomic local deletion. +func (backend *Backend) Revoke(ctx context.Context, credential oidcsession.SessionCredential) error { + key, err := parseCredential(credential) + if err != nil { + return oidcsession.ErrInvalidSession + } + rawPayload, err := backend.store.Revoke(ctx, key) + if err != nil { + return err + } + payload, err := backend.decryptTokens(ctx, rawPayload) + if err != nil { + return fmt.Errorf("decrypt revoked provider tokens: %w", err) + } + started := time.Now() + if err := backend.tokens.Revoke(ctx, payload.RefreshToken); err != nil { + backend.observe(ctx, oidcsession.OperationLogout, started, err) + } + return nil +} + +func (backend *Backend) sessionExpiry(record storedRecord) time.Time { + return minTime(time.UnixMilli(record.LastRefreshAt).Add(backend.config.IdleTimeout), time.UnixMilli(record.AbsoluteExpiresAt)) +} + +func (backend *Backend) releaseLease(ctx context.Context, key sessionKey, owner string) { + _ = backend.store.ReleaseRefresh(ctx, key, owner) +} + +func (backend *Backend) observe(ctx context.Context, operation oidcsession.Operation, started time.Time, err error) { + if backend.config.Observer != nil { + backend.config.Observer.Observe(ctx, oidcsession.Observation{Operation: operation, Err: err, Duration: time.Since(started)}) + } +} + +func validateContext(ctx context.Context) error { + if ctx == nil { + return errors.New("context is required") + } + return ctx.Err() +} + +func wait(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func minTime(first time.Time, second time.Time) time.Time { + if first.Before(second) { + return first + } + return second +} + +var _ oidcsession.SessionBackend = (*Backend)(nil) diff --git a/oidcsessionredis/backend_service_test.go b/oidcsessionredis/backend_service_test.go new file mode 100644 index 0000000..c69e8ca --- /dev/null +++ b/oidcsessionredis/backend_service_test.go @@ -0,0 +1,106 @@ +package oidcsessionredis + +import ( + "context" + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/devctllabs/go-libs/oidcsession" + "github.com/devctllabs/go-libs/oidcsession/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestBackendStatusUsesStoreContract(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + store := NewMocksessionStore(controller) + now := time.Now() + key, credential := validCredential() + store.EXPECT().Status(gomock.Any(), key).Return(storedRecord{ + Format: recordFormat, Payload: "payload", AccessExpiresAt: now.Add(time.Minute).UnixMilli(), + LastRefreshAt: now.UnixMilli(), AbsoluteExpiresAt: now.Add(time.Hour).UnixMilli(), + }, nil) + backend := newBackend(BackendConfig{IdleTimeout: 10 * time.Minute, AbsoluteLifetime: time.Hour}, + mocks.NewMockTokenService(controller), mocks.NewMockEncryptor(controller), store) + + status, err := backend.Status(context.Background(), credential) + + require.NoError(t, err) + require.WithinDuration(t, now.Add(time.Minute), status.AccessExpiresAt, time.Millisecond) + require.WithinDuration(t, now.Add(10*time.Minute), status.SessionExpiresAt, time.Millisecond) +} + +func TestBackendRefreshReturnsUsableStoredTokenWithoutProviderRefresh(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + store := NewMocksessionStore(controller) + tokens := mocks.NewMockTokenService(controller) + encryptor := mocks.NewMockEncryptor(controller) + key, credential := validCredential() + now := time.Now() + payload := tokenPayload{AccessToken: "access", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Minute)} + plaintext, err := json.Marshal(payload) + require.NoError(t, err) + ciphertext := []byte("encrypted") + record := storedRecord{ + Format: recordFormat, Payload: base64.RawStdEncoding.EncodeToString(ciphertext), LastRefreshAt: now.UnixMilli(), + AccessExpiresAt: payload.AccessExpiresAt.UnixMilli(), AbsoluteExpiresAt: now.Add(time.Hour).UnixMilli(), + } + var gate refreshGateParams + store.EXPECT().GateRefresh(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, params refreshGateParams) (refreshState, storedRecord, error) { + gate = params + return refreshReady, record, nil + }) + encryptor.EXPECT().Decrypt(gomock.Any(), ciphertext).Return(plaintext, nil) + backend := newBackend(BackendConfig{IdleTimeout: 10 * time.Minute, AbsoluteLifetime: time.Hour, RefreshWindow: time.Minute}, tokens, encryptor, store) + + result, err := backend.Refresh(context.Background(), credential) + + require.NoError(t, err) + require.Equal(t, "access", result.AccessToken) + require.Equal(t, key, gate.key) + require.NotEmpty(t, gate.owner) + require.Equal(t, time.Minute, gate.refreshWindow) +} + +func TestBackendRefreshReleasesLeaseAfterInvalidGrant(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + store := NewMocksessionStore(controller) + tokens := mocks.NewMockTokenService(controller) + encryptor := mocks.NewMockEncryptor(controller) + key, credential := validCredential() + payload := tokenPayload{AccessToken: "access", RefreshToken: "refresh", AccessExpiresAt: time.Now().Add(time.Minute)} + plaintext, err := json.Marshal(payload) + require.NoError(t, err) + ciphertext := []byte("encrypted") + record := storedRecord{Format: recordFormat, Payload: base64.RawStdEncoding.EncodeToString(ciphertext)} + var leaseOwner string + store.EXPECT().GateRefresh(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, params refreshGateParams) (refreshState, storedRecord, error) { + leaseOwner = params.owner + return refreshOwned, record, nil + }) + encryptor.EXPECT().Decrypt(gomock.Any(), ciphertext).Return(plaintext, nil) + tokens.EXPECT().Refresh(gomock.Any(), "refresh").Return(oidcsession.ProviderTokens{}, oidcsession.ErrInvalidGrant) + var releasedOwner string + store.EXPECT().ReleaseRefresh(gomock.Any(), key, gomock.Any()).DoAndReturn(func(_ context.Context, _ sessionKey, owner string) error { + releasedOwner = owner + return nil + }) + backend := newBackend(BackendConfig{IdleTimeout: 10 * time.Minute, AbsoluteLifetime: time.Hour, RefreshWindow: time.Minute}, tokens, encryptor, store) + + _, err = backend.Refresh(context.Background(), credential) + + require.ErrorIs(t, err, oidcsession.ErrInvalidGrant) + require.NotEmpty(t, leaseOwner) + require.Equal(t, leaseOwner, releasedOwner) +} + +func validCredential() (sessionKey, oidcsession.SessionCredential) { + secret := make([]byte, 32) + credential := oidcsession.SessionCredential(base64.RawURLEncoding.EncodeToString(secret)) + return sessionKey(encodedDigest(secret)), credential +} diff --git a/oidcsessionredis/backend_test.go b/oidcsessionredis/backend_test.go new file mode 100644 index 0000000..27d114e --- /dev/null +++ b/oidcsessionredis/backend_test.go @@ -0,0 +1,34 @@ +package oidcsessionredis_test + +import ( + "testing" + "time" + + "github.com/devctllabs/go-libs/oidcsession/mocks" + "github.com/devctllabs/go-libs/oidcsessionredis" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestNewBackendValidatesLifetimePolicy(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}) + t.Cleanup(func() { require.NoError(t, client.Close()) }) + tokens := mocks.NewMockTokenService(controller) + encryptor := mocks.NewMockEncryptor(controller) + + tests := []oidcsessionredis.BackendConfig{ + {}, + {KeyPrefix: "sessions:", IdleTimeout: time.Hour}, + {KeyPrefix: "sessions:", AbsoluteLifetime: time.Hour}, + {KeyPrefix: "sessions:", IdleTimeout: 2 * time.Hour, AbsoluteLifetime: time.Hour}, + {KeyPrefix: "sessions:", IdleTimeout: time.Hour, AbsoluteLifetime: 2 * time.Hour, RefreshWindow: -time.Second}, + } + for _, config := range tests { + backend, err := oidcsessionredis.NewBackend(client, config, tokens, encryptor) + require.Nil(t, backend) + require.Error(t, err) + } +} diff --git a/oidcsessionredis/codec.go b/oidcsessionredis/codec.go new file mode 100644 index 0000000..38a096b --- /dev/null +++ b/oidcsessionredis/codec.go @@ -0,0 +1,43 @@ +package oidcsessionredis + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "time" +) + +type tokenPayload struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccessExpiresAt time.Time `json:"access_expires_at"` +} + +func (backend *Backend) encryptTokens(ctx context.Context, payload tokenPayload) (string, error) { + plaintext, err := json.Marshal(payload) //nolint:gosec // The secret-bearing JSON is immediately encrypted and never persisted as plaintext. + if err != nil { + return "", err + } + ciphertext, err := backend.encryptor.Encrypt(ctx, plaintext) + if err != nil { + return "", err + } + return base64.RawStdEncoding.EncodeToString(ciphertext), nil +} + +func (backend *Backend) decryptTokens(ctx context.Context, encoded string) (tokenPayload, error) { + ciphertext, err := base64.RawStdEncoding.DecodeString(encoded) + if err != nil { + return tokenPayload{}, errors.New("stored token payload is malformed") + } + plaintext, err := backend.encryptor.Decrypt(ctx, ciphertext) + if err != nil { + return tokenPayload{}, err + } + var payload tokenPayload + if err := json.Unmarshal(plaintext, &payload); err != nil { + return tokenPayload{}, errors.New("stored token payload is malformed") + } + return payload, nil +} diff --git a/oidcsessionredis/credential.go b/oidcsessionredis/credential.go new file mode 100644 index 0000000..d7bca00 --- /dev/null +++ b/oidcsessionredis/credential.go @@ -0,0 +1,32 @@ +package oidcsessionredis + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + + "github.com/devctllabs/go-libs/oidcsession" +) + +type sessionKey string + +func parseCredential(credential oidcsession.SessionCredential) (sessionKey, error) { + secret, err := base64.RawURLEncoding.DecodeString(string(credential)) + if err != nil || len(secret) != 32 { + return "", oidcsession.ErrInvalidSession + } + return sessionKey(encodedDigest(secret)), nil +} + +func encodedDigest(value []byte) string { + digest := sha256.Sum256(value) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} + +func randomEncoded(size int) (string, error) { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} diff --git a/oidcsessionredis/doc.go b/oidcsessionredis/doc.go new file mode 100644 index 0000000..76b653c --- /dev/null +++ b/oidcsessionredis/doc.go @@ -0,0 +1,6 @@ +// Package oidcsessionredis stores encrypted OIDC token sets behind opaque session credentials. +// +// Refresh uses a bounded Redis lease so replicas normally perform one provider refresh. A process +// crash after provider-side refresh-token rotation but before Redis commit fails the session closed +// and requires login again. +package oidcsessionredis diff --git a/oidcsessionredis/go.mod b/oidcsessionredis/go.mod new file mode 100644 index 0000000..a581f5c --- /dev/null +++ b/oidcsessionredis/go.mod @@ -0,0 +1,70 @@ +module github.com/devctllabs/go-libs/oidcsessionredis + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/oidcsession v0.1.0 + github.com/redis/go-redis/v9 v9.21.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.44.0 + go.uber.org/mock v0.6.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/coreos/go-oidc/v3 v3.20.0 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/devctllabs/go-libs/retry v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.47.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool go.uber.org/mock/mockgen diff --git a/oidcsessionredis/go.sum b/oidcsessionredis/go.sum new file mode 100644 index 0000000..843e370 --- /dev/null +++ b/oidcsessionredis/go.sum @@ -0,0 +1,157 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/oidcsessionredis/integration_test.go b/oidcsessionredis/integration_test.go new file mode 100644 index 0000000..0540bb2 --- /dev/null +++ b/oidcsessionredis/integration_test.go @@ -0,0 +1,221 @@ +//go:build integration + +package oidcsessionredis + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "testing" + "time" + + "github.com/devctllabs/go-libs/oidcsession" + "github.com/devctllabs/go-libs/oidcsession/mocks" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + tcwait "github.com/testcontainers/testcontainers-go/wait" + "go.uber.org/mock/gomock" +) + +func TestRedisBackendEncryptsTokensAndCoordinatesRefreshAcrossReplicas(t *testing.T) { + t.Parallel() + ctx := context.Background() + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "redis:7.4-alpine", ExposedPorts: []string{"6379/tcp"}, WaitingFor: tcwait.ForListeningPort("6379/tcp"), + }, + Started: true, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, testcontainers.TerminateContainer(container)) }) + host, err := container.Host(ctx) + require.NoError(t, err) + port, err := container.MappedPort(ctx, "6379/tcp") + require.NoError(t, err) + client := redis.NewClient(&redis.Options{Addr: fmt.Sprintf("%s:%s", host, port.Port())}) + t.Cleanup(func() { require.NoError(t, client.Close()) }) + assertRedisStorePrimitives(ctx, t, client) + + controller := gomock.NewController(t) + tokens := mocks.NewMockTokenService(controller) + key := make([]byte, 32) + _, err = rand.Read(key) + require.NoError(t, err) + encryptor, err := oidcsession.NewAESGCMEncryptor(key) + require.NoError(t, err) + config := BackendConfig{ + KeyPrefix: "test:sessions:", IdleTimeout: 2 * time.Second, AbsoluteLifetime: time.Minute, RefreshWindow: time.Second, + } + first, err := NewBackend(client, config, tokens, encryptor) + require.NoError(t, err) + second, err := NewBackend(client, config, tokens, encryptor) + require.NoError(t, err) + created, err := first.Create(ctx, oidcsession.CreateSessionParams{ + AccessToken: "sensitive-access", RefreshToken: "sensitive-refresh", AccessExpiresAt: time.Now().Add(50 * time.Millisecond), + }) + require.NoError(t, err) + credentialBytes, err := base64.RawURLEncoding.DecodeString(string(created.Credential)) + require.NoError(t, err) + require.Len(t, credentialBytes, 32) + + keys, err := client.Keys(ctx, "test:sessions:*").Result() + require.NoError(t, err) + digest := sha256.Sum256(credentialBytes) + require.Equal(t, []string{"test:sessions:" + base64.RawURLEncoding.EncodeToString(digest[:])}, keys) + stored, err := client.Get(ctx, keys[0]).Result() + require.NoError(t, err) + require.NotContains(t, fmt.Sprint(stored), "sensitive-access") + require.NotContains(t, fmt.Sprint(stored), "sensitive-refresh") + + time.Sleep(60 * time.Millisecond) + newExpiry := time.Now().Add(time.Minute) + tokens.EXPECT().Refresh(gomock.Any(), "sensitive-refresh").DoAndReturn(func(context.Context, string) (oidcsession.ProviderTokens, error) { + time.Sleep(75 * time.Millisecond) + return oidcsession.ProviderTokens{AccessToken: "new-access", RefreshToken: "new-refresh", AccessExpiresAt: newExpiry}, nil + }).Times(1) + + results := make([]oidcsession.RefreshSessionResult, 2) + errorsByCall := make([]error, 2) + type refreshCall struct { + index int + result oidcsession.RefreshSessionResult + err error + } + refreshCtx, cancelRefresh := context.WithTimeout(ctx, 5*time.Second) + defer cancelRefresh() + calls := make(chan refreshCall, 2) + for index, backend := range []*Backend{first, second} { + go func(index int, backend *Backend) { + result, err := backend.Refresh(refreshCtx, created.Credential) + calls <- refreshCall{index: index, result: result, err: err} + }(index, backend) + } + for range 2 { + select { + case call := <-calls: + results[call.index] = call.result + errorsByCall[call.index] = call.err + case <-refreshCtx.Done(): + require.FailNow(t, "timed out waiting for Redis refresh workers", "error: %v", refreshCtx.Err()) + } + } + for index := range results { + require.NoError(t, errorsByCall[index]) + require.Equal(t, "new-access", results[index].AccessToken) + require.WithinDuration(t, newExpiry, results[index].AccessExpiresAt, time.Millisecond) + require.WithinDuration(t, time.Now().Add(2*time.Second), results[index].SessionExpiresAt, 250*time.Millisecond) + } + + tokens.EXPECT().Revoke(gomock.Any(), "new-refresh").Return(nil) + require.NoError(t, second.Revoke(ctx, created.Credential)) + _, err = first.Status(ctx, created.Credential) + require.ErrorIs(t, err, oidcsession.ErrInvalidSession) +} + +func assertRedisStorePrimitives(ctx context.Context, t *testing.T, client redis.UniversalClient) { + t.Helper() + store := newRedisSessionStore(client, "test:native:") + record := storedRecord{Format: recordFormat, Payload: "encrypted"} + key := sessionKey("session") + expiresAt := time.Now().Add(time.Minute) + + created, err := store.Create(ctx, key, record, expiresAt) + require.NoError(t, err) + require.True(t, created) + created, err = store.Create(ctx, key, record, expiresAt) + require.NoError(t, err) + require.False(t, created) + ttl, err := client.PTTL(ctx, "test:native:session").Result() + require.NoError(t, err) + require.Positive(t, ttl) + require.LessOrEqual(t, ttl, time.Minute+100*time.Millisecond) + + loaded, err := store.Status(ctx, key) + require.NoError(t, err) + require.Equal(t, record, loaded) + _, err = store.Status(ctx, sessionKey("missing")) + require.ErrorIs(t, err, oidcsession.ErrInvalidSession) + + assertRedisRefreshCoordination(ctx, t, store) + + malformed := sessionKey("malformed") + malformedKey := store.key(malformed) + require.NoError(t, client.Set(ctx, malformedKey, "not-json", time.Minute).Err()) + _, err = store.Revoke(ctx, malformed) + require.Error(t, err) + require.ErrorIs(t, client.Get(ctx, malformedKey).Err(), redis.Nil) +} + +func assertRedisRefreshCoordination(ctx context.Context, t *testing.T, store *redisSessionStore) { + t.Helper() + now := time.Now().Truncate(time.Millisecond) + key := sessionKey("refresh") + record := storedRecord{ + Format: recordFormat, Payload: "old", LastRefreshAt: now.UnixMilli(), + AccessExpiresAt: now.Add(-time.Minute).UnixMilli(), AbsoluteExpiresAt: now.Add(time.Hour).UnixMilli(), + } + created, err := store.Create(ctx, key, record, now.Add(time.Hour)) + require.NoError(t, err) + require.True(t, created) + + gate := refreshGateParams{ + key: key, now: now, refreshWindow: time.Minute, idleTimeout: time.Minute, + owner: "owner-1", leaseDuration: refreshLeaseDuration, + } + state, leased, err := store.GateRefresh(ctx, gate) + require.NoError(t, err) + require.Equal(t, refreshOwned, state) + require.Equal(t, "owner-1", leased.LeaseOwner) + + gate.owner = "owner-2" + state, _, err = store.GateRefresh(ctx, gate) + require.NoError(t, err) + require.Equal(t, refreshWaiting, state) + require.NoError(t, store.ReleaseRefresh(ctx, key, "not-owner")) + state, _, err = store.GateRefresh(ctx, gate) + require.NoError(t, err) + require.Equal(t, refreshWaiting, state) + + require.NoError(t, store.ReleaseRefresh(ctx, key, "owner-1")) + state, _, err = store.GateRefresh(ctx, gate) + require.NoError(t, err) + require.Equal(t, refreshOwned, state) + + gate.now = now.Add(refreshLeaseDuration + time.Millisecond) + gate.owner = "owner-3" + state, _, err = store.GateRefresh(ctx, gate) + require.NoError(t, err) + require.Equal(t, refreshOwned, state) + + committedAt := gate.now + accessExpiresAt := committedAt.Add(time.Minute) + err = store.CommitRefresh(ctx, refreshCommitParams{ + key: key, owner: "owner-2", payload: "stale", accessExpiresAt: accessExpiresAt, + now: committedAt, idleTimeout: time.Minute, + }) + require.ErrorIs(t, err, oidcsession.ErrInvalidSession) + require.NoError(t, store.CommitRefresh(ctx, refreshCommitParams{ + key: key, owner: "owner-3", payload: "updated", accessExpiresAt: accessExpiresAt, + now: committedAt, idleTimeout: time.Minute, + })) + + gate.now = committedAt.Add(time.Second) + gate.refreshWindow = time.Second + state, ready, err := store.GateRefresh(ctx, gate) + require.NoError(t, err) + require.Equal(t, refreshReady, state) + require.Equal(t, "updated", ready.Payload) + require.Equal(t, gate.now.UnixMilli(), ready.LastRefreshAt) + require.Empty(t, ready.LeaseOwner) + + _, err = store.Revoke(ctx, sessionKey("wrong")) + require.ErrorIs(t, err, oidcsession.ErrInvalidSession) + revokedPayload, err := store.Revoke(ctx, key) + require.NoError(t, err) + require.Equal(t, "updated", revokedPayload) + _, err = store.Status(ctx, key) + require.ErrorIs(t, err, oidcsession.ErrInvalidSession) +} diff --git a/oidcsessionredis/redis_store.go b/oidcsessionredis/redis_store.go new file mode 100644 index 0000000..32c1b93 --- /dev/null +++ b/oidcsessionredis/redis_store.go @@ -0,0 +1,138 @@ +package oidcsessionredis + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/devctllabs/go-libs/oidcsession" + "github.com/redis/go-redis/v9" +) + +const recordFormat = 1 + +type redisSessionStore struct { + client redis.UniversalClient + keyPrefix string + gate *redis.Script + commit *redis.Script + release *redis.Script +} + +func newRedisSessionStore(client redis.UniversalClient, keyPrefix string) *redisSessionStore { + return &redisSessionStore{ + client: client, keyPrefix: keyPrefix, + gate: redis.NewScript(gateScript), commit: redis.NewScript(commitScript), + release: redis.NewScript(releaseScript), + } +} + +func (store *redisSessionStore) Create(ctx context.Context, key sessionKey, record storedRecord, expiresAt time.Time) (bool, error) { + encoded, err := json.Marshal(record) + if err != nil { + return false, err + } + status, err := store.client.Do(ctx, "SET", store.key(key), encoded, "NX", "PXAT", expiresAt.UnixMilli()).Text() + if errors.Is(err, redis.Nil) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("create Redis session: %w", err) + } + return status == "OK", nil +} + +func (store *redisSessionStore) Status(ctx context.Context, key sessionKey) (storedRecord, error) { + raw, err := store.client.Get(ctx, store.key(key)).Result() + if errors.Is(err, redis.Nil) { + return storedRecord{}, oidcsession.ErrInvalidSession + } + if err != nil { + return storedRecord{}, fmt.Errorf("read Redis session: %w", err) + } + return decodeRecord(raw) +} + +func (store *redisSessionStore) GateRefresh(ctx context.Context, params refreshGateParams) (refreshState, storedRecord, error) { + result, err := store.gate.Run(ctx, store.client, []string{store.key(params.key)}, + params.now.UnixMilli(), params.refreshWindow.Milliseconds(), params.idleTimeout.Milliseconds(), + params.owner, params.leaseDuration.Milliseconds()).Slice() + if errors.Is(err, redis.Nil) { + return 0, storedRecord{}, oidcsession.ErrInvalidSession + } + if err != nil { + return 0, storedRecord{}, fmt.Errorf("coordinate Redis session refresh: %w", err) + } + return decodeGateResult(result) +} + +func (store *redisSessionStore) CommitRefresh(ctx context.Context, params refreshCommitParams) error { + committed, err := store.commit.Run(ctx, store.client, []string{store.key(params.key)}, params.owner, params.payload, + params.accessExpiresAt.UnixMilli(), params.now.UnixMilli(), params.idleTimeout.Milliseconds()).Int64() + if err != nil { + return fmt.Errorf("commit Redis session refresh: %w", err) + } + if committed != 1 { + return oidcsession.ErrInvalidSession + } + return nil +} + +func (store *redisSessionStore) ReleaseRefresh(ctx context.Context, key sessionKey, owner string) error { + _, err := store.release.Run(ctx, store.client, []string{store.key(key)}, owner).Result() + return err +} + +func (store *redisSessionStore) Revoke(ctx context.Context, key sessionKey) (string, error) { + raw, err := store.client.GetDel(ctx, store.key(key)).Result() + if errors.Is(err, redis.Nil) { + return "", oidcsession.ErrInvalidSession + } + if err != nil { + return "", fmt.Errorf("revoke Redis session: %w", err) + } + record, err := decodeRecord(raw) + if err != nil { + return "", err + } + return record.Payload, nil +} + +func (store *redisSessionStore) key(key sessionKey) string { + return store.keyPrefix + string(key) +} + +func decodeRecord(raw string) (storedRecord, error) { + var record storedRecord + if err := json.Unmarshal([]byte(raw), &record); err != nil || record.Format != recordFormat || record.Payload == "" { + return storedRecord{}, errors.New("stored session record is malformed") + } + return record, nil +} + +func decodeGateResult(result []any) (refreshState, storedRecord, error) { + if len(result) == 0 { + return 0, storedRecord{}, oidcsession.ErrInvalidSession + } + rawState, ok := result[0].(int64) + if !ok { + return 0, storedRecord{}, errors.New("redis returned a malformed refresh state") + } + state := refreshState(rawState) + if state == refreshWaiting { + return state, storedRecord{}, nil + } + if len(result) != 2 { + return 0, storedRecord{}, errors.New("redis returned a malformed refresh record") + } + raw, ok := result[1].(string) + if !ok { + return 0, storedRecord{}, errors.New("redis returned a malformed refresh record") + } + record, err := decodeRecord(raw) + return state, record, err +} + +var _ sessionStore = (*redisSessionStore)(nil) diff --git a/oidcsessionredis/scripts.go b/oidcsessionredis/scripts.go new file mode 100644 index 0000000..c7d3733 --- /dev/null +++ b/oidcsessionredis/scripts.go @@ -0,0 +1,51 @@ +package oidcsessionredis + +const gateScript = ` +local raw = redis.call('GET', KEYS[1]) +if not raw then return false end +local record = cjson.decode(raw) +local now = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +if tonumber(record.access_expires_at) > now + window then + record.last_refresh_at = now + local expiry = math.min(now + tonumber(ARGV[3]), tonumber(record.absolute_expires_at)) + local updated = cjson.encode(record) + redis.call('SET', KEYS[1], updated, 'PXAT', expiry) + return {1, updated} +end +if record.lease_owner and record.lease_owner ~= '' and tonumber(record.lease_until or 0) > now then + return {2} +end +record.lease_owner = ARGV[4] +record.lease_until = now + tonumber(ARGV[5]) +local leased = cjson.encode(record) +redis.call('SET', KEYS[1], leased, 'KEEPTTL') +return {3, leased} +` + +const commitScript = ` +local raw = redis.call('GET', KEYS[1]) +if not raw then return 0 end +local record = cjson.decode(raw) +if record.lease_owner ~= ARGV[1] then return 0 end +record.payload = ARGV[2] +record.access_expires_at = tonumber(ARGV[3]) +record.last_refresh_at = tonumber(ARGV[4]) +record.lease_owner = '' +record.lease_until = 0 +local expiry = math.min(tonumber(ARGV[4]) + tonumber(ARGV[5]), tonumber(record.absolute_expires_at)) +local updated = cjson.encode(record) +redis.call('SET', KEYS[1], updated, 'PXAT', expiry) +return 1 +` + +const releaseScript = ` +local raw = redis.call('GET', KEYS[1]) +if not raw then return 0 end +local record = cjson.decode(raw) +if record.lease_owner ~= ARGV[1] then return 0 end +record.lease_owner = '' +record.lease_until = 0 +redis.call('SET', KEYS[1], cjson.encode(record), 'KEEPTTL') +return 1 +` diff --git a/oidcsessionredis/store.gen_test.go b/oidcsessionredis/store.gen_test.go new file mode 100644 index 0000000..75d5aa6 --- /dev/null +++ b/oidcsessionredis/store.gen_test.go @@ -0,0 +1,275 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: store.go +// +// Generated by this command: +// +// mockgen -source=store.go -destination=store.gen_test.go -package=oidcsessionredis -typed +// + +// Package oidcsessionredis is a generated GoMock package. +package oidcsessionredis + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "go.uber.org/mock/gomock" +) + +// MocksessionStore is a mock of sessionStore interface. +type MocksessionStore struct { + ctrl *gomock.Controller + recorder *MocksessionStoreMockRecorder + isgomock struct{} +} + +// MocksessionStoreMockRecorder is the mock recorder for MocksessionStore. +type MocksessionStoreMockRecorder struct { + mock *MocksessionStore +} + +// NewMocksessionStore creates a new mock instance. +func NewMocksessionStore(ctrl *gomock.Controller) *MocksessionStore { + mock := &MocksessionStore{ctrl: ctrl} + mock.recorder = &MocksessionStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MocksessionStore) EXPECT() *MocksessionStoreMockRecorder { + return m.recorder +} + +// CommitRefresh mocks base method. +func (m *MocksessionStore) CommitRefresh(ctx context.Context, params refreshCommitParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CommitRefresh", ctx, params) + ret0, _ := ret[0].(error) + return ret0 +} + +// CommitRefresh indicates an expected call of CommitRefresh. +func (mr *MocksessionStoreMockRecorder) CommitRefresh(ctx, params any) *MocksessionStoreCommitRefreshCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommitRefresh", reflect.TypeOf((*MocksessionStore)(nil).CommitRefresh), ctx, params) + return &MocksessionStoreCommitRefreshCall{Call: call} +} + +// MocksessionStoreCommitRefreshCall wrap *gomock.Call +type MocksessionStoreCommitRefreshCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionStoreCommitRefreshCall) Return(arg0 error) *MocksessionStoreCommitRefreshCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionStoreCommitRefreshCall) Do(f func(context.Context, refreshCommitParams) error) *MocksessionStoreCommitRefreshCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionStoreCommitRefreshCall) DoAndReturn(f func(context.Context, refreshCommitParams) error) *MocksessionStoreCommitRefreshCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Create mocks base method. +func (m *MocksessionStore) Create(ctx context.Context, key sessionKey, record storedRecord, expiresAt time.Time) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, key, record, expiresAt) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MocksessionStoreMockRecorder) Create(ctx, key, record, expiresAt any) *MocksessionStoreCreateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MocksessionStore)(nil).Create), ctx, key, record, expiresAt) + return &MocksessionStoreCreateCall{Call: call} +} + +// MocksessionStoreCreateCall wrap *gomock.Call +type MocksessionStoreCreateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionStoreCreateCall) Return(arg0 bool, arg1 error) *MocksessionStoreCreateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionStoreCreateCall) Do(f func(context.Context, sessionKey, storedRecord, time.Time) (bool, error)) *MocksessionStoreCreateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionStoreCreateCall) DoAndReturn(f func(context.Context, sessionKey, storedRecord, time.Time) (bool, error)) *MocksessionStoreCreateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// GateRefresh mocks base method. +func (m *MocksessionStore) GateRefresh(ctx context.Context, params refreshGateParams) (refreshState, storedRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GateRefresh", ctx, params) + ret0, _ := ret[0].(refreshState) + ret1, _ := ret[1].(storedRecord) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GateRefresh indicates an expected call of GateRefresh. +func (mr *MocksessionStoreMockRecorder) GateRefresh(ctx, params any) *MocksessionStoreGateRefreshCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GateRefresh", reflect.TypeOf((*MocksessionStore)(nil).GateRefresh), ctx, params) + return &MocksessionStoreGateRefreshCall{Call: call} +} + +// MocksessionStoreGateRefreshCall wrap *gomock.Call +type MocksessionStoreGateRefreshCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionStoreGateRefreshCall) Return(arg0 refreshState, arg1 storedRecord, arg2 error) *MocksessionStoreGateRefreshCall { + c.Call = c.Call.Return(arg0, arg1, arg2) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionStoreGateRefreshCall) Do(f func(context.Context, refreshGateParams) (refreshState, storedRecord, error)) *MocksessionStoreGateRefreshCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionStoreGateRefreshCall) DoAndReturn(f func(context.Context, refreshGateParams) (refreshState, storedRecord, error)) *MocksessionStoreGateRefreshCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReleaseRefresh mocks base method. +func (m *MocksessionStore) ReleaseRefresh(ctx context.Context, key sessionKey, owner string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReleaseRefresh", ctx, key, owner) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReleaseRefresh indicates an expected call of ReleaseRefresh. +func (mr *MocksessionStoreMockRecorder) ReleaseRefresh(ctx, key, owner any) *MocksessionStoreReleaseRefreshCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReleaseRefresh", reflect.TypeOf((*MocksessionStore)(nil).ReleaseRefresh), ctx, key, owner) + return &MocksessionStoreReleaseRefreshCall{Call: call} +} + +// MocksessionStoreReleaseRefreshCall wrap *gomock.Call +type MocksessionStoreReleaseRefreshCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionStoreReleaseRefreshCall) Return(arg0 error) *MocksessionStoreReleaseRefreshCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionStoreReleaseRefreshCall) Do(f func(context.Context, sessionKey, string) error) *MocksessionStoreReleaseRefreshCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionStoreReleaseRefreshCall) DoAndReturn(f func(context.Context, sessionKey, string) error) *MocksessionStoreReleaseRefreshCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Revoke mocks base method. +func (m *MocksessionStore) Revoke(ctx context.Context, key sessionKey) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Revoke", ctx, key) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Revoke indicates an expected call of Revoke. +func (mr *MocksessionStoreMockRecorder) Revoke(ctx, key any) *MocksessionStoreRevokeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Revoke", reflect.TypeOf((*MocksessionStore)(nil).Revoke), ctx, key) + return &MocksessionStoreRevokeCall{Call: call} +} + +// MocksessionStoreRevokeCall wrap *gomock.Call +type MocksessionStoreRevokeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionStoreRevokeCall) Return(arg0 string, arg1 error) *MocksessionStoreRevokeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionStoreRevokeCall) Do(f func(context.Context, sessionKey) (string, error)) *MocksessionStoreRevokeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionStoreRevokeCall) DoAndReturn(f func(context.Context, sessionKey) (string, error)) *MocksessionStoreRevokeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Status mocks base method. +func (m *MocksessionStore) Status(ctx context.Context, key sessionKey) (storedRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Status", ctx, key) + ret0, _ := ret[0].(storedRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Status indicates an expected call of Status. +func (mr *MocksessionStoreMockRecorder) Status(ctx, key any) *MocksessionStoreStatusCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MocksessionStore)(nil).Status), ctx, key) + return &MocksessionStoreStatusCall{Call: call} +} + +// MocksessionStoreStatusCall wrap *gomock.Call +type MocksessionStoreStatusCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksessionStoreStatusCall) Return(arg0 storedRecord, arg1 error) *MocksessionStoreStatusCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksessionStoreStatusCall) Do(f func(context.Context, sessionKey) (storedRecord, error)) *MocksessionStoreStatusCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksessionStoreStatusCall) DoAndReturn(f func(context.Context, sessionKey) (storedRecord, error)) *MocksessionStoreStatusCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/oidcsessionredis/store.go b/oidcsessionredis/store.go new file mode 100644 index 0000000..19259e8 --- /dev/null +++ b/oidcsessionredis/store.go @@ -0,0 +1,59 @@ +package oidcsessionredis + +import ( + "context" + "time" +) + +//go:generate go tool mockgen -source=store.go -destination=store.gen_test.go -package=oidcsessionredis -typed + +type refreshState int64 + +const ( + refreshReady refreshState = 1 + refreshWaiting refreshState = 2 + refreshOwned refreshState = 3 +) + +type refreshGateParams struct { + key sessionKey + now time.Time + refreshWindow time.Duration + idleTimeout time.Duration + owner string + leaseDuration time.Duration +} + +type refreshCommitParams struct { + key sessionKey + owner string + payload string + accessExpiresAt time.Time + now time.Time + idleTimeout time.Duration +} + +type sessionStore interface { + // Create atomically stores record under a new session key until expiresAt. + Create(ctx context.Context, key sessionKey, record storedRecord, expiresAt time.Time) (bool, error) + // Status loads the session addressed by key. + Status(ctx context.Context, key sessionKey) (storedRecord, error) + // GateRefresh returns the current refresh coordination state and associated record. + GateRefresh(ctx context.Context, params refreshGateParams) (refreshState, storedRecord, error) + // CommitRefresh atomically replaces provider tokens for the active lease owner. + CommitRefresh(ctx context.Context, params refreshCommitParams) error + // ReleaseRefresh releases the refresh lease when owner still owns it. + ReleaseRefresh(ctx context.Context, key sessionKey, owner string) error + // Revoke atomically removes a session and returns its encrypted token payload. + Revoke(ctx context.Context, key sessionKey) (string, error) +} + +type storedRecord struct { + Format int `json:"format"` + Payload string `json:"payload"` + LastRefreshAt int64 `json:"last_refresh_at"` + AccessExpiresAt int64 `json:"access_expires_at"` + AbsoluteExpiresAt int64 `json:"absolute_expires_at"` + LeaseOwner string `json:"lease_owner,omitempty"` + LeaseUntil int64 `json:"lease_until,omitempty"` +} diff --git a/postgresdb/endpoint.go b/postgresdb/endpoint.go index 1b71b3d..163d957 100644 --- a/postgresdb/endpoint.go +++ b/postgresdb/endpoint.go @@ -16,6 +16,13 @@ type Endpoint struct { manager txmanager.Manager } +// InTransaction reports whether ctx carries a transaction for this database. +// It does not change the autocommit behavior of Endpoint operations. +func (e *Endpoint) InTransaction(ctx context.Context) bool { + _, ok := e.coordinator.Current(ctx) + return ok +} + // Exec executes query using the active transaction when ctx carries one. func (e *Endpoint) Exec(ctx context.Context, query string, args ...any) (pgconn.CommandTag, error) { if tx, ok := e.coordinator.Current(ctx); ok { diff --git a/postgresdb/integration_test.go b/postgresdb/integration_test.go index 2a0a5dd..958fb8f 100644 --- a/postgresdb/integration_test.go +++ b/postgresdb/integration_test.go @@ -42,11 +42,14 @@ func TestPostgresEndpointAndTransactionBehavior(t *testing.T) { t.Cleanup(func() { require.NoError(t, db.Close()) }) require.NoError(t, db.Writer().Check(ctx)) require.NoError(t, db.Reader().Check(ctx)) + require.False(t, db.Writer().InTransaction(ctx)) _, err = db.Writer().Exec(ctx, `CREATE TABLE entries (value text NOT NULL)`) require.NoError(t, err) callbackErr := errors.New("rollback requested") err = db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + require.True(t, db.Writer().InTransaction(txCtx)) + require.True(t, db.Reader().InTransaction(txCtx)) _, insertErr := db.Writer().Exec(txCtx, `INSERT INTO entries VALUES ($1)`, "pending") require.NoError(t, insertErr) var count int diff --git a/retry/doc.go b/retry/doc.go new file mode 100644 index 0000000..e7c8b38 --- /dev/null +++ b/retry/doc.go @@ -0,0 +1,2 @@ +// Package retry provides explicit, context-aware retry loops and backoff policies. +package retry diff --git a/retry/go.mod b/retry/go.mod new file mode 100644 index 0000000..382a884 --- /dev/null +++ b/retry/go.mod @@ -0,0 +1,11 @@ +module github.com/devctllabs/go-libs/retry + +go 1.25.0 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/retry/go.sum b/retry/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/retry/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/retry/retry.go b/retry/retry.go new file mode 100644 index 0000000..9c4821e --- /dev/null +++ b/retry/retry.go @@ -0,0 +1,201 @@ +package retry + +import ( + "context" + "errors" + "fmt" + "math" + "math/rand/v2" + "time" +) + +// Policy determines how long a retry loop waits after each failed attempt. +type Policy interface { + // Delay returns the delay after failures consecutive failures. Delay(0) must return zero. + Delay(failures uint) time.Duration +} + +// ExponentialConfig configures capped exponential backoff with optional proportional jitter. +type ExponentialConfig struct { + InitialDelay time.Duration + MaxDelay time.Duration + Multiplier float64 + Jitter float64 +} + +type exponential struct { + config ExponentialConfig + random func() float64 +} + +// NewExponential constructs a concurrency-safe exponential retry policy. +func NewExponential(config ExponentialConfig) (Policy, error) { + return newExponential(config, rand.Float64) +} + +func newExponential(config ExponentialConfig, random func() float64) (*exponential, error) { + if config.InitialDelay <= 0 { + return nil, errors.New("initial delay must be positive") + } + if config.MaxDelay <= 0 { + return nil, errors.New("max delay must be positive") + } + if config.MaxDelay < config.InitialDelay { + return nil, errors.New("max delay must not be shorter than initial delay") + } + if config.Multiplier <= 1 || math.IsNaN(config.Multiplier) || math.IsInf(config.Multiplier, 0) { + return nil, errors.New("multiplier must be finite and greater than one") + } + if config.Jitter < 0 || config.Jitter > 1 || math.IsNaN(config.Jitter) || math.IsInf(config.Jitter, 0) { + return nil, errors.New("jitter must be between zero and one") + } + if random == nil { + return nil, errors.New("random source is required") + } + return &exponential{config: config, random: random}, nil +} + +func (policy *exponential) Delay(failures uint) time.Duration { + if failures == 0 { + return 0 + } + delay := float64(policy.config.InitialDelay) * math.Pow(policy.config.Multiplier, float64(failures-1)) + delay = math.Min(delay, float64(policy.config.MaxDelay)) + if policy.config.Jitter != 0 { + delay *= 1 - policy.config.Jitter + 2*policy.config.Jitter*policy.random() + } + return time.Duration(math.Min(delay, float64(policy.config.MaxDelay))) +} + +// Operation is called once per attempt until it succeeds or retrying stops. +type Operation func(ctx context.Context) error + +// Notify observes a failed attempt immediately before its retry delay. +type Notify func(attempt uint, err error, nextDelay time.Duration) + +// Option configures a retry loop. +type Option func(*doConfig) error + +type doConfig struct { + maxAttempts uint + maxElapsed time.Duration + notify Notify +} + +// WithMaxAttempts limits the total operation calls, including the initial attempt. +func WithMaxAttempts(maxAttempts uint) Option { + return func(config *doConfig) error { + if maxAttempts == 0 { + return errors.New("max attempts must be positive") + } + config.maxAttempts = maxAttempts + return nil + } +} + +// WithMaxElapsedTime limits total wall-clock time spent by Do. +func WithMaxElapsedTime(maxElapsed time.Duration) Option { + return func(config *doConfig) error { + if maxElapsed <= 0 { + return errors.New("max elapsed time must be positive") + } + config.maxElapsed = maxElapsed + return nil + } +} + +// WithNotify observes retryable failures. notify runs synchronously in the caller's goroutine. +func WithNotify(notify Notify) Option { + return func(config *doConfig) error { + if notify == nil { + return errors.New("notify callback is required") + } + config.notify = notify + return nil + } +} + +// Do calls operation until success, cancellation, a configured limit, or a Permanent error. +// With no limits, it retries until operation succeeds or ctx ends. +func Do(ctx context.Context, policy Policy, operation Operation, options ...Option) error { + if ctx == nil { + return errors.New("context is required") + } + if policy == nil { + return errors.New("retry policy is required") + } + if operation == nil { + return errors.New("retry operation is required") + } + config := doConfig{} + for index, option := range options { + if option == nil { + return fmt.Errorf("apply option %d: option is nil", index) + } + if err := option(&config); err != nil { + return fmt.Errorf("apply option %d: %w", index, err) + } + } + + started := time.Now() + for attempt := uint(1); ; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + err := operation(ctx) + if err == nil { + return nil + } + var permanent *permanentError + if errors.As(err, &permanent) { + return permanent.err + } + if config.maxAttempts != 0 && attempt >= config.maxAttempts { + return err + } + + delay := policy.Delay(attempt) + if delay < 0 { + return errors.New("retry policy returned a negative delay") + } + if config.maxElapsed != 0 { + remaining := config.maxElapsed - time.Since(started) + if remaining <= 0 || delay > remaining { + return err + } + } + if config.notify != nil { + config.notify(attempt, err, delay) + } + if err := wait(ctx, delay); err != nil { + return err + } + } +} + +func wait(ctx context.Context, delay time.Duration) error { + if delay == 0 { + return ctx.Err() + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +type permanentError struct{ err error } + +func (err *permanentError) Error() string { return err.err.Error() } +func (err *permanentError) Unwrap() error { return err.err } + +// Permanent marks err as non-retryable. A nil error remains nil. +func Permanent(err error) error { + if err == nil { + return nil + } + return &permanentError{err: err} +} diff --git a/retry/retry_test.go b/retry/retry_test.go new file mode 100644 index 0000000..6a6e865 --- /dev/null +++ b/retry/retry_test.go @@ -0,0 +1,111 @@ +package retry + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestExponentialDelay(t *testing.T) { + t.Parallel() + policy, err := newExponential(ExponentialConfig{ + InitialDelay: time.Second, + MaxDelay: 5 * time.Second, + Multiplier: 2, + Jitter: 0.5, + }, func() float64 { return 0.5 }) + require.NoError(t, err) + + require.Equal(t, time.Duration(0), policy.Delay(0)) + require.Equal(t, time.Second, policy.Delay(1)) + require.Equal(t, 2*time.Second, policy.Delay(2)) + require.Equal(t, 4*time.Second, policy.Delay(3)) + require.Equal(t, 5*time.Second, policy.Delay(4)) +} + +func TestNewExponentialRejectsIncompleteConfiguration(t *testing.T) { + t.Parallel() + tests := []ExponentialConfig{ + {MaxDelay: time.Second, Multiplier: 2}, + {InitialDelay: time.Second, Multiplier: 2}, + {InitialDelay: time.Second, MaxDelay: time.Second}, + {InitialDelay: 2 * time.Second, MaxDelay: time.Second, Multiplier: 2}, + {InitialDelay: time.Second, MaxDelay: time.Second, Multiplier: 1}, + {InitialDelay: time.Second, MaxDelay: time.Second, Multiplier: 2, Jitter: -0.1}, + {InitialDelay: time.Second, MaxDelay: time.Second, Multiplier: 2, Jitter: 1.1}, + } + + for _, config := range tests { + _, err := NewExponential(config) + require.Error(t, err) + } +} + +func TestDoRetriesUntilSuccessAndNotifies(t *testing.T) { + t.Parallel() + policy := fixedPolicy{delay: time.Millisecond} + attempts := 0 + notifications := make([]uint, 0) + + err := Do(context.Background(), policy, func(context.Context) error { + attempts++ + if attempts < 3 { + return errors.New("temporary") + } + return nil + }, WithNotify(func(attempt uint, _ error, nextDelay time.Duration) { + notifications = append(notifications, attempt) + require.Equal(t, time.Millisecond, nextDelay) + })) + + require.NoError(t, err) + require.Equal(t, 3, attempts) + require.Equal(t, []uint{1, 2}, notifications) +} + +func TestDoStopsAtMaxAttempts(t *testing.T) { + t.Parallel() + want := errors.New("still unavailable") + attempts := 0 + err := Do(context.Background(), fixedPolicy{}, func(context.Context) error { + attempts++ + return want + }, WithMaxAttempts(3)) + + require.ErrorIs(t, err, want) + require.Equal(t, 3, attempts) +} + +func TestDoStopsOnPermanentError(t *testing.T) { + t.Parallel() + want := errors.New("invalid configuration") + attempts := 0 + err := Do(context.Background(), fixedPolicy{}, func(context.Context) error { + attempts++ + return Permanent(want) + }) + + require.ErrorIs(t, err, want) + require.Equal(t, 1, attempts) +} + +func TestDoHonorsContextWhileWaiting(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + attempts := 0 + err := Do(ctx, fixedPolicy{delay: time.Hour}, func(context.Context) error { + attempts++ + cancel() + return errors.New("temporary") + }) + + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, 1, attempts) +} + +type fixedPolicy struct{ delay time.Duration } + +func (policy fixedPolicy) Delay(uint) time.Duration { return policy.delay }