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 }