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 }