Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use GitHub reports for is-validated command #1529

Merged
merged 23 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/dukex/mixpanel v1.0.1
github.com/getsentry/sentry-go v0.27.0
github.com/go-git/go-git/v5 v5.11.0
github.com/google/go-github v17.0.0+incompatible
github.com/gosuri/uilive v0.0.4
github.com/logrusorgru/aurora/v4 v4.0.0
github.com/manifoldco/promptui v0.9.0
Expand Down Expand Up @@ -112,7 +113,6 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect
github.com/google/go-dap v0.11.0 // indirect
github.com/google/go-github v17.0.0+incompatible // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand Down
4 changes: 3 additions & 1 deletion internal/dependencymanager/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package dependencymanager

import (
"fmt"

"github.com/onflow/flow-cli/internal/util"

"github.com/spf13/cobra"
Expand All @@ -46,7 +47,8 @@ var addCommand = &command.Command{
Example: "flow dependencies add testnet://0afe396ebc8eee65.FlowToken",
Args: cobra.ExactArgs(1),
},
RunS: add,
RunS: add,
Flags: &struct{}{},
}

func init() {
Expand Down
314 changes: 294 additions & 20 deletions internal/migrate/is_validated.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,51 @@ package migrate

import (
"context"
"encoding/json"
"fmt"
"io"
"path"
"regexp"
"strings"
"time"

"github.com/onflow/cadence"
"github.com/onflow/contract-updater/lib/go/templates"
"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/scripts"
"github.com/onflow/flow-cli/internal/util"
)

//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)
}

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"`
}

type validationResult struct {
Timestamp time.Time
Status contractUpdateStatus
Network string
}

var isValidatedflags struct{}

var IsValidatedCommand = &command.Command{
Expand All @@ -45,42 +78,283 @@ 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,
globalFlags command.GlobalFlags,
_ output.Logger,
_ command.GlobalFlags,
logger output.Logger,
flow flowkit.Services,
state *flowkit.State,
) (command.Result, error) {
err := checkNetwork(flow.Network())
repoService := github.NewClient(nil).Repositories
v := 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 nil, err
return validationResult{}, err
}

contractName := args[0]
addr, err := getAddressByContractName(state, contractName, flow.Network())
v.logger.StartProgress("Checking if contract has been validated")
defer v.logger.StopProgress()

addr, err := getAddressByContractName(v.state, contractName, v.network)
if err != nil {
return validationResult{}, err
}

status, timestamp, err := v.getContractValidationStatus(
v.network,
addr.HexWithPrefix(),
contractName,
)
if err != nil {
return nil, fmt.Errorf("error getting address by contract name: %w", err)
// 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")
}

caddr := cadence.NewAddress(addr)
return validationResult{
Timestamp: *timestamp,
Status: status,
Network: v.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
}

cname, err := cadence.NewString(contractName)
// Get all the contract statuses from the report
statuses, err := v.fetchAndParseReport(report.GetPath())
if err != nil {
return nil, fmt.Errorf("failed to get cadence string from contract name: %w", err)
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)
}

value, err := flow.ExecuteScript(
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(),
flowkit.Script{
Code: templates.GenerateIsValidatedScript(MigrationContractStagingAddress(flow.Network().Name)),
Args: []cadence.Value{caddr, cname},
repoOwner,
repoName,
reportPath,
&github.RepositoryContentGetOptions{
Ref: repoRef,
},
flowkit.LatestScriptQuery,
)
if err != nil {
return nil, fmt.Errorf("error executing script: %w", err)
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-<network>-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")
}

return scripts.NewScriptResult(value), nil
// Extract the network
network := regexpMatches[2]

return network, &timestamp, 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 {
status := v.Status

builder := strings.Builder{}
builder.WriteString("Last emulated migration report was created at ")
builder.WriteString(v.Timestamp.Format(time.RFC3339))
builder.WriteString("\n\n")

statusBuilder := strings.Builder{}
emoji := "✅ "
statusColor := aurora.Green
if status.IsFailure() {
emoji = "❌ "
statusColor = aurora.Red
}

statusBuilder.WriteString(util.PrintEmoji(emoji))
statusBuilder.WriteString("The contract has ")

if status.IsFailure() {
statusBuilder.WriteString("FAILED")
} else {
statusBuilder.WriteString("PASSED")
}
statusBuilder.WriteString(" the last emulated migration")

statusBuilder.WriteString("\n\n - Account: ")
statusBuilder.WriteString(status.AccountAddress)
statusBuilder.WriteString("\n - Contract: ")
statusBuilder.WriteString(status.ContractName)
statusBuilder.WriteString("\n - Network: ")
statusBuilder.WriteString(v.Network)
statusBuilder.WriteString("\n\n")

// Write colored status
builder.WriteString(statusColor(statusBuilder.String()).String())

if status.Error != "" {
builder.WriteString(status.Error)
builder.WriteString("\n")
}

if status.IsFailure() {
builder.WriteString(aurora.Red(">> Please review the error and re-stage the contract to resolve these issues if necessary").String())
builder.WriteString("\n\n")
}

builder.WriteString(moreInformationMessage)
return builder.String()
}

func (v validationResult) JSON() interface{} {
return v
}

func (v validationResult) Oneliner() string {
if v.Status.IsFailure() {
return util.MessageWithEmojiPrefix("❌", "FAILED")
}
return util.MessageWithEmojiPrefix("✅", "FAIlED")
}
Loading
Loading