diff --git a/catalog/changesets/update_contract_metadata.go b/catalog/changesets/update_contract_metadata.go new file mode 100644 index 0000000..3301b9d --- /dev/null +++ b/catalog/changesets/update_contract_metadata.go @@ -0,0 +1,70 @@ +package changesets + +import ( + "errors" + "fmt" + + "github.com/samber/lo" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldfops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/cld-changesets/catalog/operations" +) + +// UpdateContractMetadataChangeset updates existing contract metadata entries in the Catalog service. +type UpdateContractMetadataChangeset struct{} + +type UpdateContractMetadataChangesetInput struct { + ContractMetadata []cldfdatastore.ContractMetadata `json:"contractMetadata"` +} + +// VerifyPreconditions ensures the input is valid. +func (UpdateContractMetadataChangeset) VerifyPreconditions(e cldf.Environment, input UpdateContractMetadataChangesetInput) error { + if len(input.ContractMetadata) == 0 { + return errors.New("missing contract metadata input") + } + if e.DataStore == nil { + return errors.New("missing datastore in environment") + } + + uniqContractMetadata := lo.UniqBy(input.ContractMetadata, func(cm cldfdatastore.ContractMetadata) cldfdatastore.ContractMetadataKey { + return cm.Key() + }) + if len(uniqContractMetadata) != len(input.ContractMetadata) { + return errors.New("duplicate contract metadata entries found in input") + } + + for _, contractMetadata := range input.ContractMetadata { + _, err := e.DataStore.ContractMetadata().Get(contractMetadata.Key()) + if errors.Is(err, cldfdatastore.ErrContractMetadataNotFound) { + return fmt.Errorf("contract metadata for chain selector %v and address %v does not exist", + contractMetadata.ChainSelector, contractMetadata.Address) + } + if err != nil { + return fmt.Errorf("failed to retrieve contract metadata for chain selector %v and address %v: %w", + contractMetadata.ChainSelector, contractMetadata.Address, err) + } + } + + return nil +} + +// Apply executes the changeset, updating the contract metadata in the Catalog service. +func (UpdateContractMetadataChangeset) Apply( + e cldf.Environment, input UpdateContractMetadataChangesetInput, +) (cldf.ChangesetOutput, error) { + deps := operations.UpdateContractMetadataDeps{DataStore: e.DataStore} + opInput := operations.UpdateContractMetadataInput{ContractMetadata: input.ContractMetadata} + + report, err := cldfops.ExecuteOperation(e.OperationsBundle, operations.UpdateContractMetadataOp, deps, opInput) + out := cldf.ChangesetOutput{ + DataStore: report.Output.DataStore, + Reports: []cldfops.Report[any, any]{report.ToGenericReport()}, + } + if err != nil { + return out, err + } + + return out, nil +} diff --git a/catalog/changesets/update_contract_metadata_test.go b/catalog/changesets/update_contract_metadata_test.go new file mode 100644 index 0000000..ba19f14 --- /dev/null +++ b/catalog/changesets/update_contract_metadata_test.go @@ -0,0 +1,164 @@ +package changesets + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" + + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldfoperations "github.com/smartcontractkit/chainlink-deployments-framework/operations" + cldflogger "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + + "github.com/smartcontractkit/cld-changesets/catalog/operations" +) + +func TestUpdateContractMetadataChangeset_VerifyPreconditions(t *testing.T) { + t.Parallel() + + contractMetadata1 := cldfdatastore.ContractMetadata{Address: "0x01", ChainSelector: 1234, Metadata: "value1"} + contractMetadata2 := cldfdatastore.ContractMetadata{Address: "0x01", ChainSelector: 1234, Metadata: "value2"} + + tests := []struct { + name string + env cldf.Environment + input UpdateContractMetadataChangesetInput + wantErr string + }{ + { + name: "success: valid preconditions", + env: cldf.Environment{DataStore: func() cldfdatastore.DataStore { + ds := cldfdatastore.NewMemoryDataStore() + err := ds.ContractMetadata().Add(contractMetadata1) + require.NoError(t, err) + + return ds.Seal() + }()}, + input: UpdateContractMetadataChangesetInput{ + ContractMetadata: []cldfdatastore.ContractMetadata{contractMetadata1}, + }, + }, + { + name: "failure: missing datastore", + env: cldf.Environment{}, + input: UpdateContractMetadataChangesetInput{ + ContractMetadata: []cldfdatastore.ContractMetadata{{}}, + }, + wantErr: "missing datastore in environment", + }, + { + name: "failure: no contract metadata given", + env: cldf.Environment{DataStore: cldfdatastore.NewMemoryDataStore().Seal()}, + input: UpdateContractMetadataChangesetInput{ + ContractMetadata: []cldfdatastore.ContractMetadata{}, + }, + wantErr: "missing contract metadata input", + }, + { + name: "failure: duplicate entries", + env: cldf.Environment{DataStore: cldfdatastore.NewMemoryDataStore().Seal()}, + input: UpdateContractMetadataChangesetInput{ + ContractMetadata: []cldfdatastore.ContractMetadata{contractMetadata1, contractMetadata2}, + }, + wantErr: "duplicate contract metadata entries found in input", + }, + { + name: "failure: contract metadata does not exist", + env: cldf.Environment{DataStore: cldfdatastore.NewMemoryDataStore().Seal()}, + input: UpdateContractMetadataChangesetInput{ + ContractMetadata: []cldfdatastore.ContractMetadata{contractMetadata1}, + }, + wantErr: "contract metadata for chain selector 1234 and address 0x01 does not exist", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := UpdateContractMetadataChangeset{}.VerifyPreconditions(tt.env, tt.input) + + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestUpdateContractMetadataChangeset_Apply(t *testing.T) { + t.Parallel() + + contractMetadata1 := cldfdatastore.ContractMetadata{Address: "0x01", ChainSelector: 1234, Metadata: "value1"} + contractMetadata2 := cldfdatastore.ContractMetadata{Address: "0x02", ChainSelector: 1234, Metadata: "value2"} + contractMetadata1Updated := cldfdatastore.ContractMetadata{Address: "0x01", ChainSelector: 1234, Metadata: "updated-value1"} + contractMetadata2Updated := cldfdatastore.ContractMetadata{Address: "0x02", ChainSelector: 1234, Metadata: "updated-value2"} + + tests := []struct { + name string + env cldf.Environment + input UpdateContractMetadataChangesetInput + want cldf.ChangesetOutput + wantErr string + }{ + { + name: "success: updates two entries in contract metadata", + env: cldf.Environment{ + DataStore: testDataStoreWithContractMetadata(t, contractMetadata1, contractMetadata2).Seal(), + OperationsBundle: cldfoperations.NewBundle(t.Context, cldflogger.Test(t), cldfoperations.NewMemoryReporter()), + }, + input: UpdateContractMetadataChangesetInput{ + ContractMetadata: []cldfdatastore.ContractMetadata{contractMetadata1Updated, contractMetadata2Updated}, + }, + want: cldf.ChangesetOutput{ + DataStore: testDataStoreWithContractMetadata(t, contractMetadata1Updated, contractMetadata2Updated), + Reports: []cldfoperations.Report[any, any]{{ + Def: cldfoperations.Definition{ + ID: "catalog-update-contract-metadata", + Version: semver.MustParse("1.0.0"), + Description: "Update contract metadata entries in the Catalog service", + }, + Input: operations.UpdateContractMetadataInput{ + ContractMetadata: []cldfdatastore.ContractMetadata{contractMetadata1Updated, contractMetadata2Updated}, + }, + Output: operations.UpdateContractMetadataOutput{ + DataStore: testDataStoreWithContractMetadata(t, contractMetadata1Updated, contractMetadata2Updated), + }, + }}, + }, + }, + { + name: "failure: fails to update entry that does not exist", + env: cldf.Environment{ + DataStore: testDataStoreWithContractMetadata(t, contractMetadata1).Seal(), + OperationsBundle: cldfoperations.NewBundle(t.Context, cldflogger.Test(t), cldfoperations.NewMemoryReporter()), + }, + input: UpdateContractMetadataChangesetInput{ + ContractMetadata: []cldfdatastore.ContractMetadata{contractMetadata1Updated, contractMetadata2Updated}, + }, + wantErr: "failed to update contract metadata entry 1 in catalog store: " + + "no contract metadata record can be found for the provided key", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := UpdateContractMetadataChangeset{}.Apply(tt.env, tt.input) + + if tt.wantErr == "" { + require.NoError(t, err) + require.Empty(t, + cmp.Diff(tt.want, got, + cmpopts.IgnoreFields(cldfoperations.Report[any, any]{}, "ID", "Timestamp"), + cmpopts.IgnoreUnexported(cldfdatastore.MemoryAddressRefStore{}, cldfdatastore.MemoryChainMetadataStore{}, + cldfdatastore.MemoryContractMetadataStore{}, cldfdatastore.MemoryEnvMetadataStore{}))) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} diff --git a/catalog/operations/update_contract_metadata.go b/catalog/operations/update_contract_metadata.go new file mode 100644 index 0000000..ed8178b --- /dev/null +++ b/catalog/operations/update_contract_metadata.go @@ -0,0 +1,51 @@ +package operations + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldfops "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// UpdateContractMetadataDeps holds non-serializable dependencies for the +// UpdateContractMetadataOp operation. +type UpdateContractMetadataDeps struct { + DataStore cldfdatastore.DataStore +} + +// UpdateContractMetadataInput is the serializable input of an UpdateContractMetadataOp invocation. +type UpdateContractMetadataInput struct { + ContractMetadata []cldfdatastore.ContractMetadata +} + +// UpdateContractMetadataOutput is the serializable output of an UpdateContractMetadataOp invocation. +type UpdateContractMetadataOutput struct { + DataStore cldfdatastore.MutableDataStore +} + +// UpdateContractMetadataOp updates existing contract metadata entries in the Catalog service. +var UpdateContractMetadataOp = cldfops.NewOperation( + "catalog-update-contract-metadata", + semver.MustParse("1.0.0"), + "Update contract metadata entries in the Catalog service", + func(b cldfops.Bundle, deps UpdateContractMetadataDeps, input UpdateContractMetadataInput) (UpdateContractMetadataOutput, error) { + dataStore := cldfdatastore.NewMemoryDataStore() + err := dataStore.Merge(deps.DataStore) + if err != nil { + return UpdateContractMetadataOutput{}, fmt.Errorf("failed to create memory data store: %w", err) + } + + for i, item := range input.ContractMetadata { + err = dataStore.ContractMetadata().Update(item) + if err != nil { + return UpdateContractMetadataOutput{}, fmt.Errorf("failed to update contract metadata entry %d in catalog store: %w", i, err) + } + } + + b.Logger.Infow("Catalog ContractMetadata updated successfully") + + return UpdateContractMetadataOutput{DataStore: dataStore}, nil + }, +)