github.com/badrootd/celestia-core@v0.0.0-20240305091328-aa4207a4b25d/test/e2e/generator/generate.go (about)

     1  package main
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"math/rand"
     7  	"sort"
     8  	"strconv"
     9  	"strings"
    10  
    11  	"github.com/Masterminds/semver/v3"
    12  	"github.com/badrootd/celestia-core/libs/math"
    13  	e2e "github.com/badrootd/celestia-core/test/e2e/pkg"
    14  	"github.com/badrootd/celestia-core/version"
    15  	"github.com/go-git/go-git/v5"
    16  	"github.com/go-git/go-git/v5/plumbing/object"
    17  )
    18  
    19  var (
    20  	// testnetCombinations defines global testnet options, where we generate a
    21  	// separate testnet for each combination (Cartesian product) of options.
    22  	testnetCombinations = map[string][]interface{}{
    23  		"topology":      {"single", "quad", "large_connected", "large_partially_connected"},
    24  		"initialHeight": {0, 1000},
    25  		"initialState": {
    26  			map[string]string{},
    27  			map[string]string{"initial01": "a", "initial02": "b", "initial03": "c"},
    28  		},
    29  		"validators": {"genesis", "initchain"},
    30  	}
    31  	nodeVersions = weightedChoice{
    32  		"": 2,
    33  	}
    34  
    35  	// The following specify randomly chosen values for testnet nodes.
    36  	nodeDatabases = uniformChoice{"goleveldb", "cleveldb", "rocksdb", "boltdb", "badgerdb"}
    37  	ipv6          = uniformChoice{false, true}
    38  	// FIXME: grpc disabled due to https://github.com/cometbft/cometbft/issues/5439
    39  	nodeABCIProtocols    = uniformChoice{"unix", "tcp", "builtin"} // "grpc"
    40  	nodePrivvalProtocols = uniformChoice{"file", "unix", "tcp"}
    41  	// FIXME: v2 disabled due to flake
    42  	nodeFastSyncs         = uniformChoice{"v0"} // "v2"
    43  	nodeStateSyncs        = uniformChoice{false, true}
    44  	nodeMempools          = uniformChoice{"v0", "v1", "v2"}
    45  	nodePersistIntervals  = uniformChoice{0, 1, 5}
    46  	nodeSnapshotIntervals = uniformChoice{0, 3}
    47  	nodeRetainBlocks      = uniformChoice{0, 1, 5}
    48  	nodePerturbations     = probSetChoice{
    49  		"disconnect": 0.1,
    50  		"pause":      0.1,
    51  		"kill":       0.1,
    52  		"restart":    0.1,
    53  		"upgrade":    0.3,
    54  	}
    55  	nodeMisbehaviors = weightedChoice{
    56  		// FIXME: evidence disabled due to node panicking when not
    57  		// having sufficient block history to process evidence.
    58  		// https://github.com/cometbft/cometbft/issues/5617
    59  		// misbehaviorOption{"double-prevote"}: 1,
    60  		misbehaviorOption{}: 9,
    61  	}
    62  	lightNodePerturbations = probSetChoice{
    63  		"upgrade": 0.3,
    64  	}
    65  )
    66  
    67  type generateConfig struct {
    68  	randSource   *rand.Rand
    69  	outputDir    string
    70  	multiVersion string
    71  	prometheus   bool
    72  }
    73  
    74  // Generate generates random testnets using the given RNG.
    75  func Generate(cfg *generateConfig) ([]e2e.Manifest, error) {
    76  	upgradeVersion := ""
    77  
    78  	if cfg.multiVersion != "" {
    79  		var err error
    80  		nodeVersions, upgradeVersion, err = parseWeightedVersions(cfg.multiVersion)
    81  		if err != nil {
    82  			return nil, err
    83  		}
    84  		if _, ok := nodeVersions["local"]; ok {
    85  			nodeVersions[""] = nodeVersions["local"]
    86  			delete(nodeVersions, "local")
    87  			if upgradeVersion == "local" {
    88  				upgradeVersion = ""
    89  			}
    90  		}
    91  		if _, ok := nodeVersions["latest"]; ok {
    92  			latestVersion, err := gitRepoLatestReleaseVersion(cfg.outputDir)
    93  			if err != nil {
    94  				return nil, err
    95  			}
    96  			nodeVersions[latestVersion] = nodeVersions["latest"]
    97  			delete(nodeVersions, "latest")
    98  			if upgradeVersion == "latest" {
    99  				upgradeVersion = latestVersion
   100  			}
   101  		}
   102  	}
   103  	fmt.Println("Generating testnet with weighted versions:")
   104  	for ver, wt := range nodeVersions {
   105  		if ver == "" {
   106  			fmt.Printf("- local: %d\n", wt)
   107  		} else {
   108  			fmt.Printf("- %s: %d\n", ver, wt)
   109  		}
   110  	}
   111  	manifests := []e2e.Manifest{}
   112  	for _, opt := range combinations(testnetCombinations) {
   113  		manifest, err := generateTestnet(cfg.randSource, opt, upgradeVersion, cfg.prometheus)
   114  		if err != nil {
   115  			return nil, err
   116  		}
   117  		manifests = append(manifests, manifest)
   118  	}
   119  	return manifests, nil
   120  }
   121  
   122  // generateTestnet generates a single testnet with the given options.
   123  func generateTestnet(r *rand.Rand, opt map[string]interface{}, upgradeVersion string, prometheus bool) (e2e.Manifest, error) {
   124  	manifest := e2e.Manifest{
   125  		IPv6:             ipv6.Choose(r).(bool),
   126  		ABCIProtocol:     nodeABCIProtocols.Choose(r).(string),
   127  		InitialHeight:    int64(opt["initialHeight"].(int)),
   128  		InitialState:     opt["initialState"].(map[string]string),
   129  		Validators:       &map[string]int64{},
   130  		ValidatorUpdates: map[string]map[string]int64{},
   131  		Nodes:            map[string]*e2e.ManifestNode{},
   132  		UpgradeVersion:   upgradeVersion,
   133  		Prometheus:       prometheus,
   134  	}
   135  
   136  	var numSeeds, numValidators, numFulls, numLightClients int
   137  	switch opt["topology"].(string) {
   138  	case "single":
   139  		numValidators = 1
   140  	case "quad":
   141  		numValidators = 4
   142  	case "large_connected", "large-partially_connected":
   143  		// FIXME Networks are kept small since large ones use too much CPU.
   144  		numSeeds = r.Intn(2)
   145  		numLightClients = r.Intn(3)
   146  		numValidators = 4 + r.Intn(4)
   147  		numFulls = r.Intn(4)
   148  	default:
   149  		return manifest, fmt.Errorf("unknown topology %q", opt["topology"])
   150  	}
   151  
   152  	if opt["typologoy"].(string) == "large_partially_connected" {
   153  		// currently this is at max 11 and minimum 4
   154  		totalPossibleConnections := numSeeds + numValidators + numFulls - 1
   155  		// this value should be between 3 and 2
   156  		manifest.MaxOutboundConnections = math.MaxInt(totalPossibleConnections/3, 2)
   157  		// this value should be between 5 and 2
   158  		manifest.MaxInboundConnections = math.MaxInt(totalPossibleConnections/2, 2)
   159  	}
   160  
   161  	// First we generate seed nodes, starting at the initial height.
   162  	for i := 1; i <= numSeeds; i++ {
   163  		manifest.Nodes[fmt.Sprintf("seed%02d", i)] = generateNode(
   164  			r, e2e.ModeSeed, 0, manifest.InitialHeight, false)
   165  	}
   166  
   167  	// Next, we generate validators. We make sure a BFT quorum of validators start
   168  	// at the initial height, and that we have two archive nodes. We also set up
   169  	// the initial validator set, and validator set updates for delayed nodes.
   170  	nextStartAt := manifest.InitialHeight + 5
   171  	quorum := numValidators*2/3 + 1
   172  	for i := 1; i <= numValidators; i++ {
   173  		startAt := int64(0)
   174  		if i > quorum {
   175  			startAt = nextStartAt
   176  			nextStartAt += 5
   177  		}
   178  		name := fmt.Sprintf("validator%02d", i)
   179  		manifest.Nodes[name] = generateNode(
   180  			r, e2e.ModeValidator, startAt, manifest.InitialHeight, i <= 2)
   181  
   182  		if startAt == 0 {
   183  			(*manifest.Validators)[name] = int64(30 + r.Intn(71))
   184  		} else {
   185  			manifest.ValidatorUpdates[fmt.Sprint(startAt+5)] = map[string]int64{
   186  				name: int64(30 + r.Intn(71)),
   187  			}
   188  		}
   189  	}
   190  
   191  	// Move validators to InitChain if specified.
   192  	switch opt["validators"].(string) {
   193  	case "genesis":
   194  	case "initchain":
   195  		manifest.ValidatorUpdates["0"] = *manifest.Validators
   196  		manifest.Validators = &map[string]int64{}
   197  	default:
   198  		return manifest, fmt.Errorf("invalid validators option %q", opt["validators"])
   199  	}
   200  
   201  	// Finally, we generate random full nodes.
   202  	for i := 1; i <= numFulls; i++ {
   203  		startAt := int64(0)
   204  		if r.Float64() >= 0.5 {
   205  			startAt = nextStartAt
   206  			nextStartAt += 5
   207  		}
   208  		manifest.Nodes[fmt.Sprintf("full%02d", i)] = generateNode(
   209  			r, e2e.ModeFull, startAt, manifest.InitialHeight, false)
   210  	}
   211  
   212  	// We now set up peer discovery for nodes. Seed nodes are fully meshed with
   213  	// each other, while non-seed nodes either use a set of random seeds or a
   214  	// set of random peers that start before themselves.
   215  	var seedNames, peerNames, lightProviders []string
   216  	for name, node := range manifest.Nodes {
   217  		if node.Mode == string(e2e.ModeSeed) {
   218  			seedNames = append(seedNames, name)
   219  		} else {
   220  			// if the full node or validator is an ideal candidate, it is added as a light provider.
   221  			// There are at least two archive nodes so there should be at least two ideal candidates
   222  			if (node.StartAt == 0 || node.StartAt == manifest.InitialHeight) && node.RetainBlocks == 0 {
   223  				lightProviders = append(lightProviders, name)
   224  			}
   225  			peerNames = append(peerNames, name)
   226  		}
   227  	}
   228  
   229  	for _, name := range seedNames {
   230  		for _, otherName := range seedNames {
   231  			if name != otherName {
   232  				manifest.Nodes[name].Seeds = append(manifest.Nodes[name].Seeds, otherName)
   233  			}
   234  		}
   235  	}
   236  
   237  	sort.Slice(peerNames, func(i, j int) bool {
   238  		iName, jName := peerNames[i], peerNames[j]
   239  		switch {
   240  		case manifest.Nodes[iName].StartAt < manifest.Nodes[jName].StartAt:
   241  			return true
   242  		case manifest.Nodes[iName].StartAt > manifest.Nodes[jName].StartAt:
   243  			return false
   244  		default:
   245  			return strings.Compare(iName, jName) == -1
   246  		}
   247  	})
   248  	for i, name := range peerNames {
   249  		if len(seedNames) > 0 && (i == 0 || r.Float64() >= 0.5) {
   250  			manifest.Nodes[name].Seeds = uniformSetChoice(seedNames).Choose(r)
   251  		} else if i > 0 {
   252  			manifest.Nodes[name].PersistentPeers = uniformSetChoice(peerNames[:i]).Choose(r)
   253  		}
   254  	}
   255  
   256  	// lastly, set up the light clients
   257  	for i := 1; i <= numLightClients; i++ {
   258  		startAt := manifest.InitialHeight + 5
   259  		manifest.Nodes[fmt.Sprintf("light%02d", i)] = generateLightNode(
   260  			r, startAt+(5*int64(i)), lightProviders,
   261  		)
   262  	}
   263  
   264  	return manifest, nil
   265  }
   266  
   267  // generateNode randomly generates a node, with some constraints to avoid
   268  // generating invalid configurations. We do not set Seeds or PersistentPeers
   269  // here, since we need to know the overall network topology and startup
   270  // sequencing.
   271  func generateNode(
   272  	r *rand.Rand, mode e2e.Mode, startAt int64, initialHeight int64, forceArchive bool,
   273  ) *e2e.ManifestNode {
   274  	node := e2e.ManifestNode{
   275  		Version:          nodeVersions.Choose(r).(string),
   276  		Mode:             string(mode),
   277  		StartAt:          startAt,
   278  		Database:         nodeDatabases.Choose(r).(string),
   279  		PrivvalProtocol:  nodePrivvalProtocols.Choose(r).(string),
   280  		FastSync:         nodeFastSyncs.Choose(r).(string),
   281  		Mempool:          nodeMempools.Choose(r).(string),
   282  		StateSync:        nodeStateSyncs.Choose(r).(bool) && startAt > 0,
   283  		PersistInterval:  ptrUint64(uint64(nodePersistIntervals.Choose(r).(int))),
   284  		SnapshotInterval: uint64(nodeSnapshotIntervals.Choose(r).(int)),
   285  		RetainBlocks:     uint64(nodeRetainBlocks.Choose(r).(int)),
   286  		Perturb:          nodePerturbations.Choose(r),
   287  	}
   288  
   289  	// If this node is forced to be an archive node, retain all blocks and
   290  	// enable state sync snapshotting.
   291  	if forceArchive {
   292  		node.RetainBlocks = 0
   293  		node.SnapshotInterval = 3
   294  	}
   295  
   296  	if node.Mode == string(e2e.ModeValidator) {
   297  		misbehaveAt := startAt + 5 + int64(r.Intn(10))
   298  		if startAt == 0 {
   299  			misbehaveAt += initialHeight - 1
   300  		}
   301  		node.Misbehaviors = nodeMisbehaviors.Choose(r).(misbehaviorOption).atHeight(misbehaveAt)
   302  		if len(node.Misbehaviors) != 0 {
   303  			node.PrivvalProtocol = "file"
   304  		}
   305  	}
   306  
   307  	// If a node which does not persist state also does not retain blocks, randomly
   308  	// choose to either persist state or retain all blocks.
   309  	if node.PersistInterval != nil && *node.PersistInterval == 0 && node.RetainBlocks > 0 {
   310  		if r.Float64() > 0.5 {
   311  			node.RetainBlocks = 0
   312  		} else {
   313  			node.PersistInterval = ptrUint64(node.RetainBlocks)
   314  		}
   315  	}
   316  
   317  	// If either PersistInterval or SnapshotInterval are greater than RetainBlocks,
   318  	// expand the block retention time.
   319  	if node.RetainBlocks > 0 {
   320  		if node.PersistInterval != nil && node.RetainBlocks < *node.PersistInterval {
   321  			node.RetainBlocks = *node.PersistInterval
   322  		}
   323  		if node.RetainBlocks < node.SnapshotInterval {
   324  			node.RetainBlocks = node.SnapshotInterval
   325  		}
   326  	}
   327  
   328  	return &node
   329  }
   330  
   331  func generateLightNode(r *rand.Rand, startAt int64, providers []string) *e2e.ManifestNode {
   332  	return &e2e.ManifestNode{
   333  		Mode:            string(e2e.ModeLight),
   334  		Version:         nodeVersions.Choose(r).(string),
   335  		StartAt:         startAt,
   336  		Database:        nodeDatabases.Choose(r).(string),
   337  		PersistInterval: ptrUint64(0),
   338  		PersistentPeers: providers,
   339  		Perturb:         lightNodePerturbations.Choose(r),
   340  	}
   341  }
   342  
   343  func ptrUint64(i uint64) *uint64 {
   344  	return &i
   345  }
   346  
   347  type misbehaviorOption struct {
   348  	misbehavior string
   349  }
   350  
   351  func (m misbehaviorOption) atHeight(height int64) map[string]string {
   352  	misbehaviorMap := make(map[string]string)
   353  	if m.misbehavior == "" {
   354  		return misbehaviorMap
   355  	}
   356  	misbehaviorMap[strconv.Itoa(int(height))] = m.misbehavior
   357  	return misbehaviorMap
   358  }
   359  
   360  // Parses strings like "v0.34.21:1,v0.34.22:2" to represent two versions
   361  // ("v0.34.21" and "v0.34.22") with weights of 1 and 2 respectively.
   362  // Versions may be specified as cometbft/e2e-node:v0.34.27-alpha.1:1 or
   363  // ghcr.io/informalsystems/tendermint:v0.34.26:1.
   364  // If only the tag and weight are specified, cometbft/e2e-node is assumed.
   365  // Also returns the last version in the list, which will be used for updates.
   366  func parseWeightedVersions(s string) (weightedChoice, string, error) {
   367  	wc := make(weightedChoice)
   368  	lv := ""
   369  	wvs := strings.Split(strings.TrimSpace(s), ",")
   370  	for _, wv := range wvs {
   371  		parts := strings.Split(strings.TrimSpace(wv), ":")
   372  		var ver string
   373  		if len(parts) == 2 {
   374  			ver = strings.TrimSpace(strings.Join([]string{"cometbft/e2e-node", parts[0]}, ":"))
   375  		} else if len(parts) == 3 {
   376  			ver = strings.TrimSpace(strings.Join([]string{parts[0], parts[1]}, ":"))
   377  		} else {
   378  			return nil, "", fmt.Errorf("unexpected weight:version combination: %s", wv)
   379  		}
   380  
   381  		wt, err := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1]))
   382  		if err != nil {
   383  			return nil, "", fmt.Errorf("unexpected weight \"%s\": %w", parts[1], err)
   384  		}
   385  
   386  		if wt < 1 {
   387  			return nil, "", errors.New("version weights must be >= 1")
   388  		}
   389  		wc[ver] = uint(wt)
   390  		lv = ver
   391  	}
   392  	return wc, lv, nil
   393  }
   394  
   395  // Extracts the latest release version from the given Git repository. Uses the
   396  // current version of CometBFT to establish the "major" version
   397  // currently in use.
   398  func gitRepoLatestReleaseVersion(gitRepoDir string) (string, error) {
   399  	opts := &git.PlainOpenOptions{
   400  		DetectDotGit: true,
   401  	}
   402  	r, err := git.PlainOpenWithOptions(gitRepoDir, opts)
   403  	if err != nil {
   404  		return "", err
   405  	}
   406  	tags := make([]string, 0)
   407  	tagObjs, err := r.TagObjects()
   408  	if err != nil {
   409  		return "", err
   410  	}
   411  	err = tagObjs.ForEach(func(tagObj *object.Tag) error {
   412  		tags = append(tags, tagObj.Name)
   413  		return nil
   414  	})
   415  	if err != nil {
   416  		return "", err
   417  	}
   418  	return findLatestReleaseTag(version.TMCoreSemVer, tags)
   419  }
   420  
   421  func findLatestReleaseTag(baseVer string, tags []string) (string, error) {
   422  	baseSemVer, err := semver.NewVersion(strings.Split(baseVer, "-")[0])
   423  	if err != nil {
   424  		return "", fmt.Errorf("failed to parse base version \"%s\": %w", baseVer, err)
   425  	}
   426  	compVer := fmt.Sprintf("%d.%d", baseSemVer.Major(), baseSemVer.Minor())
   427  	// Build our version comparison string
   428  	// See https://github.com/Masterminds/semver#caret-range-comparisons-major for details
   429  	compStr := "^ " + compVer
   430  	verCon, err := semver.NewConstraint(compStr)
   431  	if err != nil {
   432  		return "", err
   433  	}
   434  	var latestVer *semver.Version
   435  	for _, tag := range tags {
   436  		if !strings.HasPrefix(tag, "v") {
   437  			continue
   438  		}
   439  		curVer, err := semver.NewVersion(tag)
   440  		// Skip tags that are not valid semantic versions
   441  		if err != nil {
   442  			continue
   443  		}
   444  		// Skip pre-releases
   445  		if len(curVer.Prerelease()) != 0 {
   446  			continue
   447  		}
   448  		// Skip versions that don't match our constraints
   449  		if !verCon.Check(curVer) {
   450  			continue
   451  		}
   452  		if latestVer == nil || curVer.GreaterThan(latestVer) {
   453  			latestVer = curVer
   454  		}
   455  	}
   456  	// No relevant latest version (will cause the generator to only use the tip
   457  	// of the current branch)
   458  	if latestVer == nil {
   459  		return "", nil
   460  	}
   461  	// Ensure the version string has a "v" prefix, because all CometBFT E2E
   462  	// node Docker images' versions have a "v" prefix.
   463  	vs := latestVer.String()
   464  	if !strings.HasPrefix(vs, "v") {
   465  		return "v" + vs, nil
   466  	}
   467  	return vs, nil
   468  }