diff --git a/cmd/flow/main.go b/cmd/flow/main.go index b33b32fbf..27e91e4a1 100644 --- a/cmd/flow/main.go +++ b/cmd/flow/main.go @@ -20,10 +20,6 @@ package main import ( - "fmt" - "os" - "strings" - "github.com/spf13/cobra" "github.com/onflow/flow-cli/internal/accounts" @@ -57,34 +53,6 @@ func main() { var cmd = &cobra.Command{ Use: "flow", TraverseChildren: true, - // Messaging for Cadence 1.0 upgrade - PersistentPreRun: func(cmd *cobra.Command, args []string) { - outputFlag, _ := cmd.Flags().GetString("output") - // If output is set to json, do not append any message - if outputFlag != "json" { - - width := 80 - url := "https://cadence-lang.org/docs/cadence_migration_guide" - - // Function to center text within a given width - centerText := func(text string, width int) string { - space := (width - len(text)) / 2 - if space < 0 { - space = 0 - } - return fmt.Sprintf("%s%s%s", strings.Repeat(" ", space), text, strings.Repeat(" ", space)) - } - - fmt.Fprintln(os.Stderr, strings.Repeat("+", width)) - fmt.Fprintln(os.Stderr, centerText("⚠ Upgrade to Cadence 1.0", width)) - fmt.Fprintln(os.Stderr, centerText("The Crescendo network upgrade, including Cadence 1.0, is coming soon.", width)) - fmt.Fprintln(os.Stderr, centerText("You may need to update your existing contracts to support this change.", width)) - fmt.Fprintln(os.Stderr, centerText("Please visit our migration guide here:", width)) - fmt.Fprintln(os.Stderr, centerText(url, width)) - fmt.Fprintln(os.Stderr, strings.Repeat("+", width)) - - } - }, } // quick commands diff --git a/internal/command/command.go b/internal/command/command.go index 797ecdf60..871ad541d 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -36,6 +36,7 @@ import ( "github.com/dukex/mixpanel" "github.com/getsentry/sentry-go" + "github.com/google/go-github/github" "github.com/spf13/afero" "github.com/spf13/cobra" @@ -45,6 +46,7 @@ import ( "github.com/onflow/flowkit/v2/output" "github.com/onflow/flow-cli/build" + "github.com/onflow/flow-cli/internal/migrate/validator" "github.com/onflow/flow-cli/internal/settings" "github.com/onflow/flow-cli/internal/util" ) @@ -126,6 +128,11 @@ func (c Command) AddToParent(parent *cobra.Command) { checkVersion(logger) } + // check contract migrations if flag is set + if !Flags.SkipContractMigrationCheck { + checkContractMigrations(state, logger, flow) + } + // record command usage wg := sync.WaitGroup{} go UsageMetrics(c.Cmd, &wg) @@ -424,14 +431,43 @@ func UsageMetrics(command *cobra.Command, wg *sync.WaitGroup) { // GlobalFlags contains all global flags definitions. type GlobalFlags struct { - Filter string - Format string - Save string - Host string - HostNetworkKey string - Log string - Network string - Yes bool - ConfigPaths []string - SkipVersionCheck bool + Filter string + Format string + Save string + Host string + HostNetworkKey string + Log string + Network string + Yes bool + ConfigPaths []string + SkipVersionCheck bool + SkipContractMigrationCheck bool +} + +const migrationDataURL = "https://github.com/onflow/cadence/tree/master/migrations_data" + +func checkContractMigrations(state *flowkit.State, logger output.Logger, flow flowkit.Services) { + contractStatuses, err := validator.NewValidator(github.NewClient(nil).Repositories, flow.Network(), state, logger).GetContractStatuses() + if err != nil { + // if we can't get the contract statuses, we don't check them + return + } + + var failedContracts []validator.ContractUpdateStatus + + for _, contract := range contractStatuses { + if contract.IsFailure() { + failedContracts = append(failedContracts, contract) + } + } + + if len(failedContracts) > 0 { + fmt.Fprintf( + os.Stderr, "\n%s Heads up: We ran a check in the background to verify that your contracts are still valid for the Cadence 1.0 migration. We found %d contract(s) that have failed to migrate. \n", output.ErrorEmoji(), len(failedContracts), + ) + fmt.Fprintf( + os.Stderr, "\n Please visit %s for the latest migration snapshot and information about the failure. \n", migrationDataURL, + ) + } + return } diff --git a/internal/migrate/get_staged_code.go b/internal/migrate/get_staged_code.go index 1083263c7..8050da80a 100644 --- a/internal/migrate/get_staged_code.go +++ b/internal/migrate/get_staged_code.go @@ -32,6 +32,7 @@ import ( "github.com/onflow/flow-cli/internal/command" "github.com/onflow/flow-cli/internal/scripts" + "github.com/onflow/flow-cli/internal/util" ) var getStagedCodeflags struct{} @@ -54,13 +55,13 @@ func getStagedCode( flow flowkit.Services, state *flowkit.State, ) (command.Result, error) { - err := checkNetwork(flow.Network()) + err := util.CheckNetwork(flow.Network()) if err != nil { return nil, err } contractName := args[0] - addr, err := getAddressByContractName(state, contractName, flow.Network()) + addr, err := util.GetAddressByContractName(state, contractName, flow.Network()) if err != nil { return nil, fmt.Errorf("error getting address by contract name: %w", err) } diff --git a/internal/migrate/is_staged.go b/internal/migrate/is_staged.go index e417dcfb6..576e2d1cd 100644 --- a/internal/migrate/is_staged.go +++ b/internal/migrate/is_staged.go @@ -30,6 +30,7 @@ import ( "github.com/onflow/flow-cli/internal/command" "github.com/onflow/flow-cli/internal/scripts" + "github.com/onflow/flow-cli/internal/util" ) var isStagedflags struct{} @@ -52,13 +53,13 @@ func isStaged( flow flowkit.Services, state *flowkit.State, ) (command.Result, error) { - err := checkNetwork(flow.Network()) + err := util.CheckNetwork(flow.Network()) if err != nil { return nil, err } contractName := args[0] - addr, err := getAddressByContractName(state, contractName, flow.Network()) + addr, err := util.GetAddressByContractName(state, contractName, flow.Network()) if err != nil { return nil, fmt.Errorf("error getting address by contract name: %w", err) } diff --git a/internal/migrate/is_validated.go b/internal/migrate/is_validated.go index e0d3d7cac..682edf74f 100644 --- a/internal/migrate/is_validated.go +++ b/internal/migrate/is_validated.go @@ -19,49 +19,25 @@ package migrate import ( - "context" - "encoding/json" - "fmt" - "io" - "path" - "regexp" "strings" "time" "github.com/google/go-github/github" "github.com/logrusorgru/aurora/v4" "github.com/onflow/flowkit/v2" - "github.com/onflow/flowkit/v2/config" "github.com/onflow/flowkit/v2/output" "github.com/spf13/cobra" "github.com/onflow/flow-cli/internal/command" + "github.com/onflow/flow-cli/internal/migrate/validator" "github.com/onflow/flow-cli/internal/util" ) -//go:generate mockery --name gitHubRepositoriesService --inpackage --testonly --case underscore -type gitHubRepositoriesService interface { - GetContents(ctx context.Context, owner string, repo string, path string, opt *github.RepositoryContentGetOptions) (fileContent *github.RepositoryContent, directoryContent []*github.RepositoryContent, resp *github.Response, err error) - DownloadContents(ctx context.Context, owner string, repo string, filepath string, opt *github.RepositoryContentGetOptions) (io.ReadCloser, error) -} - -type validator struct { - repoService gitHubRepositoriesService - state *flowkit.State - logger output.Logger - network config.Network -} - -type contractUpdateStatus struct { - Kind string `json:"kind,omitempty"` - AccountAddress string `json:"account_address"` - ContractName string `json:"contract_name"` - Error string `json:"error,omitempty"` -} +const moreInformationMessage = "For more information, please find the latest full migration report on GitHub (https://github.com/onflow/cadence/tree/master/migrations_data).\n\nNew reports are generated after each weekly emulated migration and your contract's status may change, so please actively monitor this status and stay tuned for the latest announcements until the migration deadline." type validationResult struct { Timestamp time.Time - Status contractUpdateStatus + Status validator.ContractUpdateStatus Network string } @@ -78,16 +54,6 @@ var IsValidatedCommand = &command.Command{ RunS: isValidated, } -const ( - repoOwner = "onflow" - repoName = "cadence" - repoPath = "migrations_data" - repoRef = "master" -) - -const moreInformationMessage = "For more information, please find the latest full migration report on GitHub (https://github.com/onflow/cadence/tree/master/migrations_data).\n\nNew reports are generated after each weekly emulated migration and your contract's status may change, so please actively monitor this status and stay tuned for the latest announcements until the migration deadline." -const contractUpdateFailureKind = "contract-update-failure" - func isValidated( args []string, _ command.GlobalFlags, @@ -96,205 +62,20 @@ func isValidated( state *flowkit.State, ) (command.Result, error) { repoService := github.NewClient(nil).Repositories - v := newValidator(repoService, flow.Network(), state, logger) + v := validator.NewValidator(repoService, flow.Network(), state, logger) contractName := args[0] - return v.validate(contractName) -} - -func newValidator(repoService gitHubRepositoriesService, network config.Network, state *flowkit.State, logger output.Logger) *validator { - return &validator{ - repoService: repoService, - state: state, - logger: logger, - network: network, - } -} - -func (v *validator) validate(contractName string) (validationResult, error) { - err := checkNetwork(v.network) - if err != nil { - return validationResult{}, err - } - - v.logger.StartProgress("Checking if contract has been validated") - defer v.logger.StopProgress() - - addr, err := getAddressByContractName(v.state, contractName, v.network) + s, ts, err := v.Validate(contractName) if err != nil { - return validationResult{}, err - } - - status, timestamp, err := v.getContractValidationStatus( - v.network, - addr.HexWithPrefix(), - contractName, - ) - if err != nil { - // Append more information message to the error - // this way we can ensure that if, for whatever reason, we fail to fetch the report - // the user will still understand that they can find the report on GitHub - return validationResult{}, fmt.Errorf("%w\n\n%s%s", err, moreInformationMessage, "\n") + return nil, err } return validationResult{ - Timestamp: *timestamp, - Status: status, - Network: v.network.Name, + Status: s, + Timestamp: *ts, + Network: flow.Network().Name, }, nil -} - -func (v *validator) getContractValidationStatus(network config.Network, address string, contractName string) (contractUpdateStatus, *time.Time, error) { - // Get last migration report - report, timestamp, err := v.getLatestMigrationReport(network) - if err != nil { - return contractUpdateStatus{}, nil, err - } - - // Get all the contract statuses from the report - statuses, err := v.fetchAndParseReport(report.GetPath()) - if err != nil { - return contractUpdateStatus{}, nil, err - } - - // Get the validation result related to the contract - var status *contractUpdateStatus - for _, s := range statuses { - if s.ContractName == contractName && s.AccountAddress == address { - status = &s - break - } - } - - // Throw error if contract was not part of the last migration - if status == nil { - builder := strings.Builder{} - builder.WriteString("the contract does not appear to have been a part of any emulated migrations yet, please ensure that it has been staged & wait for the next emulated migration (last migration report was at ") - builder.WriteString(timestamp.Format(time.RFC3339)) - builder.WriteString(")\n\n") - - builder.WriteString(" - Account: ") - builder.WriteString(address) - builder.WriteString("\n - Contract: ") - builder.WriteString(contractName) - builder.WriteString("\n - Network: ") - builder.WriteString(network.Name) - - return contractUpdateStatus{}, nil, fmt.Errorf(builder.String()) - } - - return *status, timestamp, nil -} - -func (v *validator) getLatestMigrationReport(network config.Network) (*github.RepositoryContent, *time.Time, error) { - // Get the content of the migration reports folder - _, folderContent, _, err := v.repoService.GetContents( - context.Background(), - repoOwner, - repoName, - repoPath, - &github.RepositoryContentGetOptions{ - Ref: repoRef, - }, - ) - if err != nil { - return nil, nil, err - } - - // Find the latest report file - var latestReport *github.RepositoryContent - var latestReportTime *time.Time - for _, content := range folderContent { - if content.Type != nil && *content.Type == "file" { - contentPath := content.GetPath() - - // Try to extract the time from the filename - networkStr, t, err := extractInfoFromFilename(contentPath) - if err != nil { - // Ignore files that don't match the expected format - // Or have any another error while parsing - continue - } - - // Ignore reports from other networks - if networkStr != strings.ToLower(network.Name) { - continue - } - - // Check if this is the latest report - if latestReportTime == nil || t.After(*latestReportTime) { - latestReport = content - latestReportTime = t - } - } - } - - if latestReport == nil { - return nil, nil, fmt.Errorf("no emulated migration reports found for network `%s` within the remote repository - have any migrations been run yet for this network?", network.Name) - } - - return latestReport, latestReportTime, nil -} - -func (v *validator) fetchAndParseReport(reportPath string) ([]contractUpdateStatus, error) { - // Get the content of the latest report - rc, err := v.repoService.DownloadContents( - context.Background(), - repoOwner, - repoName, - reportPath, - &github.RepositoryContentGetOptions{ - Ref: repoRef, - }, - ) - if err != nil { - return nil, err - } - defer rc.Close() - - // Read the report content - reportContent, err := io.ReadAll(rc) - if err != nil { - return nil, err - } - - // Parse the report - var statuses []contractUpdateStatus - err = json.Unmarshal(reportContent, &statuses) - if err != nil { - return nil, err - } - - return statuses, nil -} - -func extractInfoFromFilename(filename string) (string, *time.Time, error) { - // Extracts the timestamp from the filename in the format: migrations_data/raw/XXXXXX-MM-DD-YYYY--XXXXXX.json - fileName := path.Base(filename) - - expr := regexp.MustCompile(`^staged-contracts-report.*(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)-([a-z]+).json$`) - regexpMatches := expr.FindStringSubmatch(fileName) - if regexpMatches == nil { - return "", nil, fmt.Errorf("filename does not match the expected format") - } - - // Extract the timestamp - timestampStr := regexpMatches[1] - timestamp, err := time.Parse("2006-01-02T15-04-05Z", timestampStr) - if err != nil { - return "", nil, fmt.Errorf("failed to parse timestamp from filename") - } - - // Extract the network - network := regexpMatches[2] - - return network, ×tamp, nil -} -func (s contractUpdateStatus) IsFailure() bool { - // Just in case there are failures without an error message in the future - // we will also check the kind of the status - return s.Error != "" || s.Kind == contractUpdateFailureKind } func (v validationResult) String() string { diff --git a/internal/migrate/is_validated_test.go b/internal/migrate/is_validated_test.go index 33c5ab7e8..f88de7ae3 100644 --- a/internal/migrate/is_validated_test.go +++ b/internal/migrate/is_validated_test.go @@ -32,6 +32,8 @@ import ( "github.com/stretchr/testify/require" "github.com/onflow/flow-cli/internal/command" + "github.com/onflow/flow-cli/internal/migrate/validator" + "github.com/onflow/flow-cli/internal/migrate/validator/mocks" "github.com/onflow/flow-cli/internal/util" ) @@ -45,8 +47,8 @@ func Test_IsValidated(t *testing.T) { // Helper function to test the isValidated function // with all of the necessary mocks - testIsValidatedWithStatuses := func(statuses []contractUpdateStatus) (command.Result, error) { - mockClient := newMockGitHubRepositoriesService(t) + testIsValidatedWithStatuses := func(statuses []validator.ContractUpdateStatus) (command.Result, error) { + mockClient := mocks.NewGitHubRepositoriesService(t) // mock github file download data, _ := json.Marshal(statuses) @@ -99,18 +101,26 @@ func Test_IsValidated(t *testing.T) { ) // call the isValidated function - validator := newValidator(mockClient, config.TestnetNetwork, state, util.NoLogger) + validator := validator.NewValidator(mockClient, config.TestnetNetwork, state, util.NoLogger) - res, err := validator.validate( + res, ts, err := validator.Validate( testContract.Name, ) + if err != nil { + return validationResult{}, err + } + require.Equal(t, true, mockClient.AssertExpectations(t)) - return res, err + return validationResult{ + Status: res, + Timestamp: *ts, + Network: config.TestnetNetwork.Name, + }, nil } t.Run("isValidated gets status from latest report on github", func(t *testing.T) { - res, err := testIsValidatedWithStatuses([]contractUpdateStatus{ + res, err := testIsValidatedWithStatuses([]validator.ContractUpdateStatus{ { AccountAddress: "0x01", ContractName: "some-other-contract", @@ -130,7 +140,7 @@ func Test_IsValidated(t *testing.T) { require.NoError(t, err) require.Equal(t, res.JSON(), validationResult{ Timestamp: expectedTime, - Status: contractUpdateStatus{ + Status: validator.ContractUpdateStatus{ AccountAddress: emuAccount.Address.HexWithPrefix(), ContractName: testContract.Name, Error: "1234", @@ -140,7 +150,7 @@ func Test_IsValidated(t *testing.T) { }) t.Run("isValidated errors if contract was not in last migration", func(t *testing.T) { - _, err := testIsValidatedWithStatuses([]contractUpdateStatus{ + _, err := testIsValidatedWithStatuses([]validator.ContractUpdateStatus{ { AccountAddress: "0x01", ContractName: "some-other-contract", @@ -148,6 +158,6 @@ func Test_IsValidated(t *testing.T) { }, }) - require.ErrorContains(t, err, "does not appear to have been a part of any emulated migrations yet") + require.ErrorContains(t, err, "do not appear to have been a part of any emulated migrations yet") }) } diff --git a/internal/migrate/list_staged_contracts.go b/internal/migrate/list_staged_contracts.go index 2362b7266..9e0ac22fa 100644 --- a/internal/migrate/list_staged_contracts.go +++ b/internal/migrate/list_staged_contracts.go @@ -30,6 +30,7 @@ import ( "github.com/onflow/flow-cli/internal/command" "github.com/onflow/flow-cli/internal/scripts" + "github.com/onflow/flow-cli/internal/util" ) var listStagedContractsflags struct{} @@ -52,7 +53,7 @@ func listStagedContracts( flow flowkit.Services, state *flowkit.State, ) (command.Result, error) { - err := checkNetwork(flow.Network()) + err := util.CheckNetwork(flow.Network()) if err != nil { return nil, err } diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go index 3f0348759..58074ef3a 100644 --- a/internal/migrate/migrate.go +++ b/internal/migrate/migrate.go @@ -24,8 +24,6 @@ import ( "github.com/onflow/cadence" "github.com/onflow/flow-go-sdk" "github.com/onflow/flowkit/v2" - "github.com/onflow/flowkit/v2/accounts" - "github.com/onflow/flowkit/v2/config" "github.com/onflow/flowkit/v2/project" "github.com/spf13/cobra" ) @@ -90,53 +88,3 @@ func replaceImportsIfExists(state *flowkit.State, flow flowkit.Services, locatio return program.Code(), nil } - -func getAccountByContractName(state *flowkit.State, contractName string, network config.Network) (*accounts.Account, error) { - deployments := state.Deployments().ByNetwork(network.Name) - var accountName string - for _, d := range deployments { - for _, c := range d.Contracts { - if c.Name == contractName { - accountName = d.Account - break - } - } - } - if accountName == "" { - return nil, fmt.Errorf("contract not found in state") - } - - accs := state.Accounts() - if accs == nil { - return nil, fmt.Errorf("no accounts found in state") - } - - var account *accounts.Account - for _, a := range *accs { - if accountName == a.Name { - account = &a - break - } - } - if account == nil { - return nil, fmt.Errorf("account %s not found in state", accountName) - } - - return account, nil -} - -func getAddressByContractName(state *flowkit.State, contractName string, network config.Network) (flow.Address, error) { - account, err := getAccountByContractName(state, contractName, network) - if err != nil { - return flow.Address{}, err - } - - return flow.HexToAddress(account.Address.Hex()), nil -} - -func checkNetwork(network config.Network) error { - if network.Name != config.TestnetNetwork.Name && network.Name != config.MainnetNetwork.Name { - return fmt.Errorf("staging contracts is only supported on testnet & mainnet networks, see https://cadence-lang.org/docs/cadence-migration-guide for more information") - } - return nil -} diff --git a/internal/migrate/stage.go b/internal/migrate/stage.go index 9bbcd4d4b..1c455a692 100644 --- a/internal/migrate/stage.go +++ b/internal/migrate/stage.go @@ -75,7 +75,7 @@ func stageProject( flow flowkit.Services, state *flowkit.State, ) (command.Result, error) { - err := checkNetwork(flow.Network()) + err := util.CheckNetwork(flow.Network()) if err != nil { return nil, err } diff --git a/internal/migrate/state.go b/internal/migrate/state.go index a2c14ae98..f69002ec9 100644 --- a/internal/migrate/state.go +++ b/internal/migrate/state.go @@ -38,6 +38,7 @@ import ( "github.com/spf13/cobra" "github.com/onflow/flow-cli/internal/command" + "github.com/onflow/flow-cli/internal/util" ) var stateFlags struct { @@ -143,7 +144,7 @@ func resolveStagedContracts(state *flowkit.State, contractNames []string) ([]mig // If contract is not aliased, try to get address by deployment account if address == flow.EmptyAddress { - address, err = getAddressByContractName(state, contractName, network) + address, err = util.GetAddressByContractName(state, contractName, network) if err != nil { return nil, fmt.Errorf("failed to get address by contract name: %w", err) } diff --git a/internal/migrate/unstage_contract.go b/internal/migrate/unstage_contract.go index 1595de64c..a17e85a6b 100644 --- a/internal/migrate/unstage_contract.go +++ b/internal/migrate/unstage_contract.go @@ -32,6 +32,7 @@ import ( "github.com/onflow/flow-cli/internal/command" internaltx "github.com/onflow/flow-cli/internal/transactions" + "github.com/onflow/flow-cli/internal/util" ) var unstageContractflags struct{} @@ -54,13 +55,13 @@ func unstageContract( flow flowkit.Services, state *flowkit.State, ) (command.Result, error) { - err := checkNetwork(flow.Network()) + err := util.CheckNetwork(flow.Network()) if err != nil { return nil, err } contractName := args[0] - account, err := getAccountByContractName(state, contractName, flow.Network()) + account, err := util.GetAccountByContractName(state, contractName, flow.Network()) if err != nil { return nil, fmt.Errorf("failed to get account by contract name: %w", err) } diff --git a/internal/migrate/mock_git_hub_repositories_service_test.go b/internal/migrate/validator/mocks/git_hub_repositories_service.go similarity index 74% rename from internal/migrate/mock_git_hub_repositories_service_test.go rename to internal/migrate/validator/mocks/git_hub_repositories_service.go index 6c5d0cb5e..e586ee016 100644 --- a/internal/migrate/mock_git_hub_repositories_service_test.go +++ b/internal/migrate/validator/mocks/git_hub_repositories_service.go @@ -1,6 +1,6 @@ -// Code generated by mockery v2.40.3. DO NOT EDIT. +// Code generated by mockery v2.38.0. DO NOT EDIT. -package migrate +package mocks import ( context "context" @@ -11,13 +11,13 @@ import ( mock "github.com/stretchr/testify/mock" ) -// mockGitHubRepositoriesService is an autogenerated mock type for the gitHubRepositoriesService type -type mockGitHubRepositoriesService struct { +// GitHubRepositoriesService is an autogenerated mock type for the GitHubRepositoriesService type +type GitHubRepositoriesService struct { mock.Mock } // DownloadContents provides a mock function with given fields: ctx, owner, repo, filepath, opt -func (_m *mockGitHubRepositoriesService) DownloadContents(ctx context.Context, owner string, repo string, filepath string, opt *github.RepositoryContentGetOptions) (io.ReadCloser, error) { +func (_m *GitHubRepositoriesService) DownloadContents(ctx context.Context, owner string, repo string, filepath string, opt *github.RepositoryContentGetOptions) (io.ReadCloser, error) { ret := _m.Called(ctx, owner, repo, filepath, opt) if len(ret) == 0 { @@ -47,7 +47,7 @@ func (_m *mockGitHubRepositoriesService) DownloadContents(ctx context.Context, o } // GetContents provides a mock function with given fields: ctx, owner, repo, path, opt -func (_m *mockGitHubRepositoriesService) GetContents(ctx context.Context, owner string, repo string, path string, opt *github.RepositoryContentGetOptions) (*github.RepositoryContent, []*github.RepositoryContent, *github.Response, error) { +func (_m *GitHubRepositoriesService) GetContents(ctx context.Context, owner string, repo string, path string, opt *github.RepositoryContentGetOptions) (*github.RepositoryContent, []*github.RepositoryContent, *github.Response, error) { ret := _m.Called(ctx, owner, repo, path, opt) if len(ret) == 0 { @@ -94,13 +94,13 @@ func (_m *mockGitHubRepositoriesService) GetContents(ctx context.Context, owner return r0, r1, r2, r3 } -// newMockGitHubRepositoriesService creates a new instance of mockGitHubRepositoriesService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// NewGitHubRepositoriesService creates a new instance of GitHubRepositoriesService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. -func newMockGitHubRepositoriesService(t interface { +func NewGitHubRepositoriesService(t interface { mock.TestingT Cleanup(func()) -}) *mockGitHubRepositoriesService { - mock := &mockGitHubRepositoriesService{} +}) *GitHubRepositoriesService { + mock := &GitHubRepositoriesService{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) diff --git a/internal/migrate/validator/validator.go b/internal/migrate/validator/validator.go new file mode 100644 index 000000000..c84f2ccca --- /dev/null +++ b/internal/migrate/validator/validator.go @@ -0,0 +1,325 @@ +/* + * Flow CLI + * + * Copyright 2019 Dapper Labs, 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 validator + +import ( + "context" + "encoding/json" + "fmt" + "io" + "path" + "regexp" + "strings" + "time" + + "golang.org/x/exp/slices" + + "github.com/onflow/flow-cli/internal/util" + + "github.com/google/go-github/github" + "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/config" + "github.com/onflow/flowkit/v2/output" +) + +//go:generate mockery --name GitHubRepositoriesService --output ./mocks --case underscore +type GitHubRepositoriesService interface { + GetContents(ctx context.Context, owner string, repo string, path string, opt *github.RepositoryContentGetOptions) (fileContent *github.RepositoryContent, directoryContent []*github.RepositoryContent, resp *github.Response, err error) + DownloadContents(ctx context.Context, owner string, repo string, filepath string, opt *github.RepositoryContentGetOptions) (io.ReadCloser, error) +} + +const contractUpdateFailureKind = "contract-update-failure" + +const ( + repoOwner = "onflow" + repoName = "cadence" + repoPath = "migrations_data" + repoRef = "master" +) + +type missingContractError struct { + MissingContracts []struct { + ContractName string + Address string + Network string + } + LastMigrationTime *time.Time +} + +func (m missingContractError) Error() string { + builder := strings.Builder{} + builder.WriteString("some contracts do not appear to have been a part of any emulated migrations yet, please ensure that it has been staged & wait for the next emulated migration (last migration report was at ") + builder.WriteString(m.LastMigrationTime.Format(time.RFC3339)) + builder.WriteString(")\n\n") + + for _, contract := range m.MissingContracts { + builder.WriteString(" - Account: ") + builder.WriteString(contract.Address) + builder.WriteString("\n - Contract: ") + builder.WriteString(contract.ContractName) + builder.WriteString("\n - Network: ") + builder.WriteString(contract.Network) + builder.WriteString("\n\n") + } + + return builder.String() +} + +type validator struct { + repoService GitHubRepositoriesService + state *flowkit.State + logger output.Logger + network config.Network +} + +type ContractUpdateStatus struct { + Kind string `json:"kind,omitempty"` + AccountAddress string `json:"account_address"` + ContractName string `json:"contract_name"` + Error string `json:"error,omitempty"` +} + +func (s ContractUpdateStatus) IsFailure() bool { + // Just in case there are failures without an error message in the future + // we will also check the kind of the status + return s.Error != "" || s.Kind == contractUpdateFailureKind +} + +func NewValidator(repoService GitHubRepositoriesService, network config.Network, state *flowkit.State, logger output.Logger) *validator { + return &validator{ + repoService: repoService, + state: state, + logger: logger, + network: network, + } +} + +func (v *validator) GetContractStatuses() ([]ContractUpdateStatus, error) { + if v.state == nil || v.state.Contracts() == nil { + return nil, nil + } + + var contractNames []string + for _, c := range *v.state.Contracts() { + contractNames = append(contractNames, c.Name) + } + + statuses, _, err := v.getContractUpdateStatuses(contractNames...) + if err != nil { + return nil, err + } + + return statuses, err +} + +func (v *validator) getContractUpdateStatuses(contractNames ...string) ([]ContractUpdateStatus, *time.Time, error) { + var contractUpdateStatuses []ContractUpdateStatus + err := util.CheckNetwork(v.network) + if err != nil { + return nil, nil, err + } + + v.logger.StartProgress("Checking if contracts has been validated...") + defer v.logger.StopProgress() + + addressToContractName := make(map[string]string) + for _, contractName := range contractNames { + addr, err := util.GetAddressByContractName(v.state, contractName, v.network) + if err != nil { + return nil, nil, err + } + addressToContractName[addr.HexWithPrefix()] = contractName + } + + // Get last migration report + report, ts, err := v.getLatestMigrationReport(v.network) + if err != nil { + return nil, nil, err + } + + // Get all the contract statuses from the report + statuses, err := v.fetchAndParseReport(report.GetPath()) + if err != nil { + return nil, nil, err + } + + // Get the validation result related to the contract + var foundAddresses []string + for _, s := range statuses { + if addressToContractName[s.AccountAddress] == s.ContractName { + contractUpdateStatuses = append(contractUpdateStatuses, s) + foundAddresses = append(foundAddresses, s.AccountAddress) + } + } + + for addr, contractName := range addressToContractName { + var missingContractErr missingContractError + if !slices.Contains(foundAddresses, addr) { + missingContractErr.MissingContracts = append(missingContractErr.MissingContracts, struct { + ContractName string + Address string + Network string + }{ + ContractName: contractName, + Address: addr, + Network: v.network.Name, + }) + missingContractErr.LastMigrationTime = ts + } + + if len(missingContractErr.MissingContracts) > 0 { + return nil, nil, missingContractErr + } + } + + return contractUpdateStatuses, ts, nil +} + +func (v *validator) getContractValidationStatus(contractName string) (ContractUpdateStatus, *time.Time, error) { + status, ts, err := v.getContractUpdateStatuses(contractName) + if err != nil { + return ContractUpdateStatus{}, nil, err + } + + if len(status) != 1 { + return ContractUpdateStatus{}, nil, fmt.Errorf("failed to find contract in last migration report") + } + + return status[0], ts, nil + +} + +func (v *validator) Validate(contractName string) (ContractUpdateStatus, *time.Time, error) { + err := util.CheckNetwork(v.network) + if err != nil { + return ContractUpdateStatus{}, nil, err + } + + v.logger.StartProgress("Checking if contract has been validated") + defer v.logger.StopProgress() + + return v.getContractValidationStatus( + contractName, + ) +} + +func (v *validator) getLatestMigrationReport(network config.Network) (*github.RepositoryContent, *time.Time, error) { + // Get the content of the migration reports folder + _, folderContent, _, err := v.repoService.GetContents( + context.Background(), + repoOwner, + repoName, + repoPath, + &github.RepositoryContentGetOptions{ + Ref: repoRef, + }, + ) + if err != nil { + return nil, nil, err + } + + // Find the latest report file + var latestReport *github.RepositoryContent + var latestReportTime *time.Time + for _, content := range folderContent { + if content.Type != nil && *content.Type == "file" { + contentPath := content.GetPath() + + // Try to extract the time from the filename + networkStr, t, err := extractInfoFromFilename(contentPath) + if err != nil { + // Ignore files that don't match the expected format + // Or have any another error while parsing + continue + } + + // Ignore reports from other networks + if networkStr != strings.ToLower(network.Name) { + continue + } + + // Check if this is the latest report + if latestReportTime == nil || t.After(*latestReportTime) { + latestReport = content + latestReportTime = t + } + } + } + + if latestReport == nil { + return nil, nil, fmt.Errorf("no emulated migration reports found for network `%s` within the remote repository - have any migrations been run yet for this network?", network.Name) + } + + return latestReport, latestReportTime, nil +} + +func (v *validator) fetchAndParseReport(reportPath string) ([]ContractUpdateStatus, error) { + // Get the content of the latest report + rc, err := v.repoService.DownloadContents( + context.Background(), + repoOwner, + repoName, + reportPath, + &github.RepositoryContentGetOptions{ + Ref: repoRef, + }, + ) + if err != nil { + return nil, err + } + defer rc.Close() + + // Read the report content + reportContent, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + + // Parse the report + var statuses []ContractUpdateStatus + err = json.Unmarshal(reportContent, &statuses) + if err != nil { + return nil, err + } + + return statuses, nil +} + +func extractInfoFromFilename(filename string) (string, *time.Time, error) { + // Extracts the timestamp from the filename in the format: migrations_data/raw/XXXXXX-MM-DD-YYYY--XXXXXX.json + fileName := path.Base(filename) + + expr := regexp.MustCompile(`^staged-contracts-report.*(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)-([a-z]+).json$`) + regexpMatches := expr.FindStringSubmatch(fileName) + if regexpMatches == nil { + return "", nil, fmt.Errorf("filename does not match the expected format") + } + + // Extract the timestamp + timestampStr := regexpMatches[1] + timestamp, err := time.Parse("2006-01-02T15-04-05Z", timestampStr) + if err != nil { + return "", nil, fmt.Errorf("failed to parse timestamp from filename") + } + + // Extract the network + network := regexpMatches[2] + + return network, ×tamp, nil +} diff --git a/internal/util/util.go b/internal/util/util.go index 2ace0b12e..8bcee1a55 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -27,10 +27,13 @@ import ( "strings" "text/tabwriter" + "github.com/onflow/flow-go-sdk" flowsdk "github.com/onflow/flow-go-sdk" "github.com/onflow/flow-go-sdk/crypto" "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/accounts" + "github.com/onflow/flowkit/v2/config" ) const EnvPrefix = "FLOW" @@ -113,6 +116,56 @@ func removeFromStringArray(s []string, el string) []string { return s } +func GetAccountByContractName(state *flowkit.State, contractName string, network config.Network) (*accounts.Account, error) { + deployments := state.Deployments().ByNetwork(network.Name) + var accountName string + for _, d := range deployments { + for _, c := range d.Contracts { + if c.Name == contractName { + accountName = d.Account + break + } + } + } + if accountName == "" { + return nil, fmt.Errorf("contract not found in state") + } + + accs := state.Accounts() + if accs == nil { + return nil, fmt.Errorf("no accounts found in state") + } + + var account *accounts.Account + for _, a := range *accs { + if accountName == a.Name { + account = &a + break + } + } + if account == nil { + return nil, fmt.Errorf("account %s not found in state", accountName) + } + + return account, nil +} + +func GetAddressByContractName(state *flowkit.State, contractName string, network config.Network) (flow.Address, error) { + account, err := GetAccountByContractName(state, contractName, network) + if err != nil { + return flow.Address{}, err + } + + return flow.HexToAddress(account.Address.Hex()), nil +} + +func CheckNetwork(network config.Network) error { + if network.Name != config.TestnetNetwork.Name && network.Name != config.MainnetNetwork.Name { + return fmt.Errorf("staging contracts is only supported on testnet & mainnet networks, see https://cadence-lang.org/docs/cadence-migration-guide for more information") + } + return nil +} + func NormalizeLineEndings(s string) string { return strings.ReplaceAll(s, "\r\n", "\n") }