From 224a188b4648eb47f5b2bcef6c068370f45cd683 Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Wed, 5 Aug 2026 21:32:21 +0300 Subject: [PATCH 01/13] OSAC-3428: add adapters package scaffold with ProviderAdapter interface and types Signed-off-by: Amit Oren --- go.work | 3 +- osac-metering/adapters/adapter.go | 68 +++++++++++++++++++ osac-metering/adapters/adapter_test.go | 62 +++++++++++++++++ osac-metering/adapters/adapters_suite_test.go | 22 ++++++ osac-metering/adapters/go.mod | 23 +++++++ osac-metering/adapters/go.sum | 68 +++++++++++++++++++ 6 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 osac-metering/adapters/adapter.go create mode 100644 osac-metering/adapters/adapter_test.go create mode 100644 osac-metering/adapters/adapters_suite_test.go create mode 100644 osac-metering/adapters/go.mod create mode 100644 osac-metering/adapters/go.sum diff --git a/go.work b/go.work index d538a6094..9b74ead2d 100644 --- a/go.work +++ b/go.work @@ -1,9 +1,10 @@ -go 1.26.3 +go 1.26.4 use ( ./bare-metal-fulfillment-operator ./fulfillment-service ./osac-csi-driver + ./osac-metering/adapters ./osac-metering/metering-service ./osac-operator ./osac-operator/api diff --git a/osac-metering/adapters/adapter.go b/osac-metering/adapters/adapter.go new file mode 100644 index 000000000..28e80d71a --- /dev/null +++ b/osac-metering/adapters/adapter.go @@ -0,0 +1,68 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package adapters + +import ( + "context" + + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +// MeteringEvent wraps a CloudEvent with its Kafka coordinates. +type MeteringEvent struct { + CloudEvent cloudevents.Event + Topic string + Partition int32 + Offset int64 +} + +// SubmitResult is returned by Flush to report the outcome. +type SubmitResult struct { + ProviderEventID string + Idempotent bool +} + +// ProviderAdapter is the interface that concrete provider adapters implement. +// The Runner calls Submit per event and Flush on a configurable interval. +type ProviderAdapter interface { + // Name returns the provider name used as a Prometheus label. + Name() string + // Submit processes a single metering event. + Submit(ctx context.Context, event MeteringEvent) error + // Flush uploads any buffered events. Called on the flush ticker + // (default 10s) and on graceful shutdown. + Flush(ctx context.Context) (SubmitResult, error) + // HealthCheck verifies connectivity to the provider. + HealthCheck(ctx context.Context) error + // Close releases resources after the final Flush. + Close() error +} + +// RetryableError is an optional marker for documentation purposes. The Runner +// retries all errors by default — only errors wrapped in NonRetryableError are +// skipped. Wrapping in RetryableError makes the intent explicit but does not +// change retry behavior. +type RetryableError struct{ Err error } + +func (e *RetryableError) Error() string { return e.Err.Error() } +func (e *RetryableError) Unwrap() error { return e.Err } + +// NonRetryableError signals the runner should skip the event without retry. +type NonRetryableError struct{ Err error } + +func (e *NonRetryableError) Error() string { return e.Err.Error() } +func (e *NonRetryableError) Unwrap() error { return e.Err } + +// KafkaConfig configures the Kafka consumer connection. +type KafkaConfig struct { + TLSCACert string // Path to CA certificate file (empty = system CAs) + SASLUser string // SASL/SCRAM username + SASLPassFile string // Path to file containing SASL password +} diff --git a/osac-metering/adapters/adapter_test.go b/osac-metering/adapters/adapter_test.go new file mode 100644 index 000000000..3128b5fff --- /dev/null +++ b/osac-metering/adapters/adapter_test.go @@ -0,0 +1,62 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package adapters + +import ( + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Error Types", func() { + Describe("RetryableError", func() { + It("wraps and unwraps the underlying error", func() { + underlying := errors.New("connection timeout") + err := &RetryableError{Err: underlying} + + Expect(err.Error()).To(Equal("connection timeout")) + Expect(errors.Unwrap(err)).To(Equal(underlying)) + }) + + It("can be detected with errors.As", func() { + underlying := errors.New("temporary failure") + err := &RetryableError{Err: underlying} + + var retryable *RetryableError + Expect(errors.As(err, &retryable)).To(BeTrue()) + }) + }) + + Describe("NonRetryableError", func() { + It("wraps and unwraps the underlying error", func() { + underlying := errors.New("malformed event") + err := &NonRetryableError{Err: underlying} + + Expect(err.Error()).To(Equal("malformed event")) + Expect(errors.Unwrap(err)).To(Equal(underlying)) + }) + + It("can be detected with errors.As", func() { + underlying := errors.New("permanent failure") + err := &NonRetryableError{Err: underlying} + + var nonRetryable *NonRetryableError + Expect(errors.As(err, &nonRetryable)).To(BeTrue()) + }) + + It("is not detected as RetryableError", func() { + err := &NonRetryableError{Err: errors.New("fail")} + + var retryable *RetryableError + Expect(errors.As(err, &retryable)).To(BeFalse()) + }) + }) +}) diff --git a/osac-metering/adapters/adapters_suite_test.go b/osac-metering/adapters/adapters_suite_test.go new file mode 100644 index 000000000..16bb17e77 --- /dev/null +++ b/osac-metering/adapters/adapters_suite_test.go @@ -0,0 +1,22 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package adapters + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAdapters(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Adapters Suite") +} diff --git a/osac-metering/adapters/go.mod b/osac-metering/adapters/go.mod new file mode 100644 index 000000000..c9be1a1a8 --- /dev/null +++ b/osac-metering/adapters/go.mod @@ -0,0 +1,23 @@ +module github.com/osac-project/osac-metering/adapters + +go 1.26.4 + +require ( + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.1 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.45.0 // indirect +) diff --git a/osac-metering/adapters/go.sum b/osac-metering/adapters/go.sum new file mode 100644 index 000000000..2ec573fa2 --- /dev/null +++ b/osac-metering/adapters/go.sum @@ -0,0 +1,68 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +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/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +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-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +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/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +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/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +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= From 7645dc30d102dd213fbec927109df8bbe24b78fc Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Wed, 5 Aug 2026 21:40:11 +0300 Subject: [PATCH 02/13] OSAC-3428: fix go version and add missing cloudevents dependency - Change go.work from 1.26.4 to 1.26.3 to match global constraint - Change osac-metering/adapters/go.mod from 1.26.4 to 1.26.3 - Run go mod tidy to add missing github.com/cloudevents/sdk-go/v2 v2.16.2 - All transitive dependencies now properly resolved Signed-off-by: Amit Oren --- go.work | 2 +- osac-metering/adapters/go.mod | 9 ++++++++- osac-metering/adapters/go.sum | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/go.work b/go.work index 9b74ead2d..ad35b9e6a 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.26.4 +go 1.26.3 use ( ./bare-metal-fulfillment-operator diff --git a/osac-metering/adapters/go.mod b/osac-metering/adapters/go.mod index c9be1a1a8..30a7cc387 100644 --- a/osac-metering/adapters/go.mod +++ b/osac-metering/adapters/go.mod @@ -1,8 +1,9 @@ module github.com/osac-project/osac-metering/adapters -go 1.26.4 +go 1.26.3 require ( + github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 ) @@ -13,6 +14,12 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect diff --git a/osac-metering/adapters/go.sum b/osac-metering/adapters/go.sum index 2ec573fa2..28ed01e57 100644 --- a/osac-metering/adapters/go.sum +++ b/osac-metering/adapters/go.sum @@ -1,5 +1,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= +github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= +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/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -16,10 +19,15 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +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/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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= @@ -28,6 +36,13 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= @@ -36,8 +51,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= +github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -46,6 +63,14 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +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/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= @@ -58,11 +83,14 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -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/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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= From 8137ae74f180ed02cd0cc0c3524c2b252daa7bea Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Wed, 5 Aug 2026 21:43:38 +0300 Subject: [PATCH 03/13] OSAC-3428: add dedup cache with TTL eviction Signed-off-by: Amit Oren --- osac-metering/adapters/dedup.go | 84 ++++++++++++++++++++++++ osac-metering/adapters/dedup_test.go | 97 ++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 osac-metering/adapters/dedup.go create mode 100644 osac-metering/adapters/dedup_test.go diff --git a/osac-metering/adapters/dedup.go b/osac-metering/adapters/dedup.go new file mode 100644 index 000000000..a8a69dc2e --- /dev/null +++ b/osac-metering/adapters/dedup.go @@ -0,0 +1,84 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package adapters + +import ( + "context" + "sync" + "time" +) + +type dedupCache struct { + mu sync.Mutex + entries map[string]time.Time // CloudEvent ID → insertion time + ttl time.Duration +} + +func newDedupCache(ttl time.Duration) *dedupCache { + return &dedupCache{ + entries: make(map[string]time.Time), + ttl: ttl, + } +} + +// contains returns true if the given ID is in the cache and not expired. +func (c *dedupCache) contains(id string) bool { + c.mu.Lock() + defer c.mu.Unlock() + ts, ok := c.entries[id] + if !ok { + return false + } + if time.Since(ts) > c.ttl { + delete(c.entries, id) + return false + } + return true +} + +// add inserts an ID into the cache with the current timestamp. +func (c *dedupCache) add(id string) { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[id] = time.Now() +} + +// evictExpired removes entries older than TTL. +func (c *dedupCache) evictExpired() { + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now() + for id, ts := range c.entries { + if now.Sub(ts) > c.ttl { + delete(c.entries, id) + } + } +} + +// startEviction runs evictExpired every 30 seconds until ctx is cancelled. +func (c *dedupCache) startEviction(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.evictExpired() + } + } +} + +// len returns the number of entries in the cache. +func (c *dedupCache) len() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.entries) +} diff --git a/osac-metering/adapters/dedup_test.go b/osac-metering/adapters/dedup_test.go new file mode 100644 index 000000000..216144cfb --- /dev/null +++ b/osac-metering/adapters/dedup_test.go @@ -0,0 +1,97 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package adapters + +import ( + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("dedupCache", func() { + var cache *dedupCache + + BeforeEach(func() { + cache = newDedupCache(100 * time.Millisecond) + }) + + Describe("contains and add", func() { + It("returns false for an unknown ID", func() { + Expect(cache.contains("unknown")).To(BeFalse()) + }) + + It("returns true after adding an ID", func() { + cache.add("event-1") + Expect(cache.contains("event-1")).To(BeTrue()) + }) + + It("suppresses duplicate IDs", func() { + cache.add("event-1") + Expect(cache.contains("event-1")).To(BeTrue()) + Expect(cache.contains("event-1")).To(BeTrue()) + }) + + It("tracks multiple IDs independently", func() { + cache.add("event-1") + cache.add("event-2") + Expect(cache.contains("event-1")).To(BeTrue()) + Expect(cache.contains("event-2")).To(BeTrue()) + Expect(cache.contains("event-3")).To(BeFalse()) + }) + }) + + Describe("TTL expiry", func() { + It("returns false for expired entries", func() { + cache.add("event-1") + Expect(cache.contains("event-1")).To(BeTrue()) + + time.Sleep(150 * time.Millisecond) + Expect(cache.contains("event-1")).To(BeFalse()) + }) + }) + + Describe("evictExpired", func() { + It("removes expired entries from the map", func() { + cache.add("event-1") + Expect(cache.len()).To(Equal(1)) + + time.Sleep(150 * time.Millisecond) + cache.evictExpired() + Expect(cache.len()).To(Equal(0)) + }) + + It("preserves non-expired entries", func() { + cache.add("event-1") + cache.evictExpired() + Expect(cache.len()).To(Equal(1)) + }) + }) + + Describe("concurrent safety", func() { + It("handles concurrent add and contains without panics", func() { + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(2) + id := "event-" + string(rune('0'+i%10)) + go func() { + defer wg.Done() + cache.add(id) + }() + go func() { + defer wg.Done() + _ = cache.contains(id) + }() + } + wg.Wait() + }) + }) +}) From 1cc3bb9a24b7c0c0bd586c0ea29014f40f38c85c Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Wed, 5 Aug 2026 21:46:41 +0300 Subject: [PATCH 04/13] OSAC-3428: add out-of-order detection tracker Signed-off-by: Amit Oren --- osac-metering/adapters/order.go | 92 ++++++++++++++++++++++++++ osac-metering/adapters/order_test.go | 98 ++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 osac-metering/adapters/order.go create mode 100644 osac-metering/adapters/order_test.go diff --git a/osac-metering/adapters/order.go b/osac-metering/adapters/order.go new file mode 100644 index 000000000..8cf3e6370 --- /dev/null +++ b/osac-metering/adapters/order.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package adapters + +import ( + "context" + "sync" + "time" +) + +type orderEntry struct { + transitionTime time.Time // latest event transition_time (used for ordering) + updatedAt time.Time // wall-clock time of last update (used for eviction) +} + +type orderTracker struct { + mu sync.Mutex + lastSeen map[string]orderEntry // resource_id → latest transition time + wall-clock update time + ttl time.Duration +} + +func newOrderTracker(ttl time.Duration) *orderTracker { + return &orderTracker{ + lastSeen: make(map[string]orderEntry), + ttl: ttl, + } +} + +// check returns true if the event's transitionTime is out of order +// (earlier than the last seen time for this resourceID). +// Updates the tracker with the new time if it is the latest. +func (t *orderTracker) check(resourceID string, transitionTime time.Time) bool { + t.mu.Lock() + defer t.mu.Unlock() + + entry, ok := t.lastSeen[resourceID] + if ok && transitionTime.Before(entry.transitionTime) { + // Out of order — still refresh the wall-clock time so the entry + // is not evicted while the resource is actively producing events. + entry.updatedAt = time.Now() + t.lastSeen[resourceID] = entry + return true + } + t.lastSeen[resourceID] = orderEntry{transitionTime: transitionTime, updatedAt: time.Now()} + return false +} + +// evictExpired removes entries whose wall-clock update time exceeds the TTL. +func (t *orderTracker) evictExpired() { + t.mu.Lock() + defer t.mu.Unlock() + now := time.Now() + for id, entry := range t.lastSeen { + if now.Sub(entry.updatedAt) > t.ttl { + delete(t.lastSeen, id) + } + } +} + +// startEviction runs evictExpired every 30 seconds until ctx is cancelled. +func (t *orderTracker) startEviction(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + t.evictExpired() + } + } +} + +// len returns the number of tracked resources. +func (t *orderTracker) len() int { + t.mu.Lock() + defer t.mu.Unlock() + return len(t.lastSeen) +} diff --git a/osac-metering/adapters/order_test.go b/osac-metering/adapters/order_test.go new file mode 100644 index 000000000..23bbc0c04 --- /dev/null +++ b/osac-metering/adapters/order_test.go @@ -0,0 +1,98 @@ +package adapters + +import ( + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("orderTracker", func() { + var tracker *orderTracker + + BeforeEach(func() { + tracker = newOrderTracker(100 * time.Millisecond) + }) + + Describe("check", func() { + It("returns false for a new resource ID", func() { + t := time.Now() + Expect(tracker.check("res-1", t)).To(BeFalse()) + }) + + It("returns false when events arrive in order", func() { + t1 := time.Now() + t2 := t1.Add(10 * time.Second) + + Expect(tracker.check("res-1", t1)).To(BeFalse()) + Expect(tracker.check("res-1", t2)).To(BeFalse()) + }) + + It("returns true when an event arrives out of order", func() { + t1 := time.Now() + t2 := t1.Add(10 * time.Second) + + Expect(tracker.check("res-1", t2)).To(BeFalse()) + Expect(tracker.check("res-1", t1)).To(BeTrue()) + }) + + It("tracks resources independently", func() { + t1 := time.Now() + t2 := t1.Add(10 * time.Second) + + Expect(tracker.check("res-1", t2)).To(BeFalse()) + Expect(tracker.check("res-2", t1)).To(BeFalse()) + }) + + It("updates last-seen time on newer events", func() { + t1 := time.Now() + t2 := t1.Add(10 * time.Second) + t3 := t1.Add(5 * time.Second) + + Expect(tracker.check("res-1", t1)).To(BeFalse()) + Expect(tracker.check("res-1", t2)).To(BeFalse()) + // t3 is between t1 and t2 — still out of order relative to t2 + Expect(tracker.check("res-1", t3)).To(BeTrue()) + }) + }) + + Describe("TTL eviction", func() { + It("evicts expired entries", func() { + tracker.check("res-1", time.Now()) + Expect(tracker.len()).To(Equal(1)) + + time.Sleep(150 * time.Millisecond) + tracker.evictExpired() + Expect(tracker.len()).To(Equal(0)) + }) + + It("preserves non-expired entries", func() { + tracker.check("res-1", time.Now()) + tracker.evictExpired() + Expect(tracker.len()).To(Equal(1)) + }) + + It("uses wall-clock time for eviction, not event time", func() { + // A backdated transition_time should not cause premature eviction; + // eviction is based on when the tracker last saw the resource. + tracker.check("res-1", time.Now().Add(-time.Hour)) + tracker.evictExpired() + Expect(tracker.len()).To(Equal(1)) + }) + }) + + Describe("concurrent safety", func() { + It("handles concurrent checks without panics", func() { + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + tracker.check("res-1", time.Now().Add(time.Duration(i)*time.Second)) + }(i) + } + wg.Wait() + }) + }) +}) From c49d9a8b6c64c666f5fd097ff1068ebd82473cc7 Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Wed, 5 Aug 2026 21:49:58 +0300 Subject: [PATCH 05/13] OSAC-3428: add exponential backoff retry with error classification Signed-off-by: Amit Oren --- osac-metering/adapters/retry.go | 141 ++++++++++++++++++++++ osac-metering/adapters/retry_test.go | 172 +++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 osac-metering/adapters/retry.go create mode 100644 osac-metering/adapters/retry_test.go diff --git a/osac-metering/adapters/retry.go b/osac-metering/adapters/retry.go new file mode 100644 index 000000000..4cd54d376 --- /dev/null +++ b/osac-metering/adapters/retry.go @@ -0,0 +1,141 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package adapters + +import ( + "context" + "errors" + "fmt" + "math/rand" + "time" + + "github.com/go-logr/logr" +) + +const ( + initialBackoff = 1 * time.Second + maxBackoff = 5 * time.Minute + jitterFraction = 0.25 +) + +// retryResult holds the outcome of a retry-wrapped Submit call. +type retryResult struct { + Attempts int + TotalDuration time.Duration + NonRetryable bool + Exhausted bool + Err error +} + +// calculateBackoff returns the backoff duration for the given attempt (0-indexed). +// Sequence: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s, 300s (capped). +// Jitter of ±25% is applied. +func calculateBackoff(attempt int) time.Duration { + backoff := initialBackoff + for i := 0; i < attempt; i++ { + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + break + } + } + jitter := float64(backoff) * jitterFraction * (2*rand.Float64() - 1) //nolint:gosec + return backoff + time.Duration(jitter) +} + +// sleepFunc is the function used to sleep between retries. +// Overridable in tests to avoid real sleeps. +var sleepFunc = func(ctx context.Context, d time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(d): + return nil + } +} + +// submitWithRetry calls adapter.Submit with exponential backoff on retryable errors. +func submitWithRetry( + ctx context.Context, + adapter ProviderAdapter, + event MeteringEvent, + maxRetries int, + logger logr.Logger, +) retryResult { + start := time.Now() + + if maxRetries < 1 { + maxRetries = 1 + } + + for attempt := 0; attempt < maxRetries; attempt++ { + err := adapter.Submit(ctx, event) + if err == nil { + return retryResult{ + Attempts: attempt + 1, + TotalDuration: time.Since(start), + } + } + + var nonRetryable *NonRetryableError + if errors.As(err, &nonRetryable) { + logger.Error(err, "non-retryable error, skipping event", + "event_id", event.CloudEvent.ID(), + "attempt", attempt+1, + ) + return retryResult{ + Attempts: attempt + 1, + TotalDuration: time.Since(start), + NonRetryable: true, + Err: err, + } + } + + if attempt < maxRetries-1 { + backoff := calculateBackoff(attempt) + logger.V(1).Info("retrying submit", + "event_id", event.CloudEvent.ID(), + "attempt", attempt+1, + "backoff", backoff, + ) + if err := sleepFunc(ctx, backoff); err != nil { + return retryResult{ + Attempts: attempt + 1, + TotalDuration: time.Since(start), + Err: ctx.Err(), + } + } + } else { + logger.Error(err, "retries exhausted, skipping event", + "event_id", event.CloudEvent.ID(), + "attempts", maxRetries, + ) + return retryResult{ + Attempts: maxRetries, + TotalDuration: time.Since(start), + Exhausted: true, + Err: err, + } + } + } + + // Unreachable with maxRetries >= 1, but keep the contract: Exhausted implies a non-nil Err. + return retryResult{ + Attempts: maxRetries, + TotalDuration: time.Since(start), + Exhausted: true, + Err: fmt.Errorf("submit not attempted: maxRetries %d", maxRetries), + } +} diff --git a/osac-metering/adapters/retry_test.go b/osac-metering/adapters/retry_test.go new file mode 100644 index 000000000..c37f089ea --- /dev/null +++ b/osac-metering/adapters/retry_test.go @@ -0,0 +1,172 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package adapters + +import ( + "context" + "errors" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/go-logr/logr" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Override sleepFunc to skip real sleeps in all tests. +// This is the only BeforeSuite in the package. +var _ = BeforeSuite(func() { + sleepFunc = func(ctx context.Context, _ time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } + } +}) + +var _ = Describe("calculateBackoff", func() { + It("returns 1s base for attempt 0", func() { + b := calculateBackoff(0) + // ±25% jitter: 750ms to 1250ms + Expect(b).To(BeNumerically(">=", 750*time.Millisecond)) + Expect(b).To(BeNumerically("<=", 1250*time.Millisecond)) + }) + + It("doubles on each subsequent attempt", func() { + // attempt 1 → 2s base, attempt 2 → 4s base + b1 := calculateBackoff(1) + Expect(b1).To(BeNumerically(">=", 1500*time.Millisecond)) + Expect(b1).To(BeNumerically("<=", 2500*time.Millisecond)) + + b2 := calculateBackoff(2) + Expect(b2).To(BeNumerically(">=", 3*time.Second)) + Expect(b2).To(BeNumerically("<=", 5*time.Second)) + }) + + It("caps at 5 minutes", func() { + // attempt 9 would be 512s without cap; capped at 300s (5m) + b := calculateBackoff(9) + Expect(b).To(BeNumerically("<=", 5*time.Minute+75*time.Second)) // 5m + 25% jitter + Expect(b).To(BeNumerically(">=", 5*time.Minute-75*time.Second)) // 5m - 25% jitter + }) + + It("stays capped for very high attempts", func() { + b := calculateBackoff(20) + Expect(b).To(BeNumerically("<=", 5*time.Minute+75*time.Second)) + }) +}) + +var _ = Describe("submitWithRetry", func() { + var ( + adapter *retryTestAdapter + event MeteringEvent + logger logr.Logger + ) + + BeforeEach(func() { + adapter = &retryTestAdapter{name: "test"} + ce := cloudevents.NewEvent() + ce.SetID("retry-test") + ce.SetType("osac.test.v1") + ce.SetSource("test") + event = MeteringEvent{CloudEvent: ce, Topic: "t", Partition: 0, Offset: 0} + logger = logr.Discard() + }) + + It("returns success on first attempt when Submit succeeds", func() { + result := submitWithRetry(context.Background(), adapter, event, 3, logger) + Expect(result.Err).NotTo(HaveOccurred()) + Expect(result.Attempts).To(Equal(1)) + Expect(adapter.calls).To(Equal(1)) + }) + + It("skips immediately on NonRetryableError", func() { + adapter.errs = []error{ + &NonRetryableError{Err: errors.New("bad schema")}, + } + result := submitWithRetry(context.Background(), adapter, event, 3, logger) + Expect(result.Err).To(HaveOccurred()) + Expect(result.NonRetryable).To(BeTrue()) + Expect(result.Attempts).To(Equal(1)) + Expect(adapter.calls).To(Equal(1)) + }) + + It("retries on retryable errors and succeeds", func() { + adapter.errs = []error{ + errors.New("transient-1"), + errors.New("transient-2"), + nil, // third attempt succeeds + } + result := submitWithRetry(context.Background(), adapter, event, 5, logger) + Expect(result.Err).NotTo(HaveOccurred()) + Expect(result.Attempts).To(Equal(3)) + Expect(adapter.calls).To(Equal(3)) + }) + + It("returns exhausted after max retries", func() { + adapter.errs = []error{ + errors.New("fail-1"), + errors.New("fail-2"), + errors.New("fail-3"), + } + result := submitWithRetry(context.Background(), adapter, event, 3, logger) + Expect(result.Err).To(HaveOccurred()) + Expect(result.Exhausted).To(BeTrue()) + Expect(result.Attempts).To(Equal(3)) + }) + + It("clamps maxRetries to 1 and still calls Submit", func() { + result := submitWithRetry(context.Background(), adapter, event, 0, logger) + Expect(result.Err).NotTo(HaveOccurred()) + Expect(result.Attempts).To(Equal(1)) + Expect(adapter.calls).To(Equal(1)) + }) + + It("stops retrying when context is cancelled", func() { + ctx, cancel := context.WithCancel(context.Background()) + adapter.errs = []error{errors.New("fail")} + adapter.onSubmit = func() { cancel() } + + result := submitWithRetry(ctx, adapter, event, 10, logger) + Expect(result.Err).To(HaveOccurred()) + Expect(result.Attempts).To(Equal(1)) + }) +}) + +// retryTestAdapter is a mock ProviderAdapter for retry tests. +// It returns errors from the errs slice in order; once exhausted, returns nil. +type retryTestAdapter struct { + name string + errs []error + calls int + onSubmit func() +} + +func (a *retryTestAdapter) Name() string { return a.name } +func (a *retryTestAdapter) Submit(_ context.Context, _ MeteringEvent) error { + a.calls++ + if a.onSubmit != nil { + a.onSubmit() + } + if a.calls-1 < len(a.errs) { + return a.errs[a.calls-1] + } + return nil +} +func (a *retryTestAdapter) Flush(_ context.Context) (SubmitResult, error) { return SubmitResult{}, nil } +func (a *retryTestAdapter) HealthCheck(_ context.Context) error { return nil } +func (a *retryTestAdapter) Close() error { return nil } From 508cd5db7bc64aa5b9774764c055db44408e93c6 Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Wed, 5 Aug 2026 21:52:52 +0300 Subject: [PATCH 06/13] OSAC-3428: add Prometheus metric definitions and handler Signed-off-by: Amit Oren --- osac-metering/adapters/metrics.go | 72 ++++++++++++++++++++++ osac-metering/adapters/metrics_test.go | 84 ++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 osac-metering/adapters/metrics.go create mode 100644 osac-metering/adapters/metrics_test.go diff --git a/osac-metering/adapters/metrics.go b/osac-metering/adapters/metrics.go new file mode 100644 index 000000000..2cf7fda14 --- /dev/null +++ b/osac-metering/adapters/metrics.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Red Hat, Inc. 2026 + +package adapters + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +type adapterMetrics struct { + eventsSubmitted *prometheus.CounterVec + eventsFailed *prometheus.CounterVec + retryDuration *prometheus.HistogramVec + duplicatesSuppressed *prometheus.CounterVec + outOfOrderEvents *prometheus.CounterVec + flushDuration *prometheus.HistogramVec + flushErrors *prometheus.CounterVec + registry *prometheus.Registry +} + +func newAdapterMetrics() *adapterMetrics { + reg := prometheus.NewRegistry() + + m := &adapterMetrics{ + eventsSubmitted: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "osac_adapter_events_submitted_total", + Help: "Total events successfully submitted to the provider.", + }, []string{"provider", "topic"}), + eventsFailed: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "osac_adapter_events_failed_total", + Help: "Total events that failed processing.", + }, []string{"provider", "error_type"}), + retryDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "osac_adapter_retry_duration_seconds", + Help: "Duration of retry attempts for Submit calls.", + Buckets: prometheus.ExponentialBuckets(1, 2, 10), + }, []string{"provider"}), + duplicatesSuppressed: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "osac_adapter_duplicates_suppressed_total", + Help: "Total duplicate events suppressed by the dedup cache.", + }, []string{"provider"}), + outOfOrderEvents: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "osac_adapter_out_of_order_events_total", + Help: "Total out-of-order events detected.", + }, []string{"provider"}), + flushDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "osac_adapter_flush_duration_seconds", + Help: "Duration of flush operations.", + Buckets: prometheus.DefBuckets, + }, []string{"provider"}), + flushErrors: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "osac_adapter_flush_errors_total", + Help: "Total flush operation failures.", + }, []string{"provider"}), + registry: reg, + } + + reg.MustRegister( + m.eventsSubmitted, m.eventsFailed, m.retryDuration, + m.duplicatesSuppressed, m.outOfOrderEvents, + m.flushDuration, m.flushErrors, + ) + + return m +} + +func (m *adapterMetrics) handler() http.Handler { + return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{}) +} diff --git a/osac-metering/adapters/metrics_test.go b/osac-metering/adapters/metrics_test.go new file mode 100644 index 000000000..c882999e4 --- /dev/null +++ b/osac-metering/adapters/metrics_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Red Hat, Inc. 2026 + +package adapters + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +var _ = Describe("adapterMetrics", func() { + var m *adapterMetrics + + BeforeEach(func() { + m = newAdapterMetrics() + }) + + Describe("counter increments", func() { + It("increments events submitted", func() { + m.eventsSubmitted.WithLabelValues("test-provider", "topic-1").Inc() + val := testutil.ToFloat64(m.eventsSubmitted.WithLabelValues("test-provider", "topic-1")) + Expect(val).To(Equal(float64(1))) + }) + + It("increments events failed with error type", func() { + m.eventsFailed.WithLabelValues("test-provider", "non_retryable").Inc() + val := testutil.ToFloat64(m.eventsFailed.WithLabelValues("test-provider", "non_retryable")) + Expect(val).To(Equal(float64(1))) + }) + + It("increments duplicates suppressed", func() { + m.duplicatesSuppressed.WithLabelValues("test-provider").Add(5) + val := testutil.ToFloat64(m.duplicatesSuppressed.WithLabelValues("test-provider")) + Expect(val).To(Equal(float64(5))) + }) + + It("increments out of order events", func() { + m.outOfOrderEvents.WithLabelValues("test-provider").Inc() + val := testutil.ToFloat64(m.outOfOrderEvents.WithLabelValues("test-provider")) + Expect(val).To(Equal(float64(1))) + }) + + It("increments flush errors", func() { + m.flushErrors.WithLabelValues("test-provider").Inc() + val := testutil.ToFloat64(m.flushErrors.WithLabelValues("test-provider")) + Expect(val).To(Equal(float64(1))) + }) + }) + + Describe("histogram observations", func() { + It("observes retry duration", func() { + m.retryDuration.WithLabelValues("test-provider").Observe(1.5) + m.retryDuration.WithLabelValues("test-provider").Observe(3.0) + count := testutil.CollectAndCount(m.retryDuration) + Expect(count).To(Equal(1)) // one metric family + }) + + It("observes flush duration", func() { + m.flushDuration.WithLabelValues("test-provider").Observe(0.05) + count := testutil.CollectAndCount(m.flushDuration) + Expect(count).To(Equal(1)) + }) + }) + + Describe("handler", func() { + It("serves metrics via HTTP", func() { + m.eventsSubmitted.WithLabelValues("test-provider", "topic-1").Inc() + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + m.handler().ServeHTTP(w, req) + + body, err := io.ReadAll(w.Result().Body) + Expect(err).NotTo(HaveOccurred()) + Expect(strings.Contains(string(body), "osac_adapter_events_submitted_total")).To(BeTrue()) + }) + }) +}) From 81f919fe79c2a52b01c1b45b89e6448ff75dd3a9 Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Wed, 5 Aug 2026 21:59:00 +0300 Subject: [PATCH 07/13] OSAC-3428: add Runner with Kafka consumer lifecycle, flush, and offset commit Signed-off-by: Amit Oren --- osac-metering/adapters/kafka.go | 112 ++++++ osac-metering/adapters/runner.go | 345 +++++++++++++++++ osac-metering/adapters/runner_test.go | 530 ++++++++++++++++++++++++++ 3 files changed, 987 insertions(+) create mode 100644 osac-metering/adapters/kafka.go create mode 100644 osac-metering/adapters/runner.go create mode 100644 osac-metering/adapters/runner_test.go diff --git a/osac-metering/adapters/kafka.go b/osac-metering/adapters/kafka.go new file mode 100644 index 000000000..b75d8bc74 --- /dev/null +++ b/osac-metering/adapters/kafka.go @@ -0,0 +1,112 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package adapters + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "strings" + + "github.com/IBM/sarama" + "github.com/xdg-go/scram" +) + +// newConsumerConfig creates a Sarama config for the adapter consumer group. +func newConsumerConfig(cfg KafkaConfig) (*sarama.Config, error) { + sc := sarama.NewConfig() + sc.Version = sarama.V3_9_0_0 + sc.Consumer.Return.Errors = true + sc.Consumer.Offsets.Initial = sarama.OffsetOldest + sc.Consumer.Offsets.AutoCommit.Enable = false + sc.Consumer.Group.Rebalance.GroupStrategies = []sarama.BalanceStrategy{ + sarama.NewBalanceStrategyRange(), + } + + if err := configureConsumerTLS(sc, cfg.TLSCACert); err != nil { + return nil, err + } + if cfg.SASLUser != "" { + if err := configureConsumerSASL(sc, cfg.SASLUser, cfg.SASLPassFile); err != nil { + return nil, err + } + } + return sc, nil +} + +func configureConsumerTLS(sc *sarama.Config, caCertPath string) error { + sc.Net.TLS.Enable = true + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} + if caCertPath != "" { + caCert, err := os.ReadFile(caCertPath) + if err != nil { + return fmt.Errorf("reading Kafka CA cert %s: %w", caCertPath, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caCert) { + return fmt.Errorf("failed to parse Kafka CA cert %s", caCertPath) + } + tlsCfg.RootCAs = pool + } + sc.Net.TLS.Config = tlsCfg + return nil +} + +func configureConsumerSASL(sc *sarama.Config, user, passFile string) error { + password, err := os.ReadFile(passFile) + if err != nil { + return fmt.Errorf("reading SASL password file %s: %w", passFile, err) + } + trimmed := strings.TrimSpace(string(password)) + if trimmed == "" { + return fmt.Errorf("SASL password file %s is empty", passFile) + } + sc.Net.SASL.Enable = true + sc.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA512 + sc.Net.SASL.User = user + sc.Net.SASL.Password = trimmed + sc.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { + return &adapterScramClient{} + } + return nil +} + +type adapterScramClient struct { + conversation *scram.ClientConversation +} + +func (c *adapterScramClient) Begin(userName, password, authzID string) error { + client, err := scram.SHA512.NewClient(userName, password, authzID) + if err != nil { + return err + } + c.conversation = client.NewConversation() + return nil +} + +func (c *adapterScramClient) Step(challenge string) (string, error) { + return c.conversation.Step(challenge) +} + +func (c *adapterScramClient) Done() bool { + return c.conversation.Done() +} + +func splitAndTrimBrokers(s, sep string) []string { + parts := strings.Split(s, sep) + result := parts[:0] + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} diff --git a/osac-metering/adapters/runner.go b/osac-metering/adapters/runner.go new file mode 100644 index 000000000..9b29a794a --- /dev/null +++ b/osac-metering/adapters/runner.go @@ -0,0 +1,345 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/IBM/sarama" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/go-logr/logr" +) + +const ( + defaultFlushInterval = 10 * time.Second + defaultDedupTTL = 10 * time.Minute + defaultMaxRetries = 10 + shutdownFlushTimeout = 30 * time.Second + shutdownCloseTimeout = 10 * time.Second + consumeErrorBackoff = 5 * time.Second +) + +// RunnerConfig configures the adapter Runner. +type RunnerConfig struct { + Brokers string + ConsumerGroup string + Topics []string + FlushInterval time.Duration // default 10s + DedupTTL time.Duration // default 10m + MaxRetries int // default 10 + Kafka KafkaConfig +} + +type topicPartition struct { + Topic string + Partition int32 +} + +// Runner manages the Kafka consumer lifecycle for a ProviderAdapter. +type Runner struct { + adapter ProviderAdapter + cfg RunnerConfig + logger logr.Logger + metrics *adapterMetrics + dedup *dedupCache + order *orderTracker + + mu sync.Mutex + offsets map[topicPartition]int64 + session sarama.ConsumerGroupSession +} + +// NewRunner creates a Runner for the given adapter. +func NewRunner(adapter ProviderAdapter, cfg RunnerConfig, logger logr.Logger) *Runner { + if cfg.FlushInterval == 0 { + cfg.FlushInterval = defaultFlushInterval + } + if cfg.DedupTTL == 0 { + cfg.DedupTTL = defaultDedupTTL + } + if cfg.MaxRetries == 0 { + cfg.MaxRetries = defaultMaxRetries + } + + return &Runner{ + adapter: adapter, + cfg: cfg, + logger: logger.WithName("adapter-runner").WithValues("provider", adapter.Name()), + metrics: newAdapterMetrics(), + dedup: newDedupCache(cfg.DedupTTL), + order: newOrderTracker(cfg.DedupTTL), + offsets: make(map[topicPartition]int64), + } +} + +// MetricsHandler returns an HTTP handler for Prometheus metrics. +func (r *Runner) MetricsHandler() http.Handler { + return r.metrics.handler() +} + +// Run starts the Kafka consumer group and blocks until ctx is cancelled. +func (r *Runner) Run(ctx context.Context) error { + sc, err := newConsumerConfig(r.cfg.Kafka) + if err != nil { + return fmt.Errorf("creating consumer config: %w", err) + } + + brokers := splitAndTrimBrokers(r.cfg.Brokers, ",") + group, err := sarama.NewConsumerGroup(brokers, r.cfg.ConsumerGroup, sc) + if err != nil { + return fmt.Errorf("creating consumer group: %w", err) + } + defer func() { _ = group.Close() }() + + go r.dedup.startEviction(ctx) + go r.order.startEviction(ctx) + + flushDone := make(chan struct{}) + go r.flushLoop(ctx, flushDone) + + r.logger.Info("starting consumer group", "topics", r.cfg.Topics) + + for { + if err := group.Consume(ctx, r.cfg.Topics, r); err != nil { + if errors.Is(err, sarama.ErrClosedConsumerGroup) { + r.logger.Info("consumer group closed, exiting") + break + } + r.logger.Error(err, "consumer group error, retrying after backoff") + select { + case <-ctx.Done(): + case <-time.After(consumeErrorBackoff): + } + } + if ctx.Err() != nil { + break + } + } + + <-flushDone + + r.logger.Info("performing final flush on shutdown") + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownFlushTimeout) + defer cancel() + if err := r.flush(shutdownCtx); err != nil { + r.logger.Error(err, "final flush failed") + } + + closeErr := make(chan error, 1) + go func() { closeErr <- r.adapter.Close() }() + select { + case err := <-closeErr: + if err != nil { + r.logger.Error(err, "adapter close failed") + } + case <-time.After(shutdownCloseTimeout): + r.logger.Error(nil, "adapter close timed out", "timeout", shutdownCloseTimeout) + } + + return nil +} + +func (r *Runner) flushLoop(ctx context.Context, done chan<- struct{}) { + defer close(done) + ticker := time.NewTicker(r.cfg.FlushInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := r.flush(ctx); err != nil { + r.logger.Error(err, "flush failed") + } + } + } +} + +func (r *Runner) flush(ctx context.Context) error { + start := time.Now() + _, err := r.adapter.Flush(ctx) + duration := time.Since(start) + + provider := r.adapter.Name() + r.metrics.flushDuration.WithLabelValues(provider).Observe(duration.Seconds()) + + if err != nil { + r.metrics.flushErrors.WithLabelValues(provider).Inc() + return fmt.Errorf("adapter flush: %w", err) + } + + r.mu.Lock() + session := r.session + if session == nil { + // No active session — retain offsets for the next session to commit. + r.mu.Unlock() + return nil + } + offsets := make(map[topicPartition]int64, len(r.offsets)) + for tp, o := range r.offsets { + offsets[tp] = o + } + r.offsets = make(map[topicPartition]int64) + r.mu.Unlock() + + for tp, offset := range offsets { + session.MarkOffset(tp.Topic, tp.Partition, offset+1, "") + } + session.Commit() + + return nil +} + +// --- sarama.ConsumerGroupHandler --- + +// Setup is called when a new consumer group session starts. +func (r *Runner) Setup(session sarama.ConsumerGroupSession) error { + r.mu.Lock() + r.session = session + r.mu.Unlock() + r.logger.Info("consumer group session started") + return nil +} + +// Cleanup is called when a consumer group session ends. +func (r *Runner) Cleanup(_ sarama.ConsumerGroupSession) error { + r.mu.Lock() + r.session = nil + r.mu.Unlock() + r.logger.Info("consumer group session ended") + return nil +} + +// ConsumeClaim processes messages from a partition claim. +func (r *Runner) ConsumeClaim( + session sarama.ConsumerGroupSession, + claim sarama.ConsumerGroupClaim, +) error { + provider := r.adapter.Name() + + for msg := range claim.Messages() { + r.processMessage(session.Context(), msg, provider) + } + return nil +} + +func (r *Runner) processMessage( + ctx context.Context, + msg *sarama.ConsumerMessage, + provider string, +) { + var ce cloudevents.Event + if err := json.Unmarshal(msg.Value, &ce); err != nil { + r.logger.Error(err, "failed to deserialize CloudEvent", + "topic", msg.Topic, "partition", msg.Partition, "offset", msg.Offset, + ) + r.metrics.eventsFailed.WithLabelValues(provider, "deserialization").Inc() + return + } + + eventID := ce.ID() + + if r.dedup.contains(eventID) { + r.metrics.duplicatesSuppressed.WithLabelValues(provider).Inc() + r.trackOffset(msg) + return + } + + r.checkOutOfOrder(ce, provider) + + event := MeteringEvent{ + CloudEvent: ce, + Topic: msg.Topic, + Partition: msg.Partition, + Offset: msg.Offset, + } + + result := submitWithRetry(ctx, r.adapter, event, r.cfg.MaxRetries, r.logger) + + if result.TotalDuration > 0 && result.Attempts > 1 { + r.metrics.retryDuration.WithLabelValues(provider).Observe(result.TotalDuration.Seconds()) + } + + if result.Err != nil { + if result.NonRetryable { + r.metrics.eventsFailed.WithLabelValues(provider, "non_retryable").Inc() + r.logger.Error(result.Err, "dropping non-retryable event", + "event_id", eventID, "topic", msg.Topic, + "partition", msg.Partition, "offset", msg.Offset, + ) + } else if result.Exhausted { + r.metrics.eventsFailed.WithLabelValues(provider, "retries_exhausted").Inc() + r.logger.Error(result.Err, "dropping event after retries exhausted", + "event_id", eventID, "topic", msg.Topic, + "partition", msg.Partition, "offset", msg.Offset, + ) + } else { + // Context cancelled (e.g., rebalance interrupted retry backoff). + // Do NOT track offset — the event will be redelivered to the new + // partition owner after rebalance completes. + r.logger.V(1).Info("submit interrupted, event will be redelivered", + "event_id", eventID, "topic", msg.Topic, + "partition", msg.Partition, "offset", msg.Offset, + "error", result.Err, + ) + return + } + // Track the offset for non-retryable and exhausted errors so the + // consumer does not redeliver the same poison message on every restart. + r.trackOffset(msg) + return + } + + r.dedup.add(eventID) + r.metrics.eventsSubmitted.WithLabelValues(provider, msg.Topic).Inc() + r.trackOffset(msg) +} + +func (r *Runner) trackOffset(msg *sarama.ConsumerMessage) { + tp := topicPartition{Topic: msg.Topic, Partition: msg.Partition} + r.mu.Lock() + if current, ok := r.offsets[tp]; !ok || msg.Offset > current { + r.offsets[tp] = msg.Offset + } + r.mu.Unlock() +} + +func (r *Runner) checkOutOfOrder(ce cloudevents.Event, provider string) { + resourceID, ok := ce.Extensions()["osacresourceid"] + if !ok { + return + } + + var data map[string]interface{} + if err := json.Unmarshal(ce.Data(), &data); err != nil { + return + } + + ttStr, ok := data["transition_time"].(string) + if !ok { + return + } + + tt, err := time.Parse(time.RFC3339, ttStr) + if err != nil { + return + } + + if r.order.check(fmt.Sprintf("%v", resourceID), tt) { + r.metrics.outOfOrderEvents.WithLabelValues(provider).Inc() + } +} diff --git a/osac-metering/adapters/runner_test.go b/osac-metering/adapters/runner_test.go new file mode 100644 index 000000000..74f57162d --- /dev/null +++ b/osac-metering/adapters/runner_test.go @@ -0,0 +1,530 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package adapters + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/IBM/sarama" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/go-logr/logr" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +// --- Mock types --- + +type mockAdapter struct { + mu sync.Mutex + name string + submitErr error + submitFn func(MeteringEvent) error + flushErr error + submitCalls []MeteringEvent + flushCalls int + closed bool +} + +func (m *mockAdapter) Name() string { return m.name } +func (m *mockAdapter) Submit(_ context.Context, event MeteringEvent) error { + m.mu.Lock() + defer m.mu.Unlock() + m.submitCalls = append(m.submitCalls, event) + if m.submitFn != nil { + return m.submitFn(event) + } + return m.submitErr +} +func (m *mockAdapter) Flush(_ context.Context) (SubmitResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.flushCalls++ + return SubmitResult{}, m.flushErr +} +func (m *mockAdapter) HealthCheck(_ context.Context) error { return nil } +func (m *mockAdapter) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + m.closed = true + return nil +} + +type mockSession struct { + ctx context.Context + mu sync.Mutex + marks []markEntry + committed int +} + +type markEntry struct { + topic string + partition int32 + offset int64 +} + +func (s *mockSession) Claims() map[string][]int32 { return nil } +func (s *mockSession) MemberID() string { return "test-member" } +func (s *mockSession) GenerationID() int32 { return 1 } +func (s *mockSession) MarkOffset(topic string, partition int32, offset int64, _ string) { + s.mu.Lock() + defer s.mu.Unlock() + s.marks = append(s.marks, markEntry{topic, partition, offset}) +} +func (s *mockSession) Commit() { + s.mu.Lock() + defer s.mu.Unlock() + s.committed++ +} +func (s *mockSession) ResetOffset(string, int32, int64, string) {} +func (s *mockSession) MarkMessage(*sarama.ConsumerMessage, string) {} +func (s *mockSession) Context() context.Context { return s.ctx } + +type mockClaim struct { + topic string + partition int32 + messages chan *sarama.ConsumerMessage +} + +func (c *mockClaim) Topic() string { return c.topic } +func (c *mockClaim) Partition() int32 { return c.partition } +func (c *mockClaim) InitialOffset() int64 { return 0 } +func (c *mockClaim) HighWaterMarkOffset() int64 { return 0 } +func (c *mockClaim) Messages() <-chan *sarama.ConsumerMessage { return c.messages } + +// --- Helpers --- + +func newTestMessage(id, resourceID string, offset int64) *sarama.ConsumerMessage { + return newTestMessageWithTime(id, resourceID, offset, time.Now().UTC()) +} + +func newTestMessageWithTime(id, resourceID string, offset int64, tt time.Time) *sarama.ConsumerMessage { + ce := cloudevents.NewEvent() + ce.SetID(id) + ce.SetType("osac.metering.lifecycle.started.v1") + ce.SetSource("osac-metering/test") + ce.SetExtension("osacresourceid", resourceID) + + data := map[string]string{"transition_time": tt.Format(time.RFC3339)} + dataBytes, _ := json.Marshal(data) + _ = ce.SetData(cloudevents.ApplicationJSON, dataBytes) + + ceJSON, _ := json.Marshal(ce) + return &sarama.ConsumerMessage{ + Topic: "osac.metering.lifecycle", + Partition: 0, + Offset: offset, + Value: ceJSON, + } +} + +func newTestMessageWithoutTransitionTime(id, resourceID string, offset int64) *sarama.ConsumerMessage { + ce := cloudevents.NewEvent() + ce.SetID(id) + ce.SetType("osac.inference.usage.v1") + ce.SetSource("osac-metering/test") + ce.SetExtension("osacresourceid", resourceID) + _ = ce.SetData(cloudevents.ApplicationJSON, json.RawMessage(`{"usage": 42}`)) + + ceJSON, _ := json.Marshal(ce) + return &sarama.ConsumerMessage{ + Topic: "osac.metering.lifecycle", + Partition: 0, + Offset: offset, + Value: ceJSON, + } +} + +func newRunner(adapter ProviderAdapter) *Runner { + return NewRunner(adapter, RunnerConfig{ + Brokers: "localhost:9092", + ConsumerGroup: "test-group", + Topics: []string{"osac.metering.lifecycle"}, + FlushInterval: 10 * time.Second, + DedupTTL: 10 * time.Minute, + MaxRetries: 3, + }, logr.Discard()) +} + +func feedMessages(claim *mockClaim, msgs ...*sarama.ConsumerMessage) { + go func() { + for _, msg := range msgs { + claim.messages <- msg + } + close(claim.messages) + }() +} + +// --- Tests --- + +var _ = Describe("Runner", func() { + var ( + adapter *mockAdapter + runner *Runner + session *mockSession + claim *mockClaim + ) + + BeforeEach(func() { + adapter = &mockAdapter{name: "test-provider"} + runner = newRunner(adapter) + session = &mockSession{ctx: context.Background()} + claim = &mockClaim{ + topic: "osac.metering.lifecycle", + partition: 0, + messages: make(chan *sarama.ConsumerMessage, 10), + } + // Set session on runner (simulates Setup call) + _ = runner.Setup(session) + }) + + Describe("ConsumeClaim — message processing", func() { + It("calls Submit for each valid message", func() { + feedMessages(claim, + newTestMessage("evt-1", "res-1", 0), + newTestMessage("evt-2", "res-2", 1), + ) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + adapter.mu.Lock() + defer adapter.mu.Unlock() + Expect(adapter.submitCalls).To(HaveLen(2)) + Expect(adapter.submitCalls[0].CloudEvent.ID()).To(Equal("evt-1")) + Expect(adapter.submitCalls[1].CloudEvent.ID()).To(Equal("evt-2")) + }) + + It("tracks offsets for commit", func() { + feedMessages(claim, newTestMessage("evt-1", "res-1", 5)) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + runner.mu.Lock() + defer runner.mu.Unlock() + tp := topicPartition{Topic: "osac.metering.lifecycle", Partition: 0} + Expect(runner.offsets[tp]).To(Equal(int64(5))) + }) + + It("increments events_submitted_total metric", func() { + feedMessages(claim, newTestMessage("evt-1", "res-1", 0)) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + val := testutil.ToFloat64( + runner.metrics.eventsSubmitted.WithLabelValues("test-provider", "osac.metering.lifecycle"), + ) + Expect(val).To(Equal(float64(1))) + }) + }) + + Describe("ConsumeClaim — dedup suppression", func() { + It("suppresses duplicate CloudEvent IDs", func() { + feedMessages(claim, + newTestMessage("evt-1", "res-1", 0), + newTestMessage("evt-1", "res-1", 1), // duplicate ID + ) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + adapter.mu.Lock() + defer adapter.mu.Unlock() + Expect(adapter.submitCalls).To(HaveLen(1)) + + val := testutil.ToFloat64(runner.metrics.duplicatesSuppressed.WithLabelValues("test-provider")) + Expect(val).To(Equal(float64(1))) + }) + + It("tracks offsets for duplicate messages", func() { + feedMessages(claim, + newTestMessage("evt-1", "res-1", 0), + newTestMessage("evt-1", "res-1", 3), // duplicate ID at higher offset + ) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + runner.mu.Lock() + defer runner.mu.Unlock() + tp := topicPartition{Topic: "osac.metering.lifecycle", Partition: 0} + Expect(runner.offsets[tp]).To(Equal(int64(3))) + }) + }) + + Describe("ConsumeClaim — out-of-order detection", func() { + It("detects out-of-order events and increments metric", func() { + t1 := time.Date(2026, 8, 5, 10, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC) // earlier + + feedMessages(claim, + newTestMessageWithTime("evt-1", "res-1", 0, t1), + newTestMessageWithTime("evt-2", "res-1", 1, t2), + ) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + // Both events are submitted (out-of-order events still go to Submit) + adapter.mu.Lock() + defer adapter.mu.Unlock() + Expect(adapter.submitCalls).To(HaveLen(2)) + + val := testutil.ToFloat64(runner.metrics.outOfOrderEvents.WithLabelValues("test-provider")) + Expect(val).To(Equal(float64(1))) + }) + + It("skips detection for events without transition_time", func() { + feedMessages(claim, + newTestMessageWithoutTransitionTime("evt-1", "res-1", 0), + ) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + adapter.mu.Lock() + defer adapter.mu.Unlock() + Expect(adapter.submitCalls).To(HaveLen(1)) + + val := testutil.ToFloat64(runner.metrics.outOfOrderEvents.WithLabelValues("test-provider")) + Expect(val).To(Equal(float64(0))) + }) + }) + + Describe("ConsumeClaim — error handling", func() { + It("skips messages with invalid JSON", func() { + msg := &sarama.ConsumerMessage{ + Topic: "osac.metering.lifecycle", + Partition: 0, + Offset: 0, + Value: []byte("not valid json{{{"), + } + feedMessages(claim, msg) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + adapter.mu.Lock() + defer adapter.mu.Unlock() + Expect(adapter.submitCalls).To(HaveLen(0)) + + val := testutil.ToFloat64(runner.metrics.eventsFailed.WithLabelValues("test-provider", "deserialization")) + Expect(val).To(Equal(float64(1))) + }) + + It("increments non_retryable metric on NonRetryableError", func() { + adapter.submitErr = &NonRetryableError{Err: errors.New("bad schema")} + feedMessages(claim, newTestMessage("evt-1", "res-1", 0)) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + val := testutil.ToFloat64(runner.metrics.eventsFailed.WithLabelValues("test-provider", "non_retryable")) + Expect(val).To(Equal(float64(1))) + }) + + It("increments retries_exhausted metric after max retries", func() { + adapter.submitErr = errors.New("always fails") + feedMessages(claim, newTestMessage("evt-1", "res-1", 0)) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + val := testutil.ToFloat64(runner.metrics.eventsFailed.WithLabelValues("test-provider", "retries_exhausted")) + Expect(val).To(Equal(float64(1))) + }) + + It("tracks offsets for non-retryable errors", func() { + adapter.submitErr = &NonRetryableError{Err: errors.New("bad schema")} + feedMessages(claim, newTestMessage("evt-1", "res-1", 7)) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + runner.mu.Lock() + defer runner.mu.Unlock() + tp := topicPartition{Topic: "osac.metering.lifecycle", Partition: 0} + Expect(runner.offsets[tp]).To(Equal(int64(7))) + }) + + It("tracks offsets for exhausted retries", func() { + adapter.submitErr = errors.New("always fails") + feedMessages(claim, newTestMessage("evt-1", "res-1", 12)) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + runner.mu.Lock() + defer runner.mu.Unlock() + tp := topicPartition{Topic: "osac.metering.lifecycle", Partition: 0} + Expect(runner.offsets[tp]).To(Equal(int64(12))) + }) + + It("does not track offset when context is cancelled during retry", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancelSession := &mockSession{ctx: ctx} + _ = runner.Setup(cancelSession) + + adapter.submitErr = errors.New("temporary failure") + + origSleep := sleepFunc + sleepFunc = func(sleepCtx context.Context, _ time.Duration) error { + cancel() + return sleepCtx.Err() + } + defer func() { sleepFunc = origSleep }() + + msg := newTestMessage("evt-1", "res-1", 10) + runner.processMessage(ctx, msg, "test-provider") + + runner.mu.Lock() + defer runner.mu.Unlock() + tp := topicPartition{Topic: "osac.metering.lifecycle", Partition: 0} + _, tracked := runner.offsets[tp] + Expect(tracked).To(BeFalse(), "offset should not be tracked when context is cancelled") + }) + }) + + Describe("flush", func() { + It("commits offsets on successful flush", func() { + // Simulate processed messages by setting tracked offsets + runner.mu.Lock() + runner.offsets[topicPartition{Topic: "osac.metering.lifecycle", Partition: 0}] = 5 + runner.mu.Unlock() + + err := runner.flush(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + session.mu.Lock() + defer session.mu.Unlock() + Expect(session.marks).To(HaveLen(1)) + Expect(session.marks[0]).To(Equal(markEntry{ + topic: "osac.metering.lifecycle", partition: 0, offset: 6, // offset+1 + })) + Expect(session.committed).To(Equal(1)) + }) + + It("does not commit offsets when flush fails", func() { + adapter.flushErr = errors.New("provider unavailable") + runner.mu.Lock() + runner.offsets[topicPartition{Topic: "osac.metering.lifecycle", Partition: 0}] = 5 + runner.mu.Unlock() + + err := runner.flush(context.Background()) + Expect(err).To(HaveOccurred()) + + session.mu.Lock() + defer session.mu.Unlock() + Expect(session.marks).To(BeEmpty()) + Expect(session.committed).To(Equal(0)) + + val := testutil.ToFloat64(runner.metrics.flushErrors.WithLabelValues("test-provider")) + Expect(val).To(Equal(float64(1))) + }) + + It("clears tracked offsets after successful flush", func() { + runner.mu.Lock() + runner.offsets[topicPartition{Topic: "osac.metering.lifecycle", Partition: 0}] = 5 + runner.mu.Unlock() + + err := runner.flush(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + runner.mu.Lock() + defer runner.mu.Unlock() + Expect(runner.offsets).To(BeEmpty()) + }) + + It("records flush duration in histogram", func() { + err := runner.flush(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + count := testutil.CollectAndCount(runner.metrics.flushDuration) + Expect(count).To(Equal(1)) + }) + + It("preserves offsets when no session is active", func() { + // Simulate session ending (rebalance) + _ = runner.Cleanup(session) + + runner.mu.Lock() + runner.offsets[topicPartition{Topic: "osac.metering.lifecycle", Partition: 0}] = 5 + runner.mu.Unlock() + + err := runner.flush(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + // Offsets should be retained for the next session + runner.mu.Lock() + defer runner.mu.Unlock() + tp := topicPartition{Topic: "osac.metering.lifecycle", Partition: 0} + Expect(runner.offsets[tp]).To(Equal(int64(5))) + }) + }) + + Describe("MetricsHandler", func() { + It("returns a non-nil HTTP handler", func() { + handler := runner.MetricsHandler() + Expect(handler).NotTo(BeNil()) + }) + }) + + Describe("Setup and Cleanup", func() { + It("stores and clears the session", func() { + newSession := &mockSession{ctx: context.Background()} + err := runner.Setup(newSession) + Expect(err).NotTo(HaveOccurred()) + + runner.mu.Lock() + Expect(runner.session).To(Equal(newSession)) + runner.mu.Unlock() + + err = runner.Cleanup(newSession) + Expect(err).NotTo(HaveOccurred()) + + runner.mu.Lock() + Expect(runner.session).To(BeNil()) + runner.mu.Unlock() + }) + }) + + Describe("end-to-end: ConsumeClaim → flush → commit", func() { + It("processes messages and commits offsets on flush", func() { + feedMessages(claim, + newTestMessage("evt-1", "res-1", 0), + newTestMessage("evt-2", "res-2", 1), + newTestMessage("evt-3", "res-3", 2), + ) + + err := runner.ConsumeClaim(session, claim) + Expect(err).NotTo(HaveOccurred()) + + err = runner.flush(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + adapter.mu.Lock() + Expect(adapter.submitCalls).To(HaveLen(3)) + Expect(adapter.flushCalls).To(Equal(1)) + adapter.mu.Unlock() + + session.mu.Lock() + defer session.mu.Unlock() + Expect(session.marks).To(HaveLen(1)) + Expect(session.marks[0].offset).To(Equal(int64(3))) // highest offset (2) + 1 + Expect(session.committed).To(Equal(1)) + }) + }) +}) From be0d2c5636be083fd2dd608de4b9896e3272057a Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Wed, 5 Aug 2026 22:05:44 +0300 Subject: [PATCH 08/13] OSAC-3428: standardize copyright headers across adapters package Signed-off-by: Amit Oren --- osac-metering/adapters/metrics.go | 10 ++++++++-- osac-metering/adapters/metrics_test.go | 10 ++++++++-- osac-metering/adapters/order.go | 22 ++++++++-------------- osac-metering/adapters/order_test.go | 9 +++++++++ osac-metering/adapters/retry.go | 21 ++++++++------------- osac-metering/adapters/retry_test.go | 21 ++++++++------------- 6 files changed, 49 insertions(+), 44 deletions(-) diff --git a/osac-metering/adapters/metrics.go b/osac-metering/adapters/metrics.go index 2cf7fda14..cc70e83a4 100644 --- a/osac-metering/adapters/metrics.go +++ b/osac-metering/adapters/metrics.go @@ -1,5 +1,11 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright Red Hat, Inc. 2026 +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ package adapters diff --git a/osac-metering/adapters/metrics_test.go b/osac-metering/adapters/metrics_test.go index c882999e4..c07720253 100644 --- a/osac-metering/adapters/metrics_test.go +++ b/osac-metering/adapters/metrics_test.go @@ -1,5 +1,11 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright Red Hat, Inc. 2026 +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ package adapters diff --git a/osac-metering/adapters/order.go b/osac-metering/adapters/order.go index 8cf3e6370..66f46b76d 100644 --- a/osac-metering/adapters/order.go +++ b/osac-metering/adapters/order.go @@ -1,17 +1,11 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Red Hat, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ package adapters diff --git a/osac-metering/adapters/order_test.go b/osac-metering/adapters/order_test.go index 23bbc0c04..221f13d9d 100644 --- a/osac-metering/adapters/order_test.go +++ b/osac-metering/adapters/order_test.go @@ -1,3 +1,12 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + package adapters import ( diff --git a/osac-metering/adapters/retry.go b/osac-metering/adapters/retry.go index 4cd54d376..c5b9f8112 100644 --- a/osac-metering/adapters/retry.go +++ b/osac-metering/adapters/retry.go @@ -1,16 +1,11 @@ -// Copyright 2026 Red Hat, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ package adapters diff --git a/osac-metering/adapters/retry_test.go b/osac-metering/adapters/retry_test.go index c37f089ea..d6a4ef3c7 100644 --- a/osac-metering/adapters/retry_test.go +++ b/osac-metering/adapters/retry_test.go @@ -1,16 +1,11 @@ -// Copyright 2026 Red Hat, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ package adapters From c39380cfd6d5ce9b11d52f8f55b534f78ef098f6 Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Thu, 6 Aug 2026 09:03:32 +0300 Subject: [PATCH 09/13] OSAC-3428: update go.mod with sarama and prometheus dependencies Signed-off-by: Amit Oren --- osac-metering/adapters/go.mod | 39 ++++++++-- osac-metering/adapters/go.sum | 130 ++++++++++++++++++++++++++++------ 2 files changed, 142 insertions(+), 27 deletions(-) diff --git a/osac-metering/adapters/go.mod b/osac-metering/adapters/go.mod index 30a7cc387..1389cc072 100644 --- a/osac-metering/adapters/go.mod +++ b/osac-metering/adapters/go.mod @@ -3,28 +3,53 @@ module github.com/osac-project/osac-metering/adapters go 1.26.3 require ( + github.com/IBM/sarama v1.60.1 github.com/cloudevents/sdk-go/v2 v2.16.2 + github.com/go-logr/logr v1.4.3 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 + github.com/prometheus/client_golang v1.24.1 + github.com/xdg-go/scram v1.2.0 ) require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/eapache/go-resiliency v1.7.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pierrec/lz4/v4 v4.1.27 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.57.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 + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/osac-metering/adapters/go.sum b/osac-metering/adapters/go.sum index 28ed01e57..673eee158 100644 --- a/osac-metering/adapters/go.sum +++ b/osac-metering/adapters/go.sum @@ -1,10 +1,18 @@ +github.com/IBM/sarama v1.60.1 h1:2IjpLPCL16CvaJcpxUT5+zE6tpeY5HdhREZOES80kGE= +github.com/IBM/sarama v1.60.1/go.mod h1:ugg061kdM8zE4mgCeCUwDMd9NRd7QIRMoiA4a/Z8VH8= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +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/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= 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/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= +github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -24,14 +32,35 @@ github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oX github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= 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/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -41,20 +70,38 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/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/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -65,32 +112,75 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +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/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +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/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +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= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= From da57a65ec8d398e393754d85832e2c2ad6a8d6bf Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Thu, 6 Aug 2026 10:58:20 +0300 Subject: [PATCH 10/13] OSAC-3428: add adapters go.mod to all Containerfiles go.work now lists osac-metering/adapters as a workspace member, but the Containerfiles did not copy its go.mod/go.sum into the build context, causing go mod download to fail. Signed-off-by: Amit Oren --- bare-metal-fulfillment-operator/Containerfile | 1 + fulfillment-service/Containerfile | 1 + osac-csi-driver/Containerfile | 1 + osac-operator/Containerfile | 1 + 4 files changed, 4 insertions(+) diff --git a/bare-metal-fulfillment-operator/Containerfile b/bare-metal-fulfillment-operator/Containerfile index 4fe68550e..b7a274016 100644 --- a/bare-metal-fulfillment-operator/Containerfile +++ b/bare-metal-fulfillment-operator/Containerfile @@ -15,6 +15,7 @@ COPY --chown=1001:1001 osac-operator/api/go.mod osac-operator/api/go.sum osac-op COPY --chown=1001:1001 fulfillment-service/go.mod fulfillment-service/go.sum fulfillment-service/ COPY --chown=1001:1001 osac-csi-driver/go.mod osac-csi-driver/go.sum osac-csi-driver/ COPY --chown=1001:1001 osac-metering/metering-service/go.mod osac-metering/metering-service/go.sum osac-metering/metering-service/ +COPY --chown=1001:1001 osac-metering/adapters/go.mod osac-metering/adapters/go.sum osac-metering/adapters/ RUN cd bare-metal-fulfillment-operator && go mod download COPY --chown=1001:1001 bare-metal-fulfillment-operator/ bare-metal-fulfillment-operator/ diff --git a/fulfillment-service/Containerfile b/fulfillment-service/Containerfile index 811925f39..05be12669 100644 --- a/fulfillment-service/Containerfile +++ b/fulfillment-service/Containerfile @@ -16,6 +16,7 @@ COPY --chown=1001:1001 osac-operator/go.mod osac-operator/go.sum osac-operator/ COPY --chown=1001:1001 osac-operator/api/go.mod osac-operator/api/go.sum osac-operator/api/ COPY --chown=1001:1001 osac-csi-driver/go.mod osac-csi-driver/go.sum osac-csi-driver/ COPY --chown=1001:1001 osac-metering/metering-service/go.mod osac-metering/metering-service/go.sum osac-metering/metering-service/ +COPY --chown=1001:1001 osac-metering/adapters/go.mod osac-metering/adapters/go.sum osac-metering/adapters/ RUN \ set -e; \ cd fulfillment-service && go mod download diff --git a/osac-csi-driver/Containerfile b/osac-csi-driver/Containerfile index 77151e39b..df559c765 100644 --- a/osac-csi-driver/Containerfile +++ b/osac-csi-driver/Containerfile @@ -15,6 +15,7 @@ COPY --chown=1001:1001 bare-metal-fulfillment-operator/go.mod bare-metal-fulfill COPY --chown=1001:1001 osac-operator/go.mod osac-operator/go.sum osac-operator/ COPY --chown=1001:1001 osac-operator/api/go.mod osac-operator/api/go.sum osac-operator/api/ COPY --chown=1001:1001 osac-metering/metering-service/go.mod osac-metering/metering-service/go.sum osac-metering/metering-service/ +COPY --chown=1001:1001 osac-metering/adapters/go.mod osac-metering/adapters/go.sum osac-metering/adapters/ RUN cd osac-csi-driver && go mod download COPY --chown=1001:1001 osac-csi-driver/ osac-csi-driver/ diff --git a/osac-operator/Containerfile b/osac-operator/Containerfile index c481d8fc4..c5951d7a4 100644 --- a/osac-operator/Containerfile +++ b/osac-operator/Containerfile @@ -15,6 +15,7 @@ COPY --chown=1001:1001 bare-metal-fulfillment-operator/go.mod bare-metal-fulfill COPY --chown=1001:1001 fulfillment-service/go.mod fulfillment-service/go.sum fulfillment-service/ COPY --chown=1001:1001 osac-csi-driver/go.mod osac-csi-driver/go.sum osac-csi-driver/ COPY --chown=1001:1001 osac-metering/metering-service/go.mod osac-metering/metering-service/go.sum osac-metering/metering-service/ +COPY --chown=1001:1001 osac-metering/adapters/go.mod osac-metering/adapters/go.sum osac-metering/adapters/ RUN cd osac-operator && go mod download COPY --chown=1001:1001 osac-operator/ osac-operator/ From 4521ac1d9686225dd12e1d0410d4c0c2bb7192c6 Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Sun, 9 Aug 2026 09:11:17 +0300 Subject: [PATCH 11/13] OSAC-3428: add TLS toggle to KafkaConfig and kafka_test.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TLSEnabled field to KafkaConfig so TLS is only configured when explicitly requested — prevents connection failures to plaintext Kafka brokers. Add unit tests for newConsumerConfig TLS/non-TLS paths and splitAndTrimBrokers. Signed-off-by: Amit Oren --- osac-metering/adapters/adapter.go | 1 + osac-metering/adapters/kafka.go | 6 ++-- osac-metering/adapters/kafka_test.go | 54 ++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 osac-metering/adapters/kafka_test.go diff --git a/osac-metering/adapters/adapter.go b/osac-metering/adapters/adapter.go index 28e80d71a..855234978 100644 --- a/osac-metering/adapters/adapter.go +++ b/osac-metering/adapters/adapter.go @@ -62,6 +62,7 @@ func (e *NonRetryableError) Unwrap() error { return e.Err } // KafkaConfig configures the Kafka consumer connection. type KafkaConfig struct { + TLSEnabled bool // Enable TLS for broker connections TLSCACert string // Path to CA certificate file (empty = system CAs) SASLUser string // SASL/SCRAM username SASLPassFile string // Path to file containing SASL password diff --git a/osac-metering/adapters/kafka.go b/osac-metering/adapters/kafka.go index b75d8bc74..7efc7b018 100644 --- a/osac-metering/adapters/kafka.go +++ b/osac-metering/adapters/kafka.go @@ -31,8 +31,10 @@ func newConsumerConfig(cfg KafkaConfig) (*sarama.Config, error) { sarama.NewBalanceStrategyRange(), } - if err := configureConsumerTLS(sc, cfg.TLSCACert); err != nil { - return nil, err + if cfg.TLSEnabled { + if err := configureConsumerTLS(sc, cfg.TLSCACert); err != nil { + return nil, err + } } if cfg.SASLUser != "" { if err := configureConsumerSASL(sc, cfg.SASLUser, cfg.SASLPassFile); err != nil { diff --git a/osac-metering/adapters/kafka_test.go b/osac-metering/adapters/kafka_test.go new file mode 100644 index 000000000..9cb794240 --- /dev/null +++ b/osac-metering/adapters/kafka_test.go @@ -0,0 +1,54 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package adapters + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("newConsumerConfig", func() { + It("disables TLS when TLSEnabled is false", func() { + sc, err := newConsumerConfig(KafkaConfig{TLSEnabled: false}) + Expect(err).NotTo(HaveOccurred()) + Expect(sc.Net.TLS.Enable).To(BeFalse()) + }) + + It("enables TLS when TLSEnabled is true", func() { + sc, err := newConsumerConfig(KafkaConfig{TLSEnabled: true}) + Expect(err).NotTo(HaveOccurred()) + Expect(sc.Net.TLS.Enable).To(BeTrue()) + Expect(sc.Net.TLS.Config).NotTo(BeNil()) + }) + + It("sets correct consumer defaults", func() { + sc, err := newConsumerConfig(KafkaConfig{}) + Expect(err).NotTo(HaveOccurred()) + Expect(sc.Consumer.Return.Errors).To(BeTrue()) + Expect(sc.Consumer.Offsets.AutoCommit.Enable).To(BeFalse()) + }) +}) + +var _ = Describe("splitAndTrimBrokers", func() { + It("splits and trims broker addresses", func() { + result := splitAndTrimBrokers(" broker1:9092 , broker2:9092 , ", ",") + Expect(result).To(Equal([]string{"broker1:9092", "broker2:9092"})) + }) + + It("handles a single broker", func() { + result := splitAndTrimBrokers("broker1:9092", ",") + Expect(result).To(Equal([]string{"broker1:9092"})) + }) + + It("skips empty segments", func() { + result := splitAndTrimBrokers("broker1:9092,,broker2:9092", ",") + Expect(result).To(Equal([]string{"broker1:9092", "broker2:9092"})) + }) +}) From 59951e622c58093424b11f32932997a0d3d26aaa Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Sun, 9 Aug 2026 09:11:23 +0300 Subject: [PATCH 12/13] OSAC-3428: add Makefile and golangci-lint config for adapters Add CI scaffolding matching the sibling metering-service module convention: ginkgo test runner, golangci-lint v2.12.1 with errcheck, govet, staticcheck, unused, misspell, revive, and goimports formatter. Signed-off-by: Amit Oren --- .github/workflows/unit-tests.yml | 16 ++++++++++++++++ .pre-commit-config.yaml | 6 ++++++ osac-metering/adapters/.golangci.yml | 20 ++++++++++++++++++++ osac-metering/adapters/Makefile | 27 +++++++++++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 osac-metering/adapters/.golangci.yml create mode 100644 osac-metering/adapters/Makefile diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 2ece93ec5..0e1409118 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -84,3 +84,19 @@ jobs: - working-directory: osac-metering/metering-service run: | ginkgo run -r internal + + run-osac-metering-adapters-tests: + name: Run unit tests (osac-metering/adapters) + needs: changes + if: needs.changes.outputs.should-run == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-go + with: + working-directory: osac-metering/adapters + - working-directory: osac-metering/adapters + run: | + ginkgo run . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3482b1bc5..d3e117edc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -79,3 +79,9 @@ repos: language: system files: ^osac-metering/metering-service/.*\.go$ entry: make -C osac-metering/metering-service lint + - id: osac-metering-adapters-golangci-lint + pass_filenames: false + name: osac-metering-adapters golangci-lint + language: system + files: ^osac-metering/adapters/(.*\.go|\.golangci\.yml|Makefile|go\.(mod|sum))$ + entry: make -C osac-metering/adapters lint diff --git a/osac-metering/adapters/.golangci.yml b/osac-metering/adapters/.golangci.yml new file mode 100644 index 000000000..683e1792d --- /dev/null +++ b/osac-metering/adapters/.golangci.yml @@ -0,0 +1,20 @@ +version: "2" +run: + timeout: 5m +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell + - revive + settings: + revive: + rules: + - name: exported + disabled: true +formatters: + enable: + - goimports diff --git a/osac-metering/adapters/Makefile b/osac-metering/adapters/Makefile new file mode 100644 index 000000000..769101205 --- /dev/null +++ b/osac-metering/adapters/Makefile @@ -0,0 +1,27 @@ +# Copyright (c) 2026 Red Hat Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +GINKGO ?= go run github.com/onsi/ginkgo/v2/ginkgo +GOLANGCI_LINT_VERSION ?= v2.12.1 + +LOCALBIN ?= $(shell pwd)/bin +GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint + +.PHONY: test lint clean + +test: + $(GINKGO) run . + +lint: $(GOLANGCI_LINT) + $(GOLANGCI_LINT) run . + +$(GOLANGCI_LINT): + @mkdir -p $(LOCALBIN) + GOBIN=$(LOCALBIN) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + +clean: + rm -rf bin/ From f77baae99bcf421afb081c616eac0f8762fcf43f Mon Sep 17 00:00:00 2001 From: Amit Oren Date: Sun, 9 Aug 2026 11:38:43 +0300 Subject: [PATCH 13/13] OSAC-3763: replace test-adapter with echo-adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the raw-Sarama test-adapter (metering-service/cmd/test-adapter/) and replace it with the echo-adapter (adapters/cmd/echo-adapter/) which exercises the full adapters.Runner framework lifecycle: dedup, out-of-order detection, retry with backoff, flush, and offset commit. The echo-adapter exposes an HTTP query API for E2E test assertions: - GET /events — list events with filters (type, resource_id, since, limit) - GET /events/count — count matching events - GET /events/{id} — lookup by CloudEvent ID - DELETE /events — clear buffer for test isolation - GET /metrics — Prometheus metrics - GET /healthz — health check Events are also logged to stdout for kubectl logs debugging. Changes: - Add echo-adapter binary under adapters/cmd/echo-adapter/ - Add Containerfile.echo-adapter for image builds - Rename chart templates from test-adapter to echo-adapter - Rename CI workflow to build-metering-echo-adapter-image.yaml - Update KafkaUser, RBAC, and values.yaml references - Add build-echo-adapter target to adapters Makefile - Remove test-adapter source, Containerfile, and Makefile target Signed-off-by: Amit Oren --- ...=> build-metering-echo-adapter-image.yaml} | 12 +- .github/workflows/e2e-vmaas-full-install.yml | 2 +- osac-installer/values/vmaas-ci/values.yaml | 8 +- .../Containerfile.echo-adapter} | 6 +- osac-metering/adapters/Makefile | 6 +- .../adapters/cmd/echo-adapter/main.go | 181 +++++++++++++ .../adapters/cmd/echo-adapter/store.go | 237 ++++++++++++++++++ osac-metering/adapters/go.mod | 1 + osac-metering/adapters/go.sum | 3 + ...ment.yaml => echo-adapter-deployment.yaml} | 26 +- ...user.yaml => echo-adapter-kafka-user.yaml} | 6 +- .../templates/echo-adapter-route.yaml | 17 ++ ...service.yaml => echo-adapter-service.yaml} | 8 +- .../templates/kafka-secrets-rbac.yaml | 4 +- .../charts/osac-metering/values.yaml | 12 +- osac-metering/metering-service/Makefile | 3 - .../metering-service/cmd/test-adapter/main.go | 199 --------------- 17 files changed, 493 insertions(+), 238 deletions(-) rename .github/workflows/{build-metering-test-adapter-image.yaml => build-metering-echo-adapter-image.yaml} (88%) rename osac-metering/{metering-service/Containerfile.test-adapter => adapters/Containerfile.echo-adapter} (69%) create mode 100644 osac-metering/adapters/cmd/echo-adapter/main.go create mode 100644 osac-metering/adapters/cmd/echo-adapter/store.go rename osac-metering/charts/osac-metering/templates/{test-adapter-deployment.yaml => echo-adapter-deployment.yaml} (84%) rename osac-metering/charts/osac-metering/templates/{test-adapter-kafka-user.yaml => echo-adapter-kafka-user.yaml} (84%) create mode 100644 osac-metering/charts/osac-metering/templates/echo-adapter-route.yaml rename osac-metering/charts/osac-metering/templates/{test-adapter-service.yaml => echo-adapter-service.yaml} (61%) delete mode 100644 osac-metering/metering-service/cmd/test-adapter/main.go diff --git a/.github/workflows/build-metering-test-adapter-image.yaml b/.github/workflows/build-metering-echo-adapter-image.yaml similarity index 88% rename from .github/workflows/build-metering-test-adapter-image.yaml rename to .github/workflows/build-metering-echo-adapter-image.yaml index 5456a1ab9..a969a2ec3 100644 --- a/.github/workflows/build-metering-test-adapter-image.yaml +++ b/.github/workflows/build-metering-echo-adapter-image.yaml @@ -1,11 +1,11 @@ -name: Build metering-test-adapter image +name: Build metering-echo-adapter image on: workflow_dispatch: pull_request: paths: - - 'osac-metering/metering-service/**' - - '!osac-metering/metering-service/**/*.md' + - 'osac-metering/adapters/**' + - '!osac-metering/adapters/**/*.md' push: branches: - main @@ -18,7 +18,7 @@ concurrency: env: REGISTRY: ghcr.io - IMAGE_NAME: osac-project/metering-test-adapter + IMAGE_NAME: osac-project/metering-echo-adapter jobs: build: @@ -70,8 +70,8 @@ jobs: - name: Build and push Docker image uses: docker/build-push-action@v7 with: - context: osac-metering/metering-service - file: osac-metering/metering-service/Containerfile.test-adapter + context: osac-metering/adapters + file: osac-metering/adapters/Containerfile.echo-adapter push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/e2e-vmaas-full-install.yml b/.github/workflows/e2e-vmaas-full-install.yml index e5ed9b25c..2abc2094e 100644 --- a/.github/workflows/e2e-vmaas-full-install.yml +++ b/.github/workflows/e2e-vmaas-full-install.yml @@ -94,7 +94,7 @@ jobs: # override mechanism keys strictly off imageKey, one build per key, so this # is the only way to get both fields covered without changing that mechanism # (which lives in osac-test-infra, not here). See OSAC-3546. - components: '[{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"fulfillment-service/Containerfile","context":".","imageKey":"service.images.service"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"osac-operator/Containerfile","context":".","imageKey":"operator.image.repository"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"osac-aap/execution-environment/execution-environment.yaml","imageKey":"aap.bootstrap.image","buildType":"ansible-builder"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"osac-aap/execution-environment/execution-environment.yaml","imageKey":"aap.configAsCode.eeImage","buildType":"ansible-builder"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"bare-metal-fulfillment-operator/Containerfile","context":".","imageKey":"bmf.image.repository"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"osac-metering/metering-service/Containerfile","imageKey":"metering.image.repository"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"osac-metering/metering-service/Containerfile.test-adapter","imageKey":"metering.testAdapter.image.repository"}]' + components: '[{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"fulfillment-service/Containerfile","context":".","imageKey":"service.images.service"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"osac-operator/Containerfile","context":".","imageKey":"operator.image.repository"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"osac-aap/execution-environment/execution-environment.yaml","imageKey":"aap.bootstrap.image","buildType":"ansible-builder"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"osac-aap/execution-environment/execution-environment.yaml","imageKey":"aap.configAsCode.eeImage","buildType":"ansible-builder"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"bare-metal-fulfillment-operator/Containerfile","context":".","imageKey":"bmf.image.repository"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"osac-metering/metering-service/Containerfile","imageKey":"metering.image.repository"},{"repo":"${{ github.event.pull_request.head.repo.full_name || github.repository }}","ref":"${{ github.event.pull_request.head.ref || github.ref_name }}","containerfile":"osac-metering/adapters/Containerfile.echo-adapter","imageKey":"metering.echoAdapter.image.repository"}]' # Pass author_association for fork PR authorization (empty for non-fork PRs) fork-pr-author-association: ${{ github.event.pull_request.head.repo.fork && github.event.pull_request.author_association || '' }} fork-pr-author: ${{ github.event.pull_request.head.repo.fork && github.event.pull_request.user.login || '' }} diff --git a/osac-installer/values/vmaas-ci/values.yaml b/osac-installer/values/vmaas-ci/values.yaml index d7969af0a..76f95535a 100644 --- a/osac-installer/values/vmaas-ci/values.yaml +++ b/osac-installer/values/vmaas-ci/values.yaml @@ -179,8 +179,14 @@ kafka: # --- Metering --- metering: enabled: true - testAdapter: + # Echo adapter — test/development tool only. Consumes metering events from + # Kafka and exposes them via HTTP for E2E test assertions. + echoAdapter: enabled: true + image: + repository: ghcr.io/osac-project/metering-echo-adapter + tag: latest + pullPolicy: Always database: connection: - secret: diff --git a/osac-metering/metering-service/Containerfile.test-adapter b/osac-metering/adapters/Containerfile.echo-adapter similarity index 69% rename from osac-metering/metering-service/Containerfile.test-adapter rename to osac-metering/adapters/Containerfile.echo-adapter index 6f6600446..ece3c25d4 100644 --- a/osac-metering/metering-service/Containerfile.test-adapter +++ b/osac-metering/adapters/Containerfile.echo-adapter @@ -8,10 +8,10 @@ RUN go mod download COPY . ./ -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -buildvcs=false -a -o test-adapter ./cmd/test-adapter +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -buildvcs=false -a -o echo-adapter ./cmd/echo-adapter FROM registry.access.redhat.com/ubi10-minimal:10.2 WORKDIR / -COPY --from=builder /opt/app-root/src/test-adapter . +COPY --from=builder /opt/app-root/src/echo-adapter . USER 65532:65532 -ENTRYPOINT ["/test-adapter"] +ENTRYPOINT ["/echo-adapter"] diff --git a/osac-metering/adapters/Makefile b/osac-metering/adapters/Makefile index 769101205..04cc27bdd 100644 --- a/osac-metering/adapters/Makefile +++ b/osac-metering/adapters/Makefile @@ -11,7 +11,11 @@ GOLANGCI_LINT_VERSION ?= v2.12.1 LOCALBIN ?= $(shell pwd)/bin GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint -.PHONY: test lint clean +.PHONY: build-echo-adapter test lint clean + +build-echo-adapter: + @mkdir -p bin + go build -o bin/echo-adapter ./cmd/echo-adapter test: $(GINKGO) run . diff --git a/osac-metering/adapters/cmd/echo-adapter/main.go b/osac-metering/adapters/cmd/echo-adapter/main.go new file mode 100644 index 000000000..74c7e8fa0 --- /dev/null +++ b/osac-metering/adapters/cmd/echo-adapter/main.go @@ -0,0 +1,181 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +// echo-adapter is a test binary that consumes metering events from Kafka +// and exposes them via an HTTP query API for E2E test assertions. It +// exercises the full adapters.Runner lifecycle (dedup, out-of-order +// detection, retry, flush, offset commit) without connecting to a real +// metering provider. +// +// Events are logged to stdout and stored in a bounded ring buffer +// queryable via GET /events and GET /events/count. +// +// Usage: +// +// export KAFKA_BROKERS="localhost:9092" +// export KAFKA_TOPICS="osac.metering.events" +// go run ./cmd/echo-adapter/ +// +// Optional TLS/SASL (for cluster-deployed Kafka): +// +// export KAFKA_TLS_ENABLED="true" +// export KAFKA_TLS_CA_CERT="/path/to/ca.crt" +// export KAFKA_SASL_USERNAME="metering-user" +// export KAFKA_SASL_PASSWORD_FILE="/path/to/password" +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/go-logr/stdr" + "github.com/osac-project/osac-metering/adapters" +) + +// echoAdapter logs every event to stdout, stores it in a ring buffer +// for HTTP queries, and counts submissions. +type echoAdapter struct { + store *eventStore + submitted atomic.Int64 + flushed atomic.Int64 +} + +func (a *echoAdapter) Name() string { return "echo" } + +func (a *echoAdapter) Submit(_ context.Context, event adapters.MeteringEvent) error { + fmt.Printf("[SUBMIT] id=%-36s type=%-30s topic=%-30s partition=%d offset=%d\n", + event.CloudEvent.ID(), + event.CloudEvent.Type(), + event.Topic, + event.Partition, + event.Offset, + ) + a.store.add(event) + a.submitted.Add(1) + return nil +} + +func (a *echoAdapter) Flush(_ context.Context) (adapters.SubmitResult, error) { + n := a.flushed.Add(1) + total := a.submitted.Load() + fmt.Printf("[FLUSH] #%d — %d events submitted so far\n", n, total) + return adapters.SubmitResult{Idempotent: true}, nil +} + +func (a *echoAdapter) HealthCheck(_ context.Context) error { return nil } + +func (a *echoAdapter) Close() error { + fmt.Printf("[CLOSE] total events submitted: %d, total flushes: %d\n", + a.submitted.Load(), a.flushed.Load()) + return nil +} + +func main() { + brokers := os.Getenv("KAFKA_BROKERS") + if brokers == "" { + log.Fatal("KAFKA_BROKERS is required (comma-separated broker list)") + } + + topicsEnv := os.Getenv("KAFKA_TOPICS") + if topicsEnv == "" { + topicsEnv = "osac.metering.events" + } + topics := strings.Split(topicsEnv, ",") + for i := range topics { + topics[i] = strings.TrimSpace(topics[i]) + } + + group := os.Getenv("KAFKA_CONSUMER_GROUP") + if group == "" { + group = "echo-adapter-smoke-test" + } + + flushInterval := 5 * time.Second + if v := os.Getenv("FLUSH_INTERVAL"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + log.Fatalf("invalid FLUSH_INTERVAL %q: %v", v, err) + } + flushInterval = d + } + + metricsAddr := os.Getenv("METRICS_ADDR") + if metricsAddr == "" { + metricsAddr = ":2112" + } + + bufferSize := defaultMaxEvents + if v := os.Getenv("ECHO_BUFFER_SIZE"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + log.Fatalf("invalid ECHO_BUFFER_SIZE %q: must be a positive integer", v) + } + bufferSize = n + } + + logger := stdr.New(log.New(os.Stderr, "", log.LstdFlags)) + + store := newEventStore(bufferSize) + adapter := &echoAdapter{store: store} + runner := adapters.NewRunner(adapter, adapters.RunnerConfig{ + Brokers: brokers, + ConsumerGroup: group, + Topics: topics, + FlushInterval: flushInterval, + Kafka: adapters.KafkaConfig{ + TLSEnabled: os.Getenv("KAFKA_TLS_ENABLED") == "true", + TLSCACert: os.Getenv("KAFKA_TLS_CA_CERT"), + SASLUser: os.Getenv("KAFKA_SASL_USERNAME"), + SASLPassFile: os.Getenv("KAFKA_SASL_PASSWORD_FILE"), + }, + }, logger) + + // Serve metrics, health, and event query endpoints. + go func() { + mux := http.NewServeMux() + mux.Handle("/metrics", runner.MetricsHandler()) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + if err := adapter.HealthCheck(r.Context()); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /events", store.handleEvents) + mux.HandleFunc("DELETE /events", store.handleDeleteEvents) + mux.HandleFunc("GET /events/count", store.handleCount) + mux.HandleFunc("GET /events/{id}", store.handleEventByID) + log.Printf("HTTP server listening on %s", metricsAddr) + if err := http.ListenAndServe(metricsAddr, mux); err != nil { + log.Printf("metrics server error: %v", err) + } + }() + + ctx, cancel := signal.NotifyContext(context.Background(), + syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + log.Printf("starting echo adapter: broker_count=%d topics=%v group=%s flush=%s", + len(strings.Split(brokers, ",")), topics, group, flushInterval) + + if err := runner.Run(ctx); err != nil { + log.Fatalf("runner error: %v", err) + } + + log.Print("echo adapter shut down cleanly") +} diff --git a/osac-metering/adapters/cmd/echo-adapter/store.go b/osac-metering/adapters/cmd/echo-adapter/store.go new file mode 100644 index 000000000..446fc8963 --- /dev/null +++ b/osac-metering/adapters/cmd/echo-adapter/store.go @@ -0,0 +1,237 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/osac-project/osac-metering/adapters" +) + +const defaultMaxEvents = 1000 + +// storedEvent holds the metadata for a received metering event. +type storedEvent struct { + ID string `json:"id"` + Type string `json:"type"` + Source string `json:"source"` + Time string `json:"time,omitempty"` + ResourceID string `json:"resource_id,omitempty"` + Topic string `json:"topic"` + Partition int32 `json:"partition"` + Offset int64 `json:"offset"` + ReceivedAt time.Time `json:"received_at"` +} + +// eventStore is a bounded, thread-safe ring buffer of received events. +// It supports filtered queries for E2E test assertions. +type eventStore struct { + mu sync.RWMutex + events []storedEvent + max int +} + +func newEventStore(max int) *eventStore { + if max <= 0 { + max = defaultMaxEvents + } + return &eventStore{ + events: make([]storedEvent, 0, max), + max: max, + } +} + +// clear removes all events from the store. +func (s *eventStore) clear() { + s.mu.Lock() + defer s.mu.Unlock() + s.events = s.events[:0] +} + +// getByID returns the event with the given CloudEvent ID, or nil if not found. +func (s *eventStore) getByID(id string) *storedEvent { + s.mu.RLock() + defer s.mu.RUnlock() + for i := range s.events { + if s.events[i].ID == id { + e := s.events[i] + return &e + } + } + return nil +} + +// add records a metering event in the ring buffer. +func (s *eventStore) add(event adapters.MeteringEvent) { + ce := event.CloudEvent + + var resourceID string + if v, ok := ce.Extensions()["osacresourceid"]; ok { + resourceID = fmt.Sprintf("%v", v) + } + + entry := storedEvent{ + ID: ce.ID(), + Type: ce.Type(), + Source: ce.Source(), + Time: ce.Time().Format(time.RFC3339), + ResourceID: resourceID, + Topic: event.Topic, + Partition: event.Partition, + Offset: event.Offset, + ReceivedAt: time.Now(), + } + + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.events) >= s.max { + // Shift left by 1, dropping the oldest entry. + copy(s.events, s.events[1:]) + s.events[len(s.events)-1] = entry + } else { + s.events = append(s.events, entry) + } +} + +// query returns events matching the given filters. +func (s *eventStore) query(eventType, resourceID string, since time.Time, limit int) []storedEvent { + s.mu.RLock() + defer s.mu.RUnlock() + + var result []storedEvent + for _, e := range s.events { + if eventType != "" && e.Type != eventType { + continue + } + if resourceID != "" && e.ResourceID != resourceID { + continue + } + if !since.IsZero() && e.ReceivedAt.Before(since) { + continue + } + result = append(result, e) + if limit > 0 && len(result) >= limit { + break + } + } + return result +} + +// count returns the number of events matching the given filters. +func (s *eventStore) count(eventType, resourceID string, since time.Time) int { + s.mu.RLock() + defer s.mu.RUnlock() + + n := 0 + for _, e := range s.events { + if eventType != "" && e.Type != eventType { + continue + } + if resourceID != "" && e.ResourceID != resourceID { + continue + } + if !since.IsZero() && e.ReceivedAt.Before(since) { + continue + } + n++ + } + return n +} + +// handleEvents serves GET /events with optional query parameters: +// - type: filter by CloudEvent type +// - resource_id: filter by resource ID +// - since: RFC3339 timestamp, only events received after this time +// - limit: max number of results +func (s *eventStore) handleEvents(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + eventType := q.Get("type") + resourceID := q.Get("resource_id") + + var since time.Time + if v := q.Get("since"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + http.Error(w, fmt.Sprintf("invalid since parameter: %v", err), http.StatusBadRequest) + return + } + since = t + } + + var limit int + if v := q.Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + http.Error(w, fmt.Sprintf("invalid limit parameter: %v", err), http.StatusBadRequest) + return + } + limit = n + } + + events := s.query(eventType, resourceID, since, limit) + if events == nil { + events = []storedEvent{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(events) //nolint:errcheck +} + +// handleEventByID serves GET /events/{id} — returns a single event by CloudEvent ID. +func (s *eventStore) handleEventByID(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + http.Error(w, "missing event id", http.StatusBadRequest) + return + } + + event := s.getByID(id) + if event == nil { + http.Error(w, "event not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(event) //nolint:errcheck +} + +// handleDeleteEvents serves DELETE /events — clears all stored events. +func (s *eventStore) handleDeleteEvents(w http.ResponseWriter, _ *http.Request) { + s.clear() + w.WriteHeader(http.StatusNoContent) +} + +// handleCount serves GET /events/count with the same filters as /events. +func (s *eventStore) handleCount(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + eventType := q.Get("type") + resourceID := q.Get("resource_id") + + var since time.Time + if v := q.Get("since"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + http.Error(w, fmt.Sprintf("invalid since parameter: %v", err), http.StatusBadRequest) + return + } + since = t + } + + n := s.count(eventType, resourceID, since) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]int{"count": n}) //nolint:errcheck +} diff --git a/osac-metering/adapters/go.mod b/osac-metering/adapters/go.mod index 1389cc072..0f170d809 100644 --- a/osac-metering/adapters/go.mod +++ b/osac-metering/adapters/go.mod @@ -6,6 +6,7 @@ require ( github.com/IBM/sarama v1.60.1 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/go-logr/logr v1.4.3 + github.com/go-logr/stdr v1.2.2 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 github.com/prometheus/client_golang v1.24.1 diff --git a/osac-metering/adapters/go.sum b/osac-metering/adapters/go.sum index 673eee158..b8bc2c215 100644 --- a/osac-metering/adapters/go.sum +++ b/osac-metering/adapters/go.sum @@ -19,8 +19,11 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +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-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= diff --git a/osac-metering/charts/osac-metering/templates/test-adapter-deployment.yaml b/osac-metering/charts/osac-metering/templates/echo-adapter-deployment.yaml similarity index 84% rename from osac-metering/charts/osac-metering/templates/test-adapter-deployment.yaml rename to osac-metering/charts/osac-metering/templates/echo-adapter-deployment.yaml index 58630948f..f6dac4fa5 100644 --- a/osac-metering/charts/osac-metering/templates/test-adapter-deployment.yaml +++ b/osac-metering/charts/osac-metering/templates/echo-adapter-deployment.yaml @@ -1,11 +1,11 @@ -{{- if .Values.testAdapter.enabled }} +{{- if .Values.echoAdapter.enabled }} apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "osac-metering.fullname" . }}-test-adapter + name: {{ include "osac-metering.fullname" . }}-echo-adapter labels: {{- include "osac-metering.labels" . | nindent 4 }} - app.kubernetes.io/component: test-adapter + app.kubernetes.io/component: echo-adapter spec: replicas: 1 strategy: @@ -13,12 +13,12 @@ spec: selector: matchLabels: {{- include "osac-metering.selectorLabels" . | nindent 6 }} - app.kubernetes.io/component: test-adapter + app.kubernetes.io/component: echo-adapter template: metadata: labels: {{- include "osac-metering.selectorLabels" . | nindent 8 }} - app.kubernetes.io/component: test-adapter + app.kubernetes.io/component: echo-adapter spec: serviceAccountName: {{ include "osac-metering.fullname" . }} securityContext: @@ -42,7 +42,7 @@ spec: - name: SOURCE_NS value: {{ include "osac-metering.kafkaClusterNamespace" . }} - name: SASL_SECRET - value: osac-metering-test-adapter + value: osac-metering-echo-adapter - name: CA_SECRET value: {{ include "osac-metering.kafkaCaSecret" . }} volumeMounts: @@ -89,18 +89,24 @@ spec: oc get secret "${CA_SECRET}" -n "${SOURCE_NS}" -o jsonpath='{.data.ca\.crt}' | base64 -d > /etc/kafka-secrets/ca.crt echo "Kafka secrets fetched." containers: - - name: test-adapter - image: "{{ .Values.testAdapter.image.repository }}:{{ .Values.testAdapter.image.tag }}" - imagePullPolicy: {{ .Values.testAdapter.image.pullPolicy }} + - name: echo-adapter + image: "{{ .Values.echoAdapter.image.repository }}:{{ .Values.echoAdapter.image.tag }}" + imagePullPolicy: {{ .Values.echoAdapter.image.pullPolicy }} env: - name: KAFKA_BROKERS value: {{ include "osac-metering.kafkaBrokers" . | quote }} + - name: KAFKA_TLS_ENABLED + value: "true" - name: KAFKA_TLS_CA_CERT value: /etc/kafka-secrets/ca.crt - name: KAFKA_SASL_USERNAME - value: osac-metering-test-adapter + value: osac-metering-echo-adapter + - name: KAFKA_CONSUMER_GROUP + value: osac-metering-echo-adapter - name: KAFKA_SASL_PASSWORD_FILE value: /etc/kafka-secrets/password + - name: METRICS_ADDR + value: ":8080" volumeMounts: - name: kafka-secrets mountPath: /etc/kafka-secrets diff --git a/osac-metering/charts/osac-metering/templates/test-adapter-kafka-user.yaml b/osac-metering/charts/osac-metering/templates/echo-adapter-kafka-user.yaml similarity index 84% rename from osac-metering/charts/osac-metering/templates/test-adapter-kafka-user.yaml rename to osac-metering/charts/osac-metering/templates/echo-adapter-kafka-user.yaml index e57cbdc33..682c2b8c5 100644 --- a/osac-metering/charts/osac-metering/templates/test-adapter-kafka-user.yaml +++ b/osac-metering/charts/osac-metering/templates/echo-adapter-kafka-user.yaml @@ -1,8 +1,8 @@ -{{- if .Values.testAdapter.enabled }} +{{- if .Values.echoAdapter.enabled }} apiVersion: kafka.strimzi.io/v1 kind: KafkaUser metadata: - name: osac-metering-test-adapter + name: osac-metering-echo-adapter namespace: {{ include "osac-metering.kafkaClusterNamespace" . }} labels: strimzi.io/cluster: {{ include "osac-metering.kafkaClusterName" . }} @@ -22,7 +22,7 @@ spec: host: "*" - resource: type: group - name: osac-metering-test-adapter + name: osac-metering-echo-adapter patternType: literal operations: - Read diff --git a/osac-metering/charts/osac-metering/templates/echo-adapter-route.yaml b/osac-metering/charts/osac-metering/templates/echo-adapter-route.yaml new file mode 100644 index 000000000..9e54a6cd6 --- /dev/null +++ b/osac-metering/charts/osac-metering/templates/echo-adapter-route.yaml @@ -0,0 +1,17 @@ +{{- if .Values.echoAdapter.enabled }} +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: {{ include "osac-metering.fullname" . }}-echo-adapter + labels: + {{- include "osac-metering.labels" . | nindent 4 }} + app.kubernetes.io/component: echo-adapter +spec: + to: + kind: Service + name: {{ include "osac-metering.fullname" . }}-echo-adapter + port: + targetPort: http + tls: + termination: edge +{{- end }} diff --git a/osac-metering/charts/osac-metering/templates/test-adapter-service.yaml b/osac-metering/charts/osac-metering/templates/echo-adapter-service.yaml similarity index 61% rename from osac-metering/charts/osac-metering/templates/test-adapter-service.yaml rename to osac-metering/charts/osac-metering/templates/echo-adapter-service.yaml index cb2365293..a77b815da 100644 --- a/osac-metering/charts/osac-metering/templates/test-adapter-service.yaml +++ b/osac-metering/charts/osac-metering/templates/echo-adapter-service.yaml @@ -1,11 +1,11 @@ -{{- if .Values.testAdapter.enabled }} +{{- if .Values.echoAdapter.enabled }} apiVersion: v1 kind: Service metadata: - name: {{ include "osac-metering.fullname" . }}-test-adapter + name: {{ include "osac-metering.fullname" . }}-echo-adapter labels: {{- include "osac-metering.labels" . | nindent 4 }} - app.kubernetes.io/component: test-adapter + app.kubernetes.io/component: echo-adapter spec: type: ClusterIP ports: @@ -15,5 +15,5 @@ spec: name: http selector: {{- include "osac-metering.selectorLabels" . | nindent 4 }} - app.kubernetes.io/component: test-adapter + app.kubernetes.io/component: echo-adapter {{- end }} diff --git a/osac-metering/charts/osac-metering/templates/kafka-secrets-rbac.yaml b/osac-metering/charts/osac-metering/templates/kafka-secrets-rbac.yaml index 8a9b40bb0..55cdf26b9 100644 --- a/osac-metering/charts/osac-metering/templates/kafka-secrets-rbac.yaml +++ b/osac-metering/charts/osac-metering/templates/kafka-secrets-rbac.yaml @@ -14,8 +14,8 @@ rules: resourceNames: - {{ include "osac-metering.kafkaSaslSecretName" . }} - {{ include "osac-metering.kafkaCaSecret" . }} - {{- if .Values.testAdapter.enabled }} - - osac-metering-test-adapter + {{- if .Values.echoAdapter.enabled }} + - osac-metering-echo-adapter {{- end }} verbs: ["get"] --- diff --git a/osac-metering/charts/osac-metering/values.yaml b/osac-metering/charts/osac-metering/values.yaml index dde057dba..41dbb3e07 100644 --- a/osac-metering/charts/osac-metering/values.yaml +++ b/osac-metering/charts/osac-metering/values.yaml @@ -27,9 +27,11 @@ resources: cpu: 500m memory: 256Mi -testAdapter: +## Echo adapter — test/development tool only, not for production use. +## Enable and configure via environment-specific values (e.g., values/vmaas-ci/values.yaml). +echoAdapter: enabled: false - image: - repository: ghcr.io/osac-project/metering-test-adapter - tag: latest - pullPolicy: Always + # image: + # repository: ghcr.io/osac-project/metering-echo-adapter + # tag: latest + # pullPolicy: Always diff --git a/osac-metering/metering-service/Makefile b/osac-metering/metering-service/Makefile index 2f856d3b5..f1b03077a 100644 --- a/osac-metering/metering-service/Makefile +++ b/osac-metering/metering-service/Makefile @@ -18,9 +18,6 @@ GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint build: go build -o bin/$(BINARY_NAME) ./cmd/metering-service -build-test-adapter: - go build -o bin/test-adapter ./cmd/test-adapter - test: $(GINKGO) run -r internal diff --git a/osac-metering/metering-service/cmd/test-adapter/main.go b/osac-metering/metering-service/cmd/test-adapter/main.go deleted file mode 100644 index fca82ade9..000000000 --- a/osac-metering/metering-service/cmd/test-adapter/main.go +++ /dev/null @@ -1,199 +0,0 @@ -/* -Copyright (c) 2026 Red Hat, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except -in compliance with the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 -*/ - -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "os" - "os/signal" - "strconv" - "sync" - "syscall" - "time" - - "github.com/IBM/sarama" - - kafkapub "github.com/osac-project/osac-metering/internal/kafka" -) - -// Bounded ring buffer for CI — drops oldest events to prevent OOM. -// Not a production store; the test adapter is a read-only consumer -// used only for E2E test assertions. -const defaultMaxEvents = 10000 - -var topics = kafkapub.Topics - -type eventStore struct { - mu sync.RWMutex - events []json.RawMessage - maxEvents int -} - -func (s *eventStore) add(data []byte) { - s.mu.Lock() - defer s.mu.Unlock() - if len(s.events) >= s.maxEvents { - s.events = s.events[1:] - } - s.events = append(s.events, json.RawMessage(data)) -} - -func (s *eventStore) query(eventType, resourceID string, since time.Time) []json.RawMessage { - s.mu.RLock() - defer s.mu.RUnlock() - var result []json.RawMessage - for _, raw := range s.events { - var ev map[string]any - if json.Unmarshal(raw, &ev) != nil { - continue - } - if eventType != "" { - if t, _ := ev["type"].(string); t != eventType { - continue - } - } - if resourceID != "" { - if rid, _ := ev["osacresourceid"].(string); rid != resourceID { - continue - } - } - if !since.IsZero() { - if ts, _ := ev["time"].(string); ts != "" { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil && t.Before(since) { - continue - } - } - } - result = append(result, raw) - } - return result -} - -// consumerGroupHandler implements sarama.ConsumerGroupHandler. -type consumerGroupHandler struct { - store *eventStore -} - -func (h *consumerGroupHandler) Setup(_ sarama.ConsumerGroupSession) error { return nil } -func (h *consumerGroupHandler) Cleanup(_ sarama.ConsumerGroupSession) error { return nil } -func (h *consumerGroupHandler) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error { - for msg := range claim.Messages() { - h.store.add(msg.Value) - session.MarkMessage(msg, "") - } - return nil -} - -func main() { - cfg := kafkapub.ConnectionConfig{ - Brokers: os.Getenv("KAFKA_BROKERS"), - TLSCACert: os.Getenv("KAFKA_TLS_CA_CERT"), - SASLUser: os.Getenv("KAFKA_SASL_USERNAME"), - SASLPassFile: os.Getenv("KAFKA_SASL_PASSWORD_FILE"), - } - listenAddr := os.Getenv("LISTEN_ADDR") - if listenAddr == "" { - listenAddr = ":8080" - } - - maxEvents := defaultMaxEvents - if v := os.Getenv("MAX_EVENTS"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - maxEvents = n - } - } - - if cfg.Brokers == "" || cfg.SASLPassFile == "" { - fmt.Fprintln(os.Stderr, "KAFKA_BROKERS and KAFKA_SASL_PASSWORD_FILE are required") - os.Exit(2) - } - - store := &eventStore{maxEvents: maxEvents} - - sc := sarama.NewConfig() - sc.Version = sarama.V3_9_0_0 - sc.Consumer.Offsets.Initial = sarama.OffsetNewest - if err := kafkapub.ConfigureTLS(sc, cfg.TLSCACert); err != nil { - fmt.Fprintf(os.Stderr, "TLS config: %v\n", err) - os.Exit(1) - } - if err := kafkapub.ConfigureSASL(sc, cfg.SASLUser, cfg.SASLPassFile); err != nil { - fmt.Fprintf(os.Stderr, "SASL config: %v\n", err) - os.Exit(1) - } - - brokers := kafkapub.SplitAndTrim(cfg.Brokers, ",") - group, err := sarama.NewConsumerGroup(brokers, "osac-metering-test-adapter", sc) - if err != nil { - fmt.Fprintf(os.Stderr, "creating consumer group: %v\n", err) - os.Exit(1) - } - defer func() { _ = group.Close() }() - - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) - defer cancel() - - go func() { - handler := &consumerGroupHandler{store: store} - for { - if err := group.Consume(ctx, topics, handler); err != nil { - fmt.Fprintf(os.Stderr, "consumer error: %v\n", err) - } - if ctx.Err() != nil { - return - } - } - }() - - mux := http.NewServeMux() - mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprintln(w, "ok") - }) - mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { - eventType := r.URL.Query().Get("type") - resourceID := r.URL.Query().Get("resource_id") - var since time.Time - if s := r.URL.Query().Get("since"); s != "" { - since, _ = time.Parse(time.RFC3339Nano, s) - } - events := store.query(eventType, resourceID, since) - if events == nil { - events = []json.RawMessage{} - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(events) - }) - - srv := &http.Server{ - Addr: listenAddr, - Handler: mux, - ReadHeaderTimeout: 5 * time.Second, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 60 * time.Second, - } - - go func() { - <-ctx.Done() - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutdownCancel() - _ = srv.Shutdown(shutdownCtx) - }() - - fmt.Printf("test-adapter listening on %s, consuming %v\n", listenAddr, topics) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - fmt.Fprintf(os.Stderr, "HTTP server: %v\n", err) - os.Exit(1) - } -}