diff --git a/.changeset/flat-moose-check.md b/.changeset/flat-moose-check.md new file mode 100644 index 000000000..51f3c178b --- /dev/null +++ b/.changeset/flat-moose-check.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": minor +--- + +feat: adds HMAC authentication support for catalog remote diff --git a/datastore/catalog/remote/address_ref_store.go b/datastore/catalog/remote/address_ref_store.go index b41e1d0a6..21dbada35 100644 --- a/datastore/catalog/remote/address_ref_store.go +++ b/datastore/catalog/remote/address_ref_store.go @@ -53,12 +53,6 @@ func (s *catalogAddressRefStore) get( ignoreTransaction bool, key datastore.AddressRefKey, ) (datastore.AddressRef, error) { - // Create a bidirectional stream - stream, err := s.client.DataAccess() - if err != nil { - return datastore.AddressRef{}, fmt.Errorf("failed to create data access stream: %w", err) - } - // Create the find request with the key converted to a filter filter := s.keyToFilter(key) findRequest := &pb.AddressReferenceFindRequest{ @@ -66,13 +60,19 @@ func (s *catalogAddressRefStore) get( IgnoreTransaction: ignoreTransaction, } - // Send the request + // Create the request request := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_AddressReferenceFindRequest{ AddressReferenceFindRequest: findRequest, }, } + // Create a bidirectional stream with the initial request for HMAC + stream, err := s.client.DataAccess(request) + if err != nil { + return datastore.AddressRef{}, fmt.Errorf("failed to create data access stream: %w", err) + } + if sendErr := stream.Send(request); sendErr != nil { return datastore.AddressRef{}, fmt.Errorf("failed to send find request: %w", sendErr) } @@ -120,12 +120,6 @@ func (s *catalogAddressRefStore) get( // Fetch returns a copy of all AddressRef in the catalog. func (s *catalogAddressRefStore) Fetch(_ context.Context) ([]datastore.AddressRef, error) { - // Create a bidirectional stream - stream, err := s.client.DataAccess() - if err != nil { - return nil, fmt.Errorf("failed to create data access stream: %w", err) - } - // Create the find request with an empty filter to get all records // We only filter by domain and environment to get all records for this store's scope filter := &pb.AddressReferenceKeyFilter{ @@ -138,13 +132,19 @@ func (s *catalogAddressRefStore) Fetch(_ context.Context) ([]datastore.AddressRe KeyFilter: filter, } - // Send the request + // Create the request request := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_AddressReferenceFindRequest{ AddressReferenceFindRequest: findRequest, }, } + // Create a bidirectional stream with the initial request for HMAC + stream, err := s.client.DataAccess(request) + if err != nil { + return nil, fmt.Errorf("failed to create data access stream: %w", err) + } + if sendErr := stream.Send(request); sendErr != nil { return nil, fmt.Errorf("failed to send find request: %w", sendErr) } @@ -212,12 +212,6 @@ func (s *catalogAddressRefStore) Filter( } func (s *catalogAddressRefStore) Add(_ context.Context, record datastore.AddressRef) error { - // Create a bidirectional stream - stream, err := s.client.DataAccess() - if err != nil { - return fmt.Errorf("failed to create data access stream: %w", err) - } - // Convert the datastore record to protobuf protoRef := s.addressRefToProto(record) @@ -227,13 +221,19 @@ func (s *catalogAddressRefStore) Add(_ context.Context, record datastore.Address Semantics: pb.EditSemantics_SEMANTICS_INSERT, } - // Send the edit request + // Create the request editReq := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_AddressReferenceEditRequest{ AddressReferenceEditRequest: editRequest, }, } + // Create a bidirectional stream with the initial request for HMAC + stream, err := s.client.DataAccess(editReq) + if err != nil { + return fmt.Errorf("failed to create data access stream: %w", err) + } + if sendErr := stream.Send(editReq); sendErr != nil { return fmt.Errorf("failed to send edit request: %w", sendErr) } @@ -259,12 +259,6 @@ func (s *catalogAddressRefStore) Add(_ context.Context, record datastore.Address } func (s *catalogAddressRefStore) Upsert(_ context.Context, record datastore.AddressRef) error { - // Create a bidirectional stream - stream, err := s.client.DataAccess() - if err != nil { - return fmt.Errorf("failed to create data access stream: %w", err) - } - // Convert the datastore record to protobuf protoRef := s.addressRefToProto(record) @@ -274,13 +268,19 @@ func (s *catalogAddressRefStore) Upsert(_ context.Context, record datastore.Addr Semantics: pb.EditSemantics_SEMANTICS_UPSERT, } - // Send the edit request + // Create the request request := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_AddressReferenceEditRequest{ AddressReferenceEditRequest: editRequest, }, } + // Create a bidirectional stream with the initial request for HMAC + stream, err := s.client.DataAccess(request) + if err != nil { + return fmt.Errorf("failed to create data access stream: %w", err) + } + if sendErr := stream.Send(request); sendErr != nil { return fmt.Errorf("failed to send edit request: %w", sendErr) } @@ -319,12 +319,6 @@ func (s *catalogAddressRefStore) Update(ctx context.Context, record datastore.Ad } // Record exists, proceed with updating it - // Create a bidirectional stream - stream, streamErr := s.client.DataAccess() - if streamErr != nil { - return fmt.Errorf("failed to create data access stream: %w", streamErr) - } - // Convert the datastore record to protobuf protoRef := s.addressRefToProto(record) @@ -334,13 +328,19 @@ func (s *catalogAddressRefStore) Update(ctx context.Context, record datastore.Ad Semantics: pb.EditSemantics_SEMANTICS_UPDATE, } - // Send the edit request + // Create the request editReq := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_AddressReferenceEditRequest{ AddressReferenceEditRequest: editRequest, }, } + // Create a bidirectional stream with the initial request for HMAC + stream, streamErr := s.client.DataAccess(editReq) + if streamErr != nil { + return fmt.Errorf("failed to create data access stream: %w", streamErr) + } + if sendErr := stream.Send(editReq); sendErr != nil { return fmt.Errorf("failed to send edit request: %w", sendErr) } diff --git a/datastore/catalog/remote/address_ref_store_test.go b/datastore/catalog/remote/address_ref_store_test.go index 5c6a0e927..c6d1326af 100644 --- a/datastore/catalog/remote/address_ref_store_test.go +++ b/datastore/catalog/remote/address_ref_store_test.go @@ -637,7 +637,7 @@ func setupTestStore(t *testing.T, domain, environment string) *catalogAddressRef } // Test if the service is actually available by making a simple call - _, err = catalogClient.DataAccess() + _, err = catalogClient.DataAccess(&pb.DataAccessRequest{}) if err != nil { t.Skipf("gRPC service not available at %s: %v. Skipping integration tests.", address, err) return nil diff --git a/datastore/catalog/remote/chain_metadata_store.go b/datastore/catalog/remote/chain_metadata_store.go index f4ac99fe1..b83abc49f 100644 --- a/datastore/catalog/remote/chain_metadata_store.go +++ b/datastore/catalog/remote/chain_metadata_store.go @@ -119,12 +119,7 @@ func (s *catalogChainMetadataStore) Get( } func (s *catalogChainMetadataStore) get(ignoreTransaction bool, key datastore.ChainMetadataKey) (datastore.ChainMetadata, error) { - stream, err := s.client.DataAccess() - if err != nil { - return datastore.ChainMetadata{}, fmt.Errorf("failed to create gRPC stream: %w", err) - } - - // Send find request + // Create find request findReq := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_ChainMetadataFindRequest{ ChainMetadataFindRequest: &pb.ChainMetadataFindRequest{ @@ -134,6 +129,12 @@ func (s *catalogChainMetadataStore) get(ignoreTransaction bool, key datastore.Ch }, } + // Create stream with the initial request for HMAC + stream, err := s.client.DataAccess(findReq) + if err != nil { + return datastore.ChainMetadata{}, fmt.Errorf("failed to create gRPC stream: %w", err) + } + if sendErr := stream.Send(findReq); sendErr != nil { return datastore.ChainMetadata{}, fmt.Errorf("failed to send find request: %w", sendErr) } @@ -181,12 +182,7 @@ func (s *catalogChainMetadataStore) get(ignoreTransaction bool, key datastore.Ch // Fetch returns a copy of all ChainMetadata in the catalog. func (s *catalogChainMetadataStore) Fetch(_ context.Context) ([]datastore.ChainMetadata, error) { - stream, err := s.client.DataAccess() - if err != nil { - return nil, fmt.Errorf("failed to create gRPC stream: %w", err) - } - - // Send find request with domain and environment filter only (fetch all) + // Create find request with domain and environment filter only (fetch all) findReq := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_ChainMetadataFindRequest{ ChainMetadataFindRequest: &pb.ChainMetadataFindRequest{ @@ -198,6 +194,12 @@ func (s *catalogChainMetadataStore) Fetch(_ context.Context) ([]datastore.ChainM }, } + // Create stream with the initial request for HMAC + stream, err := s.client.DataAccess(findReq) + if err != nil { + return nil, fmt.Errorf("failed to create gRPC stream: %w", err) + } + if sendErr := stream.Send(findReq); sendErr != nil { return nil, fmt.Errorf("failed to send find request: %w", sendErr) } @@ -347,16 +349,11 @@ func (s *catalogChainMetadataStore) Delete(_ context.Context, _ datastore.ChainM // editRecord is a helper method that handles Add, Upsert, and Update operations func (s *catalogChainMetadataStore) editRecord(record datastore.ChainMetadata, semantics pb.EditSemantics) error { - stream, err := s.client.DataAccess() - if err != nil { - return fmt.Errorf("failed to create gRPC stream: %w", err) - } - // Get the current version for this record key := record.Key() version := s.getVersion(key) - // Send edit request + // Create edit request editReq := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_ChainMetadataEditRequest{ ChainMetadataEditRequest: &pb.ChainMetadataEditRequest{ @@ -366,6 +363,12 @@ func (s *catalogChainMetadataStore) editRecord(record datastore.ChainMetadata, s }, } + // Create stream with the initial request for HMAC + stream, err := s.client.DataAccess(editReq) + if err != nil { + return fmt.Errorf("failed to create gRPC stream: %w", err) + } + if sendErr := stream.Send(editReq); sendErr != nil { return fmt.Errorf("failed to send edit request: %w", sendErr) } diff --git a/datastore/catalog/remote/chain_metadata_store_test.go b/datastore/catalog/remote/chain_metadata_store_test.go index 995814167..30a7ed08c 100644 --- a/datastore/catalog/remote/chain_metadata_store_test.go +++ b/datastore/catalog/remote/chain_metadata_store_test.go @@ -60,7 +60,7 @@ func setupTestChainStore(t *testing.T, domain, environment string) *catalogChain } // Test if the service is actually available by making a simple call - _, err = catalogClient.DataAccess() + _, err = catalogClient.DataAccess(&pb.DataAccessRequest{}) if err != nil { t.Skipf("gRPC service not available at %s: %v. Skipping integration tests.", address, err) return nil diff --git a/datastore/catalog/remote/contract_metadata_store.go b/datastore/catalog/remote/contract_metadata_store.go index 40974bf9c..f65ddda6c 100644 --- a/datastore/catalog/remote/contract_metadata_store.go +++ b/datastore/catalog/remote/contract_metadata_store.go @@ -123,12 +123,7 @@ func (s *catalogContractMetadataStore) Get( } func (s *catalogContractMetadataStore) get(ignoreTransaction bool, key datastore.ContractMetadataKey) (datastore.ContractMetadata, error) { - stream, err := s.client.DataAccess() - if err != nil { - return datastore.ContractMetadata{}, fmt.Errorf("failed to create gRPC stream: %w", err) - } - - // Send find request + // Create find request findReq := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_ContractMetadataFindRequest{ ContractMetadataFindRequest: &pb.ContractMetadataFindRequest{ @@ -138,6 +133,12 @@ func (s *catalogContractMetadataStore) get(ignoreTransaction bool, key datastore }, } + // Create stream with the initial request for HMAC + stream, err := s.client.DataAccess(findReq) + if err != nil { + return datastore.ContractMetadata{}, fmt.Errorf("failed to create gRPC stream: %w", err) + } + if sendErr := stream.Send(findReq); sendErr != nil { return datastore.ContractMetadata{}, fmt.Errorf("failed to send find request: %w", sendErr) } @@ -185,12 +186,7 @@ func (s *catalogContractMetadataStore) get(ignoreTransaction bool, key datastore // Fetch returns a copy of all ContractMetadata in the catalog. func (s *catalogContractMetadataStore) Fetch(_ context.Context) ([]datastore.ContractMetadata, error) { - stream, err := s.client.DataAccess() - if err != nil { - return nil, fmt.Errorf("failed to create gRPC stream: %w", err) - } - - // Send find request with domain and environment filter only (fetch all) + // Create find request with domain and environment filter only (fetch all) findReq := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_ContractMetadataFindRequest{ ContractMetadataFindRequest: &pb.ContractMetadataFindRequest{ @@ -202,6 +198,12 @@ func (s *catalogContractMetadataStore) Fetch(_ context.Context) ([]datastore.Con }, } + // Create stream with the initial request for HMAC + stream, err := s.client.DataAccess(findReq) + if err != nil { + return nil, fmt.Errorf("failed to create gRPC stream: %w", err) + } + if sendErr := stream.Send(findReq); sendErr != nil { return nil, fmt.Errorf("failed to send find request: %w", sendErr) } @@ -354,16 +356,11 @@ func (s *catalogContractMetadataStore) Delete(_ context.Context, _ datastore.Con // editRecord is a helper method that handles Add, Upsert, and Update operations func (s *catalogContractMetadataStore) editRecord(record datastore.ContractMetadata, semantics pb.EditSemantics) error { - stream, err := s.client.DataAccess() - if err != nil { - return fmt.Errorf("failed to create gRPC stream: %w", err) - } - // Get the current version for this record key := record.Key() version := s.getVersion(key) - // Send edit request + // Create edit request editReq := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_ContractMetadataEditRequest{ ContractMetadataEditRequest: &pb.ContractMetadataEditRequest{ @@ -373,6 +370,12 @@ func (s *catalogContractMetadataStore) editRecord(record datastore.ContractMetad }, } + // Create stream with the initial request for HMAC + stream, err := s.client.DataAccess(editReq) + if err != nil { + return fmt.Errorf("failed to create gRPC stream: %w", err) + } + if sendErr := stream.Send(editReq); sendErr != nil { return fmt.Errorf("failed to send edit request: %w", sendErr) } diff --git a/datastore/catalog/remote/contract_metadata_store_test.go b/datastore/catalog/remote/contract_metadata_store_test.go index 568898336..7f04f8011 100644 --- a/datastore/catalog/remote/contract_metadata_store_test.go +++ b/datastore/catalog/remote/contract_metadata_store_test.go @@ -60,7 +60,7 @@ func setupTestContractStore(t *testing.T, domain, environment string) *catalogCo } // Test if the gRPC service is actually available by making a simple call - _, err = catalogClient.DataAccess() + _, err = catalogClient.DataAccess(&pb.DataAccessRequest{}) if err != nil { t.Skipf("gRPC service not available at %s: %v. Skipping integration tests.", address, err) return nil diff --git a/datastore/catalog/remote/datastore_tx_test.go b/datastore/catalog/remote/datastore_tx_test.go index 598b27418..edaf0aaf1 100644 --- a/datastore/catalog/remote/datastore_tx_test.go +++ b/datastore/catalog/remote/datastore_tx_test.go @@ -11,9 +11,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/credentials/insecure" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + + pb "github.com/smartcontractkit/chainlink-protos/op-catalog/v1/datastore" ) //nolint:paralleltest @@ -448,7 +451,7 @@ func setupStore(t *testing.T, ctx context.Context) (*catalogDataStore, error) { return nil, fmt.Errorf("failed to connect to gRPC server at %s: %w. Skipping integration tests", address, err) } // Test if the service is actually available by making a simple call - _, err = catalogClient.DataAccess() + _, err = catalogClient.DataAccess(&pb.DataAccessRequest{}) if err != nil { return nil, fmt.Errorf("gRPC service not available at %s: %w. Skipping integration tests", address, err) } diff --git a/datastore/catalog/remote/env_metadata_store.go b/datastore/catalog/remote/env_metadata_store.go index 3550b523d..268dbc1ad 100644 --- a/datastore/catalog/remote/env_metadata_store.go +++ b/datastore/catalog/remote/env_metadata_store.go @@ -117,12 +117,7 @@ func (s *catalogEnvMetadataStore) Get( } func (s *catalogEnvMetadataStore) get(ignoreTransaction bool) (datastore.EnvMetadata, error) { - stream, err := s.client.DataAccess() - if err != nil { - return datastore.EnvMetadata{}, fmt.Errorf("failed to create gRPC stream: %w", err) - } - - // Send find request + // Create find request findReq := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_EnvironmentMetadataFindRequest{ EnvironmentMetadataFindRequest: &pb.EnvironmentMetadataFindRequest{ @@ -132,6 +127,12 @@ func (s *catalogEnvMetadataStore) get(ignoreTransaction bool) (datastore.EnvMeta }, } + // Create stream with the initial request for HMAC + stream, err := s.client.DataAccess(findReq) + if err != nil { + return datastore.EnvMetadata{}, fmt.Errorf("failed to create gRPC stream: %w", err) + } + if sendErr := stream.Send(findReq); sendErr != nil { return datastore.EnvMetadata{}, fmt.Errorf("failed to send find request: %w", sendErr) } @@ -224,12 +225,7 @@ func (s *catalogEnvMetadataStore) editRecord(record datastore.EnvMetadata) error // Create the protobuf record protoRecord := s.envMetadataToProto(record, version) - // Send edit request with UPSERT semantics (since Set should always work) - stream, err := s.client.DataAccess() - if err != nil { - return fmt.Errorf("failed to create gRPC stream: %w", err) - } - + // Create edit request with UPSERT semantics (since Set should always work) editReq := &pb.DataAccessRequest{ Operation: &pb.DataAccessRequest_EnvironmentMetadataEditRequest{ EnvironmentMetadataEditRequest: &pb.EnvironmentMetadataEditRequest{ @@ -239,6 +235,12 @@ func (s *catalogEnvMetadataStore) editRecord(record datastore.EnvMetadata) error }, } + // Create stream with the initial request for HMAC + stream, err := s.client.DataAccess(editReq) + if err != nil { + return fmt.Errorf("failed to create gRPC stream: %w", err) + } + if sendErr := stream.Send(editReq); sendErr != nil { return fmt.Errorf("failed to send edit request: %w", sendErr) } diff --git a/datastore/catalog/remote/env_metadata_store_test.go b/datastore/catalog/remote/env_metadata_store_test.go index fbf8286dc..d069c0dcc 100644 --- a/datastore/catalog/remote/env_metadata_store_test.go +++ b/datastore/catalog/remote/env_metadata_store_test.go @@ -55,7 +55,7 @@ func setupTestEnvStore(t *testing.T, domain, environment string) *catalogEnvMeta } // Test if the gRPC service is actually available by making a simple call - _, err = catalogClient.DataAccess() + _, err = catalogClient.DataAccess(&pb.DataAccessRequest{}) if err != nil { t.Skipf("gRPC service not available at %s: %v. Skipping integration tests.", address, err) return nil diff --git a/datastore/catalog/remote/grpc.go b/datastore/catalog/remote/grpc.go index 93fffac07..ecb75fc04 100644 --- a/datastore/catalog/remote/grpc.go +++ b/datastore/catalog/remote/grpc.go @@ -3,11 +3,12 @@ package remote import ( "context" "fmt" + "sync" + pb "github.com/smartcontractkit/chainlink-protos/op-catalog/v1/datastore" "google.golang.org/grpc" "google.golang.org/grpc/credentials" - - pb "github.com/smartcontractkit/chainlink-protos/op-catalog/v1/datastore" + "google.golang.org/protobuf/proto" ) type CatalogClient struct { @@ -21,20 +22,37 @@ type CatalogClient struct { // passing context down the call-stack. // //nolint:containedctx - ctx context.Context - cachedStream grpc.BidiStreamingClient[pb.DataAccessRequest, pb.DataAccessResponse] + ctx context.Context + cachedStream grpc.BidiStreamingClient[pb.DataAccessRequest, pb.DataAccessResponse] + hmacConfig *HMACAuthConfig + streamInitOnce sync.Once + streamInitErr error + kmsClient kmsClient + kmsClientOnce sync.Once + kmsClientErr error } -func (c *CatalogClient) DataAccess() (grpc.BidiStreamingClient[pb.DataAccessRequest, pb.DataAccessResponse], error) { - if c.cachedStream == nil { - stream, err := c.protoClient.DataAccess(c.ctx) +func (c *CatalogClient) DataAccess(req proto.Message) (grpc.BidiStreamingClient[pb.DataAccessRequest, pb.DataAccessResponse], error) { + c.streamInitOnce.Do(func() { + ctx := c.ctx + if c.hmacConfig != nil { + var err error + ctx, err = c.prepareHMACContext(c.ctx, req) + if err != nil { + c.streamInitErr = fmt.Errorf("failed to prepare HMAC context: %w", err) + return + } + } + + stream, err := c.protoClient.DataAccess(ctx) if err != nil { - return nil, err + c.streamInitErr = err + return } c.cachedStream = stream - } + }) - return c.cachedStream, nil + return c.cachedStream, c.streamInitErr } func (c *CatalogClient) CloseStream() error { @@ -51,35 +69,57 @@ func (c *CatalogClient) CloseStream() error { } type CatalogConfig struct { - GRPC string - Creds credentials.TransportCredentials + GRPC string + Creds credentials.TransportCredentials + HMACConfig *HMACAuthConfig } // NewCatalogClient creates a new CatalogClient with the provided configuration. +// +// Example usage: +// +// cfg := CatalogConfig{ +// GRPC: "op-catalog.example.com:443", +// Creds: credentials.NewTLS(&tls.Config{}), +// HMACConfig: &HMACAuthConfig{ +// KeyID: "kms-key-id", +// KeyRegion: "us-west-2", +// Authority: "op-catalog.example.com", +// }, +// } +// client, err := NewCatalogClient(ctx, cfg) func NewCatalogClient(ctx context.Context, cfg CatalogConfig) (*CatalogClient, error) { + // Create connection with the configured options. conn, err := newCatalogConnection(cfg) if err != nil { - return &CatalogClient{}, fmt.Errorf("failed to connect Catalog service. Err: %w", err) + return nil, fmt.Errorf("failed to connect Catalog service. Err: %w", err) } + client := CatalogClient{ ctx: ctx, + hmacConfig: cfg.HMACConfig, protoClient: pb.NewDatastoreClient(conn), } - return &client, err + return &client, nil } // newCatalogConnection creates a new gRPC connection to the Catalog service. func newCatalogConnection(cfg CatalogConfig) (*grpc.ClientConn, error) { var opts []grpc.DialOption - var interceptors []grpc.UnaryClientInterceptor if cfg.Creds != nil { opts = append(opts, grpc.WithTransportCredentials(cfg.Creds)) } - if len(interceptors) > 0 { - opts = append(opts, grpc.WithChainUnaryInterceptor(interceptors...)) + // Force authority header to be set to match what's used in the HMAC signature, this ensures the server verifies against the + // same authority we signed with. If not set explicitly, the authority is derived from the grpc URL, which may not match the + // authority used in the HMAC signature since gRPC clients take some liberties with the authority header like removing the port + // E.g. if it is default 443, the authority header will be "grpc.example.com" instead of "grpc.example.com:443" + // see: https://github.com/grpc/grpc-go/blob/7472d578b15f718cbe8ca0f5f5a3713093c47b03/internal/transport/http2_client.go#L653 + // see: https://github.com/grpc/grpc-go/blob/7472d578b15f718cbe8ca0f5f5a3713093c47b03/internal/transport/http2_client.go#L533 + if cfg.HMACConfig != nil { + opts = append(opts, grpc.WithAuthority(cfg.HMACConfig.Authority)) } conn, err := grpc.NewClient(cfg.GRPC, opts...) diff --git a/datastore/catalog/remote/grpc_test.go b/datastore/catalog/remote/grpc_test.go index 49addbe88..dde5f4dae 100644 --- a/datastore/catalog/remote/grpc_test.go +++ b/datastore/catalog/remote/grpc_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "google.golang.org/grpc/credentials/insecure" "github.com/smartcontractkit/chainlink-deployments-framework/datastore/catalog/remote" @@ -35,6 +36,19 @@ func TestNewCatalogClient_Success(t *testing.T) { expectError: true, errorContains: "no transport security set", }, + { + name: "config_with_hmac_auth", + config: remote.CatalogConfig{ + GRPC: "localhost:9090", + Creds: insecure.NewCredentials(), + HMACConfig: &remote.HMACAuthConfig{ + KeyID: "test-key-id", + KeyRegion: "us-west-2", + Authority: "catalog.example.com", + }, + }, + expectError: false, + }, } for _, tt := range tests { @@ -48,7 +62,7 @@ func TestNewCatalogClient_Success(t *testing.T) { if tt.expectError { require.Error(t, err) require.Contains(t, err.Error(), tt.errorContains) - require.Equal(t, &remote.CatalogClient{}, client) + require.Nil(t, client) } else { require.NoError(t, err) require.NotNil(t, client) diff --git a/datastore/catalog/remote/hmac_auth.go b/datastore/catalog/remote/hmac_auth.go new file mode 100644 index 000000000..322830d96 --- /dev/null +++ b/datastore/catalog/remote/hmac_auth.go @@ -0,0 +1,135 @@ +package remote + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/kms/types" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" +) + +const ( + // dataAccessMethod is the full gRPC method name for DataAccess + dataAccessMethod = "/op_catalog.v1.datastore.Datastore/DataAccess" +) + +// HMACAuthConfig holds HMAC authentication configuration. +type HMACAuthConfig struct { + KeyID string + KeyRegion string + Authority string // The gRPC authority (hostname without port) used for HMAC signing +} + +// getKMSClient initializes and returns the KMS client, using sync.Once to ensure +// the AWS config is loaded only once per CatalogClient instance. +func (c *CatalogClient) getKMSClient(ctx context.Context) (kmsClient, error) { + c.kmsClientOnce.Do(func() { + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(c.hmacConfig.KeyRegion)) + if err != nil { + c.kmsClientErr = fmt.Errorf("failed to load AWS config: %w", err) + return + } + c.kmsClient = kms.NewFromConfig(cfg) + }) + + return c.kmsClient, c.kmsClientErr +} + +// prepareHMACContext prepares the context with HMAC authentication metadata. +// It loads AWS KMS configuration, creates a KMS client, generates an HMAC signature, +// and attaches it to the outgoing gRPC metadata. +func (c *CatalogClient) prepareHMACContext(ctx context.Context, req proto.Message) (context.Context, error) { + // Get or initialize KMS client (cached via sync.Once) + client, err := c.getKMSClient(ctx) + if err != nil { + return nil, err + } + + return c.prepareHMACContextWithClient(ctx, req, client) +} + +// prepareHMACContextWithClient prepares the context with HMAC authentication metadata using the provided KMS client. +// This method is extracted for testability. +func (c *CatalogClient) prepareHMACContextWithClient(ctx context.Context, req proto.Message, client kmsClient) (context.Context, error) { + // Create HMAC helper + hmacHelper := &kmsHMACClientHelper{ + kmsClient: client, + keyID: c.hmacConfig.KeyID, + } + + // Marshal the message to bytes for HMAC + payload, err := proto.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal message for HMAC: %w", err) + } + + // Generate HMAC signature and timestamp + signature, timestamp, err := hmacHelper.generateHMACSignature(ctx, dataAccessMethod, c.hmacConfig.Authority, payload) + if err != nil { + return nil, fmt.Errorf("failed to generate HMAC signature: %w", err) + } + + // Add HMAC authentication to gRPC metadata + md := metadata.Pairs( + "x-hmac-signature", signature, + "x-hmac-timestamp", timestamp, + ) + + // Merge with existing metadata if present + if existingMd, ok := metadata.FromOutgoingContext(ctx); ok { + md = metadata.Join(existingMd, md) + } + + return metadata.NewOutgoingContext(ctx, md), nil +} + +// kmsClient defines the interface for KMS operations needed for HMAC +type kmsClient interface { + GenerateMac(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) +} + +// kmsHMACClientHelper helps clients generate HMAC signatures using AWS KMS. +type kmsHMACClientHelper struct { + kmsClient kmsClient + keyID string +} + +// generateHMACSignature generates an HMAC signature and timestamp for the given request. +// Returns the hex-encoded signature and Unix timestamp as strings. +// The caller is responsible for adding these to transport-specific metadata/headers. +func (h *kmsHMACClientHelper) generateHMACSignature(ctx context.Context, method string, authority string, payload []byte) (signature string, timestamp string, err error) { + timestamp = strconv.FormatInt(time.Now().Unix(), 10) + + // Hash the payload with SHA-256 to stay within KMS message size limits (4096 bytes) + // and to have a predictable signature length + payloadHash := sha256.Sum256(payload) + + // Construct HMAC message using method path, authority, timestamp, and payload hash + // Format: method\nauthority\ntimestamp\nsha256(payload) + messagePrefix := fmt.Sprintf("%s\n%s\n%s\n", method, authority, timestamp) + fullMessage := append([]byte(messagePrefix), payloadHash[:]...) + + // Generate MAC using KMS with HMAC_SHA_256 + generateInput := &kms.GenerateMacInput{ + KeyId: aws.String(h.keyID), + Message: fullMessage, + MacAlgorithm: types.MacAlgorithmSpecHmacSha256, + } + + generateOutput, err := h.kmsClient.GenerateMac(ctx, generateInput) + if err != nil { + return "", "", fmt.Errorf("failed to generate MAC: %w", err) + } + + signature = hex.EncodeToString(generateOutput.Mac) + + return signature, timestamp, nil +} diff --git a/datastore/catalog/remote/hmac_auth_test.go b/datastore/catalog/remote/hmac_auth_test.go new file mode 100644 index 000000000..d3e25fa1c --- /dev/null +++ b/datastore/catalog/remote/hmac_auth_test.go @@ -0,0 +1,251 @@ +package remote + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/kms/types" + pb "github.com/smartcontractkit/chainlink-protos/op-catalog/v1/datastore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" +) + +// mockKMSClient is a mock implementation of the kmsClient interface for testing +type mockKMSClient struct { + generateMacFunc func(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) +} + +// GenerateMac implements the kmsClient interface +func (m *mockKMSClient) GenerateMac(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) { + if m.generateMacFunc != nil { + return m.generateMacFunc(ctx, params, optFns...) + } + + return nil, errors.New("mock not configured") +} + +func TestKmsHMACClientHelper_generateHMACSignature(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + keyID string + method string + authority string + payload []byte + mockGenerateMac func(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) + wantSignature string + wantErr bool + wantErrContains string + }{ + { + name: "successful signature generation", + keyID: "test-key-id", + method: "/op_catalog.v1.datastore.Datastore/DataAccess", + authority: "catalog.example.com", + payload: []byte("test payload"), + mockGenerateMac: func(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) { + // Verify inputs + assert.Equal(t, "test-key-id", *params.KeyId) + assert.Equal(t, types.MacAlgorithmSpecHmacSha256, params.MacAlgorithm) + assert.NotNil(t, params.Message) + + // Return mock MAC + mockMac := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + + return &kms.GenerateMacOutput{ + Mac: mockMac, + }, nil + }, + wantSignature: "0102030405060708", + wantErr: false, + }, + { + name: "large payload", + keyID: "test-key-id", + method: "/op_catalog.v1.datastore.Datastore/DataAccess", + authority: "catalog.example.com", + payload: make([]byte, 10000), // 10KB payload + mockGenerateMac: func(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) { + // Verify the message length stays under KMS limits (4096 bytes) (should be hashed) + // Message format: method\nauthority\ntimestamp\nsha256(payload) + assert.Less(t, len(params.Message), 4096, "Message should be hashed to stay under KMS limits") + return &kms.GenerateMacOutput{ + Mac: []byte{0xaa, 0xbb}, + }, nil + }, + wantSignature: "aabb", + wantErr: false, + }, + { + name: "kms error", + keyID: "test-key-id", + method: "/op_catalog.v1.datastore.Datastore/DataAccess", + authority: "catalog.example.com", + payload: []byte("test payload"), + mockGenerateMac: func(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) { + return nil, errors.New("kms service unavailable") + }, + wantErr: true, + wantErrContains: "failed to generate MAC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + mockClient := &mockKMSClient{ + generateMacFunc: tt.mockGenerateMac, + } + + helper := &kmsHMACClientHelper{ + kmsClient: mockClient, + keyID: tt.keyID, + } + + signature, timestamp, err := helper.generateHMACSignature(ctx, tt.method, tt.authority, tt.payload) + + if tt.wantErr { + require.Error(t, err) + if tt.wantErrContains != "" { + assert.Contains(t, err.Error(), tt.wantErrContains) + } + assert.Empty(t, signature) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantSignature, signature) + assert.NotEmpty(t, timestamp, "timestamp should not be empty") + // Verify timestamp is a valid unix timestamp string + assert.Regexp(t, `^\d+$`, timestamp, "timestamp should be numeric") + } + }) + } +} + +func TestCatalogClient_prepareHMACContextWithClient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hmacConfig *HMACAuthConfig + request *pb.DataAccessRequest + existingMeta metadata.MD + mockGenerateMac func(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) + wantErr bool + wantErrContains string + validateResult func(t *testing.T, ctx context.Context) + }{ + { + name: "successful context preparation with metadata", + hmacConfig: &HMACAuthConfig{ + KeyID: "test-key-id", + KeyRegion: "us-west-2", + Authority: "catalog.example.com", + }, + request: &pb.DataAccessRequest{}, + mockGenerateMac: func(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) { + mockMac := []byte{0x01, 0x02, 0x03, 0x04} + return &kms.GenerateMacOutput{ + Mac: mockMac, + }, nil + }, + wantErr: false, + validateResult: func(t *testing.T, ctx context.Context) { + t.Helper() + + md, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok, "metadata should be present") + assert.NotEmpty(t, md.Get("x-hmac-signature"), "signature should be present") + assert.NotEmpty(t, md.Get("x-hmac-timestamp"), "timestamp should be present") + assert.Equal(t, "01020304", md.Get("x-hmac-signature")[0]) + }, + }, + { + name: "merge with existing metadata", + hmacConfig: &HMACAuthConfig{ + KeyID: "test-key-id", + KeyRegion: "us-west-2", + Authority: "catalog.example.com", + }, + request: &pb.DataAccessRequest{}, + existingMeta: metadata.Pairs( + "existing-key", "existing-value", + "another-key", "another-value", + ), + mockGenerateMac: func(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) { + mockMac := []byte{0xaa, 0xbb} + return &kms.GenerateMacOutput{ + Mac: mockMac, + }, nil + }, + wantErr: false, + validateResult: func(t *testing.T, ctx context.Context) { + t.Helper() + + md, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok, "metadata should be present") + // Check existing metadata is preserved + assert.Equal(t, "existing-value", md.Get("existing-key")[0]) + assert.Equal(t, "another-value", md.Get("another-key")[0]) + // Check new HMAC metadata is added + assert.NotEmpty(t, md.Get("x-hmac-signature")) + assert.NotEmpty(t, md.Get("x-hmac-timestamp")) + }, + }, + { + name: "kms error propagates", + hmacConfig: &HMACAuthConfig{ + KeyID: "test-key-id", + KeyRegion: "us-west-2", + Authority: "catalog.example.com", + }, + request: &pb.DataAccessRequest{}, + mockGenerateMac: func(ctx context.Context, params *kms.GenerateMacInput, optFns ...func(*kms.Options)) (*kms.GenerateMacOutput, error) { + return nil, errors.New("kms access denied") + }, + wantErr: true, + wantErrContains: "failed to generate HMAC signature", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Add existing metadata to context if provided + if tt.existingMeta != nil { + ctx = metadata.NewOutgoingContext(ctx, tt.existingMeta) + } + + mockClient := &mockKMSClient{ + generateMacFunc: tt.mockGenerateMac, + } + + catalogClient := &CatalogClient{ + hmacConfig: tt.hmacConfig, + } + + resultCtx, err := catalogClient.prepareHMACContextWithClient(ctx, tt.request, mockClient) + + if tt.wantErr { + require.Error(t, err) + if tt.wantErrContains != "" { + assert.Contains(t, err.Error(), tt.wantErrContains) + } + } else { + require.NoError(t, err) + require.NotNil(t, resultCtx) + if tt.validateResult != nil { + tt.validateResult(t, resultCtx) + } + } + }) + } +} diff --git a/datastore/catalog/remote/utils.go b/datastore/catalog/remote/utils.go index 0f86ccd0e..12671cced 100644 --- a/datastore/catalog/remote/utils.go +++ b/datastore/catalog/remote/utils.go @@ -10,8 +10,8 @@ func ThrowAndCatch( catalog *catalogDataStore, request *pb.DataAccessRequest, ) (*pb.DataAccessResponse, error) { - // Create a bidirectional stream - stream, err := catalog.client.DataAccess() + // Create a bidirectional stream with the initial request for HMAC + stream, err := catalog.client.DataAccess(request) if err != nil { return nil, fmt.Errorf("failed to create data access stream: %w", err) } diff --git a/datastore/catalog_syncer_integration_test.go b/datastore/catalog_syncer_integration_test.go index 50c808ce2..c76ee41a1 100644 --- a/datastore/catalog_syncer_integration_test.go +++ b/datastore/catalog_syncer_integration_test.go @@ -3,9 +3,11 @@ package datastore_test import ( "context" "os" + "strings" "testing" "github.com/Masterminds/semver/v3" + pb "github.com/smartcontractkit/chainlink-protos/op-catalog/v1/datastore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/credentials/insecure" @@ -34,11 +36,19 @@ func TestMergeDataStoreToCatalog_FullSync(t *testing.T) { }) if err != nil { t.Skipf("Catalog service not available at %s: %v. Start with: cd op-catalog && docker-compose up -d", catalogAddr, err) + return } - testStream, streamErr := testClient.DataAccess() + testStream, streamErr := testClient.DataAccess(&pb.DataAccessRequest{}) if streamErr != nil { + // Check if it's an auth error + if strings.Contains(streamErr.Error(), "Unauthenticated") || strings.Contains(streamErr.Error(), "HMAC") { + t.Skipf("Catalog service at %s requires HMAC authentication. Skipping integration test.", catalogAddr) + + return + } t.Skipf("Cannot connect to catalog service at %s: %v. Start with: cd op-catalog && docker-compose up -d", catalogAddr, streamErr) + return } _ = testStream.CloseSend() @@ -154,7 +164,14 @@ func TestMergeDataStoreToCatalog_FullSync(t *testing.T) { // Step 2: Merge datastore to catalog (full sync for initial migration) t.Log("Step 2: Merging local datastore to catalog...") err = datastore.MergeDataStoreToCatalog(ctx, sealedDS, catalogStore) - require.NoError(t, err, "Failed to merge datastore to catalog") + if err != nil { + // Check if it's an auth error and skip if so + if strings.Contains(err.Error(), "Unauthenticated") || strings.Contains(err.Error(), "HMAC") { + t.Skipf("Catalog service requires HMAC authentication: %v", err) + return + } + require.NoError(t, err, "Failed to merge datastore to catalog") + } t.Log("✅ Merge completed successfully!") // Step 3: Verify data was synced correctly by reading back from catalog @@ -249,11 +266,19 @@ func TestMergeDataStoreToCatalog_Incremental(t *testing.T) { }) if err != nil { t.Skipf("Catalog service not available at %s: %v. Start with: cd op-catalog && docker-compose up -d", catalogAddr, err) + return } - testStream, streamErr := testClient.DataAccess() + testStream, streamErr := testClient.DataAccess(&pb.DataAccessRequest{}) if streamErr != nil { + // Check if it's an auth error + if strings.Contains(streamErr.Error(), "Unauthenticated") || strings.Contains(streamErr.Error(), "HMAC") { + t.Skipf("Catalog service at %s requires HMAC authentication. Skipping integration test.", catalogAddr) + + return + } t.Skipf("Cannot connect to catalog service at %s: %v. Start with: cd op-catalog && docker-compose up -d", catalogAddr, streamErr) + return } _ = testStream.CloseSend() @@ -299,7 +324,14 @@ func TestMergeDataStoreToCatalog_Incremental(t *testing.T) { // Merge initial state to catalog err = datastore.MergeDataStoreToCatalog(ctx, initialDS.Seal(), catalogStore) - require.NoError(t, err) + if err != nil { + // Check if it's an auth error and skip if so + if strings.Contains(err.Error(), "Unauthenticated") || strings.Contains(err.Error(), "HMAC") { + t.Skipf("Catalog service requires HMAC authentication: %v", err) + return + } + require.NoError(t, err) + } t.Log("✅ Initial state merged") // Step 2: Create a migration datastore with new contracts @@ -396,11 +428,19 @@ func TestMergeDataStoreToCatalog_TransactionRollback(t *testing.T) { }) if err != nil { t.Skipf("Catalog service not available at %s: %v. Start with: cd op-catalog && docker-compose up -d", catalogAddr, err) + return } - testStream, streamErr := testClient.DataAccess() + testStream, streamErr := testClient.DataAccess(&pb.DataAccessRequest{}) if streamErr != nil { + // Check if it's an auth error + if strings.Contains(streamErr.Error(), "Unauthenticated") || strings.Contains(streamErr.Error(), "HMAC") { + t.Skipf("Catalog service at %s requires HMAC authentication. Skipping integration test.", catalogAddr) + + return + } t.Skipf("Cannot connect to catalog service at %s: %v. Start with: cd op-catalog && docker-compose up -d", catalogAddr, streamErr) + return } _ = testStream.CloseSend() @@ -445,7 +485,14 @@ func TestMergeDataStoreToCatalog_TransactionRollback(t *testing.T) { // Merge should succeed err = datastore.MergeDataStoreToCatalog(ctx, localDS.Seal(), catalogStore) - require.NoError(t, err) + if err != nil { + // Check if it's an auth error and skip if so + if strings.Contains(err.Error(), "Unauthenticated") || strings.Contains(err.Error(), "HMAC") { + t.Skipf("Catalog service requires HMAC authentication: %v", err) + return + } + require.NoError(t, err) + } // Verify data was written addressRefs, err := catalogStore.Addresses().Fetch(ctx) diff --git a/engine/cld/catalog/catalog.go b/engine/cld/catalog/catalog.go index d771bdc7d..387c353e8 100644 --- a/engine/cld/catalog/catalog.go +++ b/engine/cld/catalog/catalog.go @@ -2,10 +2,12 @@ package catalog import ( "context" + "strings" fdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" catalogremote "github.com/smartcontractkit/chainlink-deployments-framework/datastore/catalog/remote" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config" + cfgenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/env" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" credentials "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/internal/credentials" ) @@ -13,7 +15,7 @@ import ( // LoadCatalog loads a catalog data store for the specified domain and environment. func LoadCatalog(ctx context.Context, env string, config *config.Config, domain domain.Domain) (fdatastore.CatalogStore, error) { - catalogClient, err := loadCatalogClient(ctx, env, config.Env.Catalog.GRPC) + catalogClient, err := loadCatalogClient(ctx, env, &config.Env.Catalog) if err != nil { return nil, err } @@ -29,17 +31,49 @@ func LoadCatalog(ctx context.Context, env string, // loadCatalogClient initializes a Catalogue client using the grpc and gap config. func loadCatalogClient( - ctx context.Context, env string, url string, + ctx context.Context, env string, cfg *cfgenv.CatalogConfig, ) (*catalogremote.CatalogClient, error) { creds := credentials.GetCredsForEnv(env) - client, err := catalogremote.NewCatalogClient(ctx, catalogremote.CatalogConfig{ - GRPC: url, + catalogCfg := catalogremote.CatalogConfig{ + GRPC: cfg.GRPC, Creds: creds, - }) + } + + // Configure HMAC authentication if KMS key is provided + if cfg.Auth != nil && cfg.Auth.KMSKeyID != "" { + // Extract authority from GRPC URL (hostname without port) + authority := extractAuthority(cfg.GRPC) + + catalogCfg.HMACConfig = &catalogremote.HMACAuthConfig{ + KeyID: cfg.Auth.KMSKeyID, + KeyRegion: cfg.Auth.KMSKeyRegion, + Authority: authority, + } + } + + client, err := catalogremote.NewCatalogClient(ctx, catalogCfg) if err != nil { return nil, err } return client, nil } + +// extractAuthority extracts the authority (hostname without scheme and port) from a gRPC URL. +// Examples: +// - "grpc.example.com:443" -> "grpc.example.com" +// - "https://grpc.example.com:443" -> "grpc.example.com" +// - "grpc.example.com" -> "grpc.example.com" +func extractAuthority(grpcURL string) string { + // Remove scheme if present + authority := strings.TrimPrefix(grpcURL, "https://") + authority = strings.TrimPrefix(authority, "http://") + + // Remove port if present + if idx := strings.LastIndex(authority, ":"); idx != -1 { + authority = authority[:idx] + } + + return authority +} diff --git a/engine/cld/catalog/catalog_test.go b/engine/cld/catalog/catalog_test.go index 1602fe313..7161b710b 100644 --- a/engine/cld/catalog/catalog_test.go +++ b/engine/cld/catalog/catalog_test.go @@ -75,23 +75,40 @@ func TestLoadCatalogClient(t *testing.T) { tests := []struct { name string env string - url string + cfg *cfgenv.CatalogConfig wantErr string }{ { name: "successful client creation with local env", env: "local", - url: "localhost:50051", + cfg: &cfgenv.CatalogConfig{ + GRPC: "localhost:50051", + }, }, { name: "successful client creation with non-local env", env: "testnet", - url: "grpc.example.com:443", + cfg: &cfgenv.CatalogConfig{ + GRPC: "grpc.example.com:443", + }, }, { name: "empty url - should still create client", env: "testnet", - url: "", + cfg: &cfgenv.CatalogConfig{ + GRPC: "", + }, + }, + { + name: "client with HMAC authentication", + env: "testnet", + cfg: &cfgenv.CatalogConfig{ + GRPC: "grpc.example.com:443", + Auth: &cfgenv.CatalogAuthConfig{ + KMSKeyID: "test-key-id", + KMSKeyRegion: "us-west-2", + }, + }, }, } @@ -101,7 +118,7 @@ func TestLoadCatalogClient(t *testing.T) { ctx := t.Context() - result, err := loadCatalogClient(ctx, tt.env, tt.url) + result, err := loadCatalogClient(ctx, tt.env, tt.cfg) if tt.wantErr != "" { require.Error(t, err) @@ -117,3 +134,53 @@ func TestLoadCatalogClient(t *testing.T) { }) } } + +func TestExtractAuthority(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + grpcURL string + expected string + }{ + { + name: "hostname with port", + grpcURL: "grpc.example.com:443", + expected: "grpc.example.com", + }, + { + name: "hostname without port", + grpcURL: "grpc.example.com", + expected: "grpc.example.com", + }, + { + name: "https scheme with port", + grpcURL: "https://grpc.example.com:443", + expected: "grpc.example.com", + }, + { + name: "http scheme with port", + grpcURL: "http://grpc.example.com:8080", + expected: "grpc.example.com", + }, + { + name: "localhost with port", + grpcURL: "localhost:50051", + expected: "localhost", + }, + { + name: "localhost without port", + grpcURL: "localhost", + expected: "localhost", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := extractAuthority(tt.grpcURL) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/engine/cld/config/env/config.go b/engine/cld/config/env/config.go index 3aad0e368..b79f516ea 100644 --- a/engine/cld/config/env/config.go +++ b/engine/cld/config/env/config.go @@ -111,9 +111,16 @@ type OCRConfig struct { XProposers string `mapstructure:"x_proposers" yaml:"x_proposers"` // Secret: BIP39 mnemonic phrase for the OCR proposer. } +// CatalogAuthConfig is the configuration for the Catalog authentication. +type CatalogAuthConfig struct { + KMSKeyID string `mapstructure:"kms_key_id" yaml:"kms_key_id"` // AWS KMS Key ID (arn or alias) + KMSKeyRegion string `mapstructure:"kms_key_region" yaml:"kms_key_region"` // AWS KMS Key Region (e.g. us-west-1) +} + // CatalogConfig is the configuration to connect to the Catalog. type CatalogConfig struct { - GRPC string `mapstructure:"grpc" yaml:"grpc"` // The gRPC URL for the Catalog. Used to interact with the Catalog API. + GRPC string `mapstructure:"grpc" yaml:"grpc"` // The gRPC URL for the Catalog. Used to interact with the Catalog API. + Auth *CatalogAuthConfig `mapstructure:"auth" yaml:"auth,omitempty"` // The authentication configuration for the Catalog. } // OnchainConfig wraps the configuration for the onchain components. @@ -229,7 +236,9 @@ var ( "offchain.job_distributor.endpoints.grpc": {"OFFCHAIN_JD_ENDPOINTS_GRPC", "JD_GRPC"}, "offchain.ocr.x_signers": {"OFFCHAIN_OCR_X_SIGNERS", "OCR_X_SIGNERS"}, "offchain.ocr.x_proposers": {"OFFCHAIN_OCR_X_PROPOSERS", "OCR_X_PROPOSERS"}, - "catalog.grpc": {"CATALOG_GRPC", "CATALOG_SERVICE_GRPC"}, + "catalog.grpc": {"CATALOG_GRPC"}, + "catalog.auth.kms_key_id": {"CATALOG_AUTH_KMS_KEY_ID"}, + "catalog.auth.kms_key_region": {"CATALOG_AUTH_KMS_KEY_REGION"}, } ) diff --git a/engine/cld/config/env/config_test.go b/engine/cld/config/env/config_test.go index 090d10f39..1b51a0918 100644 --- a/engine/cld/config/env/config_test.go +++ b/engine/cld/config/env/config_test.go @@ -63,6 +63,10 @@ var ( }, Catalog: CatalogConfig{ GRPC: "http://localhost:1000", + Auth: &CatalogAuthConfig{ + KMSKeyID: "123", + KMSKeyRegion: "us-east-1", + }, }, } @@ -90,6 +94,8 @@ var ( "OFFCHAIN_OCR_X_SIGNERS": "awkward bat", "OFFCHAIN_OCR_X_PROPOSERS": "caring deer", "CATALOG_GRPC": "http://localhost:8080", + "CATALOG_AUTH_KMS_KEY_ID": "123", + "CATALOG_AUTH_KMS_KEY_REGION": "us-east-1", } legacyEnvVars = map[string]string{ @@ -112,10 +118,12 @@ var ( "JD_GRPC": "GRPC2", "OCR_X_SIGNERS": "awkward bat", "OCR_X_PROPOSERS": "caring deer", - "CATALOG_SERVICE_GRPC": "http://localhost:8080", // These values do not have a legacy equivalent - "ONCHAIN_TON_DEPLOYER_KEY": "0x123", - "ONCHAIN_TON_WALLET_VERSION": "V5R1", + "ONCHAIN_TON_DEPLOYER_KEY": "0x123", + "ONCHAIN_TON_WALLET_VERSION": "V5R1", + "CATALOG_GRPC": "http://localhost:8080", + "CATALOG_AUTH_KMS_KEY_ID": "123", + "CATALOG_AUTH_KMS_KEY_REGION": "us-east-1", } // envCfg is the config that is loaded from the environment variables. @@ -171,6 +179,10 @@ var ( }, Catalog: CatalogConfig{ GRPC: "http://localhost:8080", + Auth: &CatalogAuthConfig{ + KMSKeyID: "123", + KMSKeyRegion: "us-east-1", + }, }, } ) @@ -330,6 +342,7 @@ func Test_YAML_Marshal_Unmarshal(t *testing.T) { cfg.Offchain.JobDistributor.Auth = nil cfg.Onchain.EVM.Seth = nil + cfg.Catalog.Auth = nil return &cfg }, diff --git a/engine/cld/config/env/testdata/config.yml b/engine/cld/config/env/testdata/config.yml index b29b56099..3ea26d297 100644 --- a/engine/cld/config/env/testdata/config.yml +++ b/engine/cld/config/env/testdata/config.yml @@ -38,3 +38,6 @@ offchain: x_proposers: "furious fox" catalog: grpc: "http://localhost:1000" + auth: + kms_key_id: "123" + kms_key_region: "us-east-1" diff --git a/engine/cld/config/env_test.go b/engine/cld/config/env_test.go index 85f5e0900..f0e2ca1ba 100644 --- a/engine/cld/config/env_test.go +++ b/engine/cld/config/env_test.go @@ -67,7 +67,6 @@ func Test_LoadEnvConfig(t *testing.T) { //nolint:paralleltest // These tests are "SOLANA_PROGRAM_PATH": "0xcde", "APTOS_DEPLOYER_KEY": "0x345", "TRON_DEPLOYER_KEY": "0x456", - "CATALOG_SERVICE_GRPC": "http://localhost:2000", }, wantFunc: func(t *testing.T, cfg *cfgenv.Config) { t.Helper() @@ -92,7 +91,6 @@ func Test_LoadEnvConfig(t *testing.T) { //nolint:paralleltest // These tests are assert.Equal(t, "0x234", cfg.Onchain.Solana.WalletKey) assert.Equal(t, "0x345", cfg.Onchain.Aptos.DeployerKey) assert.Equal(t, "0x456", cfg.Onchain.Tron.DeployerKey) - assert.Equal(t, "http://localhost:2000", cfg.Catalog.GRPC) }, }, { @@ -114,6 +112,8 @@ func Test_LoadEnvConfig(t *testing.T) { //nolint:paralleltest // These tests are "ONCHAIN_EVM_SETH_CONFIG_FILE_PATH": "/tmp/config", "ONCHAIN_EVM_SETH_GETH_WRAPPER_DIRS": "dir1,dir2", "CATALOG_GRPC": "http://localhost:2000", + "CATALOG_AUTH_KMS_KEY_ID": "c4f1a2b3", + "CATALOG_AUTH_KMS_KEY_REGION": "us-east-1", "ONCHAIN_SOLANA_WALLET_KEY": "0x234", "ONCHAIN_SOLANA_PROGRAM_PATH": "0xcde", "ONCHAIN_APTOS_DEPLOYER_KEY": "0x345", @@ -147,6 +147,8 @@ func Test_LoadEnvConfig(t *testing.T) { //nolint:paralleltest // These tests are assert.Equal(t, "0x456", cfg.Onchain.Tron.DeployerKey) assert.Equal(t, "0x567", cfg.Onchain.Sui.DeployerKey) assert.Equal(t, "http://localhost:2000", cfg.Catalog.GRPC) + assert.Equal(t, "c4f1a2b3", cfg.Catalog.Auth.KMSKeyID) + assert.Equal(t, "us-east-1", cfg.Catalog.Auth.KMSKeyRegion) }, }, } diff --git a/go.mod b/go.mod index 2db1f3b69..978e62f8a 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,8 @@ require ( github.com/aptos-labs/aptos-go-sdk v1.11.0 github.com/avast/retry-go/v4 v4.6.1 github.com/aws/aws-sdk-go v1.55.7 - github.com/aws/aws-sdk-go-v2 v1.38.1 - github.com/aws/aws-sdk-go-v2/config v1.28.0 + github.com/aws/aws-sdk-go-v2 v1.39.6 + github.com/aws/aws-sdk-go-v2/config v1.31.17 github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.57.1 github.com/block-vision/sui-go-sdk v1.1.2 github.com/cosmos/go-bip39 v1.0.0 @@ -57,6 +57,7 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2/service/kms v1.47.1 // indirect github.com/btcsuite/btcutil v1.0.2 // indirect github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/lib/pq v1.10.9 // indirect @@ -77,17 +78,17 @@ require ( github.com/XSAM/otelsql v0.37.0 // indirect github.com/avast/retry-go v3.0.0+incompatible // indirect github.com/awalterschulze/gographviz v2.0.3+incompatible // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.41 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 // indirect - github.com/aws/smithy-go v1.22.5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/go.sum b/go.sum index 51dce6f19..ac6e203a8 100644 --- a/go.sum +++ b/go.sum @@ -41,32 +41,60 @@ github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.38.1 h1:j7sc33amE74Rz0M/PoCpsZQ6OunLqys/m5antM0J+Z8= github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk= +github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/config v1.28.0 h1:FosVYWcqEtWNxHn8gB/Vs6jOlNwSoyOCA/g/sxyySOQ= github.com/aws/aws-sdk-go-v2/config v1.28.0/go.mod h1:pYhbtvg1siOOg8h5an77rXle9tVG8T+BWLWAo7cOukc= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= github.com/aws/aws-sdk-go-v2/credentials v1.17.41 h1:7gXo+Axmp+R4Z+AK8YFQO0ZV3L0gizGINCOWxSLY9W8= github.com/aws/aws-sdk-go-v2/credentials v1.17.41/go.mod h1:u4Eb8d3394YLubphT4jLEwN1rLNq2wFOlT6OuxFwPzU= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 h1:TMH3f/SCAWdNtXXVPPu5D6wrr4G5hI1rAxbcocKfC7Q= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17/go.mod h1:1ZRXLdTpzdJb9fwTMXiLipENRxkGMTn1sfKexGllQCw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 h1:IdCLsiiIj5YJ3AFevsewURCPV+YWUlOW8JiPhoAy8vg= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 h1:j7vjtr1YIssWQOMeOWRbh3z8g2oY/xPjnZH2gLY4sGw= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.57.1 h1:gKFnV8HEJomx4XFOVBXRUA5hphkhpnUjqJsYPCc9K8Q= github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.57.1/go.mod h1:+UxryRSMGMtqsvxdnws+VpNyFYWRkw4ZlM+5AC160XA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 h1:s7NA1SOw8q/5c0wr8477yOPp0z+uBaXBnLE0XYb0POA= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2/go.mod h1:fnjjWyAW/Pj5HYOxl9LJqWtEwS7W2qgcRLWP+uWbss0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= +github.com/aws/aws-sdk-go-v2/service/kms v1.47.1 h1:6+C0RoGF4HJQALrsecOXN7cm/l5rgNHCw2xbcvFgpH4= +github.com/aws/aws-sdk-go-v2/service/kms v1.47.1/go.mod h1:VJcNH6BLr+3VJwinRKdotLOMglHO8mIKlD3ea5c7hbw= github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 h1:bSYXVyUzoTHoKalBmwaZxs97HU9DWWI3ehHSAMa7xOk= github.com/aws/aws-sdk-go-v2/service/sso v1.24.2/go.mod h1:skMqY7JElusiOUjMJMOv1jJsP7YUg7DrhgqZZWuzu1U= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 h1:AhmO1fHINP9vFYUE0LHzCWg/LfUWUF+zFPEcY9QXb7o= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2/go.mod h1:o8aQygT2+MVP0NaV6kbdE1YnnIM8RRVQzoeUH45GOdI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 h1:CiS7i0+FUe+/YY1GvIBLLrR/XNGZ4CtM1Ll0XavNuVo= github.com/aws/aws-sdk-go-v2/service/sts v1.32.2/go.mod h1:HtaiBI8CjYoNVde8arShXb94UbQQi9L4EMr6D+xGBwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0=