github.com/decred/politeia@v1.4.0/politeiad/cmd/legacypoliteia/cmd_import.go (about) 1 // Copyright (c) 2022 The Decred developers 2 // Use of this source code is governed by an ISC 3 // license that can be found in the LICENSE file. 4 5 package main 6 7 import ( 8 "bytes" 9 "encoding/base64" 10 "encoding/binary" 11 "encoding/hex" 12 "encoding/json" 13 "errors" 14 "flag" 15 "fmt" 16 "io/fs" 17 "net/http" 18 "os" 19 "path/filepath" 20 "sort" 21 "strings" 22 "sync" 23 "time" 24 25 "github.com/decred/dcrd/chaincfg/v3" 26 "github.com/decred/dcrd/dcrutil/v3" 27 "github.com/decred/politeia/politeiad/api/v1/identity" 28 "github.com/decred/politeia/politeiad/api/v1/mime" 29 backend "github.com/decred/politeia/politeiad/backendv2" 30 "github.com/decred/politeia/politeiad/backendv2/tstorebe/store" 31 "github.com/decred/politeia/politeiad/backendv2/tstorebe/store/mysql" 32 "github.com/decred/politeia/politeiad/backendv2/tstorebe/tlog" 33 "github.com/decred/politeia/politeiad/backendv2/tstorebe/tstore" 34 "github.com/decred/politeia/politeiad/plugins/comments" 35 "github.com/decred/politeia/politeiad/plugins/pi" 36 "github.com/decred/politeia/politeiad/plugins/ticketvote" 37 "github.com/decred/politeia/politeiad/plugins/usermd" 38 "github.com/decred/politeia/politeiawww/config" 39 "github.com/decred/politeia/politeiawww/legacy/user" 40 userdb "github.com/decred/politeia/politeiawww/legacy/user/mysql" 41 "github.com/decred/politeia/util" 42 "github.com/google/trillian" 43 "github.com/google/uuid" 44 "google.golang.org/grpc/codes" 45 ) 46 47 const ( 48 // tstore settings 49 defaultTlogHost = "localhost:8090" 50 defaultDBHost = "localhost:3306" 51 defaultDBPass = "politeiadpass" 52 53 // User database settings 54 userDBPass = "politeiawwwpass" 55 ) 56 57 var ( 58 // CLI flags for the import command. We print a custom usage message, 59 // see usage.go, so the individual flag usage messages are left blank. 60 importFlags = flag.NewFlagSet(importCmdName, flag.ExitOnError) 61 testnet = importFlags.Bool("testnet", false, "") 62 tlogHost = importFlags.String("tloghost", defaultTlogHost, "") 63 dbHost = importFlags.String("dbhost", defaultDBHost, "") 64 dbPass = importFlags.String("dbpass", defaultDBPass, "") 65 importToken = importFlags.String("token", "", "") 66 stubUsers = importFlags.Bool("stubusers", false, "") 67 68 // tstore settings 69 politeiadHomeDir = dcrutil.AppDataDir("politeiad", false) 70 politeiadDataDir = filepath.Join(politeiadHomeDir, "data") 71 // dcrtimeHost = "" // Not needed for import 72 // dcrtimeCert = "" // Not needed for import 73 74 // User database settings 75 userDBEncryptionKey = filepath.Join(config.DefaultHomeDir, "sbox.key") 76 ) 77 78 // execImportCmd executes the import command. 79 func execImportCmd(args []string) error { 80 // Verify the legacy directory exists 81 if len(args) == 0 { 82 return fmt.Errorf("legacy dir argument not provided") 83 } 84 legacyDir := util.CleanAndExpandPath(args[0]) 85 if _, err := os.Stat(legacyDir); err != nil { 86 return fmt.Errorf("legacy directory not found: %v", legacyDir) 87 } 88 89 // Parse the CLI flags 90 err := importFlags.Parse(args[1:]) 91 if err != nil { 92 return err 93 } 94 95 // Testnet or mainnet 96 params := config.MainNetParams.Params 97 if *testnet { 98 params = config.TestNet3Params.Params 99 } 100 101 fmt.Printf("\n") 102 fmt.Printf("Command parameters\n") 103 fmt.Printf("Network : %v\n", params.Name) 104 fmt.Printf("Tlog host: %v\n", *tlogHost) 105 fmt.Printf("DB host : %v\n", *dbHost) 106 fmt.Printf("\n") 107 108 // Print the total elapsed time on exit 109 t := time.Now() 110 defer func() { 111 fmt.Printf("Import elapsed time: %v\n", time.Since(t)) 112 }() 113 114 // Setup the import command context 115 c, err := newImportCmd(legacyDir, *tlogHost, *dbHost, *dbPass, 116 *importToken, *stubUsers, params) 117 if err != nil { 118 return err 119 } 120 121 // Import the legacy proposals 122 return c.importLegacyProposals() 123 } 124 125 // importCmd implements the legacypoliteia import command. The import command 126 // reads the output of the convert command from disk and imports it into the 127 // politeiad tstore backend. 128 // 129 // The performance bottleneck for this command is the trillian log server (tlog 130 // server). ~50 leaves/sec can be appended onto a tlog tree. This means that 131 // importing 10,000 proposal votes will take ~200 seconds (3 minutes, 20 132 // seconds). The vast majority of the execution time of this command is spent 133 // importing proposal votes. 134 // 135 // The command is relatively light weight. It's memory footprint should stay 136 // under 100 MiB and CPU usage should be minimal. 137 type importCmd struct { 138 sync.Mutex 139 legacyDir string 140 tlogHost string 141 token string // Optional 142 stubUsers bool 143 tstore *tstore.Tstore 144 145 // The following are used to import the proposal votes into tstore manually 146 // in order to increase performance to an acceptable speed. 147 kv store.BlobKV 148 tlogClient tlog.Client 149 150 // The following fields will only be populated when the caller provides 151 // the stub users flag. 152 userDB user.Database 153 http *http.Client 154 } 155 156 // newImportCmd returns a new importCmd. 157 func newImportCmd(legacyDir, tlogHost, dbHost, dbPass, importToken string, stubUsers bool, params *chaincfg.Params) (*importCmd, error) { 158 // Setup the tstore connection 159 ts, err := tstore.New(politeiadHomeDir, politeiadDataDir, 160 params, tlogHost, dbHost, dbPass, "", "") 161 if err != nil { 162 return nil, err 163 } 164 165 // Setup key-value store 166 var ( 167 dbUser = "politeiad" 168 dbName = fmt.Sprintf("%v_kv", params.Name) 169 ) 170 kv, err := mysql.New(dbHost, dbUser, dbPass, dbName) 171 if err != nil { 172 return nil, err 173 174 } 175 176 // Setup trillian client 177 tlogClient, err := tlog.NewClient(tlogHost) 178 if err != nil { 179 return nil, err 180 } 181 182 // Setup the user database connection 183 var ( 184 userDB user.Database 185 httpC *http.Client 186 ) 187 if stubUsers { 188 userDB, err = userdb.New(dbHost, userDBPass, 189 params.Name, userDBEncryptionKey) 190 if err != nil { 191 return nil, err 192 } 193 httpC, err = util.NewHTTPClient(false, "") 194 if err != nil { 195 return nil, err 196 } 197 } 198 199 return &importCmd{ 200 legacyDir: legacyDir, 201 token: importToken, 202 tlogHost: tlogHost, 203 stubUsers: stubUsers, 204 tstore: ts, 205 kv: kv, 206 tlogClient: tlogClient, 207 userDB: userDB, 208 http: httpC, 209 }, nil 210 } 211 212 // importProposals walks the legacy directory and imports the legacy proposals 213 // into tstore. It accomplishes this using the following steps: 214 // 215 // 1. Inventory all legacy proposals being imported. 216 // 217 // 2. Retrieve the tstore token inventory. 218 // 219 // 3. Iterate through each record in the existing tstore inventory and check 220 // if the record corresponds to one of the legacy proposals. 221 // 222 // 4. Perform an fsck on all legacy proposals that already exist in tstore to 223 // verify that the full legacy proposal has been imported. Any missing 224 // legacy proposal content is added to tstore during this step. A partial 225 // import can happen if the import command was being run and was stopped 226 // prior to completion or if it encountered an unexpected error. 227 // 228 // 5. Add the legacy RFP proposals to tstore. This must be done first so that 229 // the RFP submissions can link to the tstore RFP proposal token. 230 // 231 // 6. Add the remaining legacy proposals to tstore. 232 // 233 // 7. Add a startRunoffRecord for each RFP proposal vote. The record is added 234 // to the RFP parent's tlog tree. This is required in order to mimic what 235 // would happen under normal operating conditions. 236 func (c *importCmd) importLegacyProposals() error { 237 // 1. Inventory all legacy proposals being imported 238 legacyInv, err := parseLegacyTokens(c.legacyDir) 239 if err != nil { 240 return err 241 } 242 legacyInvM := make(map[string]struct{}, len(legacyInv)) 243 for _, token := range legacyInv { 244 legacyInvM[token] = struct{}{} 245 } 246 247 fmt.Printf("%v legacy proposals found for import\n", len(legacyInv)) 248 249 // 2. Retrieve the tstore token inventory 250 inv, err := c.tstore.Inventory() 251 if err != nil { 252 return err 253 } 254 255 fmt.Printf("%v existing proposals found in tstore\n", len(inv)) 256 257 // imported contains the legacy tokens of all legacy proposals 258 // that have already been imported into tstore. This list does 259 // not differentiate between partially imported or fully 260 // imported proposals. The fsck function checks for and handles 261 // partially imported proposals. 262 // 263 // map[legacyToken]tstoreToken 264 imported := make(map[string][]byte, len(legacyInv)) 265 266 // startRunoffRecords is used to aggregate the data for runoff 267 // votes. This is done during runtime because the tstore tokens 268 // for all of the RFP submissions must be compiled before the 269 // startRunoffRecord can be saved to the parent RFP tree. 270 // 271 // map[tstoreTokenForParentRFP]startRunoffRecord 272 startRunoffRecords := make(map[string]startRunoffRecord, len(legacyInv)) 273 274 // 3. Iterate through each record in the existing tstore 275 // inventory and check if the record corresponds to one 276 // of the legacy proposals. 277 for _, tstoreToken := range inv { 278 // Get the record metadata from tstore 279 filenames := []string{pi.FileNameProposalMetadata} 280 r, err := c.tstore.RecordPartial(tstoreToken, 0, filenames, false) 281 if err != nil { 282 return err 283 } 284 switch r.RecordMetadata.Status { 285 case backend.StatusPublic, backend.StatusArchived: 286 // These statuses are expected 287 default: 288 // This is not a record that we're interested in. 289 // The legacy proposals are all going to be either 290 // public or archived. 291 continue 292 } 293 294 // Check if this is a legacy proposal 295 pm, err := decodeProposalMetadata(r.Files) 296 if err != nil { 297 return err 298 } 299 if pm.LegacyToken == "" { 300 // This is not a legacy proposal 301 continue 302 } 303 304 // This is a legacy proposal. Add it to the imported list. 305 imported[pm.LegacyToken] = tstoreToken 306 } 307 308 fmt.Printf("%v legacy proposals were found in tstore\n", len(imported)) 309 310 // 4. Perform an fsck on all legacy proposals that already exist 311 // in tstore to verify that the full legacy proposal has been 312 // imported. Any missing legacy proposal content is added to 313 // tstore during this step. A partial import can happen if 314 // the import command was being run and was stopped prior to 315 // completion or if it encountered an unexpected error. 316 for legacyToken, tstoreToken := range imported { 317 err := c.fsckProposal(legacyToken, tstoreToken) 318 if err != nil { 319 return err 320 } 321 } 322 323 // 5. Add the legacy RFP proposals to tstore. This must be done 324 // first so that the RFP submissions can link to the tstore 325 // RFP proposal token. 326 for _, legacyToken := range legacyInv { 327 if c.token != "" && c.token != legacyToken { 328 // The caller wants to import a specific 329 // proposal and this is not it. 330 continue 331 } 332 if _, ok := imported[legacyToken]; ok { 333 // This proposal has already been imported 334 continue 335 } 336 p, err := readProposal(c.legacyDir, legacyToken) 337 if err != nil { 338 return err 339 } 340 if !p.isRFP() { 341 // This is not an RFP. Skip it for now. 342 continue 343 } 344 345 fmt.Printf("Importing proposal %v/%v\n", len(imported)+1, len(legacyInv)) 346 347 tstoreToken, err := c.importProposal(p, nil) 348 if err != nil { 349 return err 350 } 351 352 imported[legacyToken] = tstoreToken 353 } 354 355 // 6. Add the remaining legacy proposals to tstore 356 for _, legacyToken := range legacyInv { 357 if c.token != "" && c.token != legacyToken { 358 // The caller wants to import a specific 359 // proposal and this is not it. 360 continue 361 } 362 if _, ok := imported[legacyToken]; ok { 363 // This proposal has already been imported 364 continue 365 } 366 367 fmt.Printf("Importing proposal %v/%v\n", len(imported)+1, len(legacyInv)) 368 369 // Read the proposal from disk 370 p, err := readProposal(c.legacyDir, legacyToken) 371 if err != nil { 372 return err 373 } 374 375 // Lookup th RFP parent tstore token if this is an RFP submission. 376 // The RFP submissions must reference the parent RFP tstore token, 377 // not the parent RFP legacy token. 378 var parentTstoreToken []byte 379 if p.isRFPSubmission() { 380 parentTstoreToken = imported[p.VoteMetadata.LinkTo] 381 if parentTstoreToken == nil { 382 // Should not happen 383 return fmt.Errorf("rpf parent tstore token not found") 384 } 385 } 386 387 // Import the proposal 388 tstoreToken, err := c.importProposal(p, parentTstoreToken) 389 if err != nil { 390 return err 391 } 392 393 imported[legacyToken] = tstoreToken 394 395 // Aggregate the runoff vote data needed for the startRunoffRecord. 396 // This is only necessary if this proposal in an RFP submission. 397 if parentTstoreToken != nil { 398 parentToken := hex.EncodeToString(parentTstoreToken) 399 srr, ok := startRunoffRecords[parentToken] 400 if !ok { 401 srr = startRunoffRecord{ 402 Submissions: []string{}, 403 Mask: p.VoteDetails.Params.Mask, 404 Duration: p.VoteDetails.Params.Duration, 405 QuorumPercentage: p.VoteDetails.Params.QuorumPercentage, 406 PassPercentage: p.VoteDetails.Params.PassPercentage, 407 StartBlockHeight: p.VoteDetails.StartBlockHeight, 408 StartBlockHash: p.VoteDetails.StartBlockHash, 409 EndBlockHeight: p.VoteDetails.EndBlockHeight, 410 EligibleTickets: p.VoteDetails.EligibleTickets, 411 } 412 } 413 414 submissionToken := hex.EncodeToString(tstoreToken) 415 srr.Submissions = append(srr.Submissions, submissionToken) 416 417 startRunoffRecords[parentToken] = srr 418 } 419 } 420 421 // 7. Add a startRunoffRecord for each RFP proposal vote. The 422 // record is added to the RFP parent's tlog tree. This is 423 // required in order to mimic what would happen under normal 424 // operating conditions. 425 for parentTstoreToken, srr := range startRunoffRecords { 426 fmt.Printf("Importing start runoff record to %v\n", parentTstoreToken) 427 428 parent, err := hex.DecodeString(parentTstoreToken) 429 if err != nil { 430 return err 431 } 432 err = c.saveStartRunoffRecord(parent, srr) 433 if err != nil { 434 return err 435 } 436 } 437 438 return nil 439 } 440 441 // fsckProposal verifies that a legacy proposal has been fully imported into 442 // tstore. If a partial import is found, this function will pick up where the 443 // previous invocation left off and finish the import. 444 func (c *importCmd) fsckProposal(legacyToken string, tstoreToken []byte) error { 445 fmt.Printf("Fsck proposal %x %v\n", tstoreToken, legacyToken) 446 447 // This is non-trivial to implement and will only be needed 448 // if an error occurs during the import process. We'll leave 449 // this unimplemented for now and only implement it if 450 // something goes wrong during the production import process 451 // and we actually need it. 452 453 return nil 454 } 455 456 // importProposal imports the specified legacy proposal into tstore and returns 457 // the tstore token that is created during import. 458 // 459 // parentTstoreToken is an optional argument that will be populated for RFP 460 // submissions. The parentTstoreToken is the parent RFP tstore token that the 461 // RFP submissions will need to reference. This argument will be nil for all 462 // proposals that are not RFP submissions. 463 // 464 // This function assumes that the proposal does not yet exist in tstore. 465 // Handling proposals that have been partially added is done by the 466 // fsckProposal function. 467 func (c *importCmd) importProposal(p *proposal, parentTstoreToken []byte) ([]byte, error) { 468 fmt.Printf(" Legacy token: %v\n", p.RecordMetadata.Token) 469 470 // Create a new tstore record entry 471 tstoreToken, err := c.tstore.RecordNew() 472 if err != nil { 473 return nil, err 474 } 475 476 fmt.Printf(" Tstore token: %x\n", tstoreToken) 477 478 // Perform proposal data changes 479 err = overwriteProposalFields(p, tstoreToken, parentTstoreToken) 480 if err != nil { 481 return nil, err 482 } 483 484 // Import the proposal contents 485 fmt.Printf(" Importing record data...\n") 486 err = c.importRecord(*p, tstoreToken) 487 if err != nil { 488 return nil, err 489 } 490 491 fmt.Printf(" Importing comment plugin data...\n") 492 err = c.importCommentPluginData(*p, tstoreToken) 493 if err != nil { 494 return nil, err 495 } 496 497 fmt.Printf(" Importing ticketvote plugin data...\n") 498 err = c.importTicketvotePluginData(*p, tstoreToken) 499 if err != nil { 500 return nil, err 501 } 502 503 // Stub the user in the politeiawww user database 504 if c.stubUsers { 505 err := c.stubProposalUsers(*p) 506 if err != nil { 507 return nil, err 508 } 509 } 510 511 return tstoreToken, nil 512 } 513 514 // importRecord imports the backend record portion of a proposal into tstore 515 // using the same steps that would occur under if the proposal was saved under 516 // normal conditions and not being imported by this tool. This is required 517 // because there are certain steps that the tstore backend must complete, ex. 518 // re-saving encrypted blobs as plain text when a proposal is made public, in 519 // order for the proposal to be imported correctly. 520 func (c *importCmd) importRecord(p proposal, tstoreToken []byte) error { 521 // Convert user generated metadata into backend files. 522 // 523 // User generated metadata includes: 524 // - pi plugin ProposalMetadata 525 // - ticketvote plugin VoteMetadata (may not exist) 526 f, err := convertProposalMetadataToFile(p.ProposalMetadata) 527 if err != nil { 528 return err 529 } 530 p.Files = append(p.Files, *f) 531 532 if p.VoteMetadata != nil { 533 f, err := convertVoteMetadataToFile(*p.VoteMetadata) 534 if err != nil { 535 return err 536 } 537 p.Files = append(p.Files, *f) 538 } 539 540 // Convert server generated metadata into backed metadata streams. 541 // 542 // Server generated metadata includes: 543 // - user plugin StatusChangeMetadata 544 // - user plugin UserMetadata 545 // 546 // Public proposals will only have one status change. Abandoned 547 // proposals will have two status changes, the public status change 548 // and the archived status change. The status changes are handled 549 // individually and not automatically added to the same metadata 550 // stream so that we can mimick how status change data is saved 551 // under normal operation. 552 userStream, err := convertUserMetadataToMetadataStream(p.UserMetadata) 553 if err != nil { 554 return err 555 } 556 557 var ( 558 publicStatus = p.StatusChanges[0] 559 abandonedStatus *usermd.StatusChangeMetadata 560 ) 561 if len(p.StatusChanges) > 1 { 562 abandonedStatus = &p.StatusChanges[1] 563 } 564 565 // Cache the record status that we will end up at. We 566 // must go through the normal status iterations in order 567 // to import the proposal correctly. 568 // 569 // Ex: unreviewed -> public -> abandoned 570 status := p.RecordMetadata.Status 571 572 // Save the proposal as unvetted 573 p.RecordMetadata.State = backend.StateUnvetted 574 p.RecordMetadata.Status = backend.StatusUnreviewed 575 576 metadataStreams := []backend.MetadataStream{ 577 *userStream, 578 } 579 580 err = c.tstore.RecordSave(tstoreToken, p.RecordMetadata, 581 metadataStreams, p.Files) 582 if err != nil { 583 return err 584 } 585 586 // Save the proposal as vetted. The public status change 587 // is added to the status change metadata stream during 588 // this step. The timestamp is incremented by 1 second 589 // so it's not the same timestamp as the unvetted version. 590 p.RecordMetadata.State = backend.StateVetted 591 p.RecordMetadata.Status = backend.StatusPublic 592 p.RecordMetadata.Timestamp += 1 593 594 statusChangeStream, err := convertStatusChangeToMetadataStream(publicStatus) 595 if err != nil { 596 return err 597 } 598 599 metadataStreams = []backend.MetadataStream{ 600 *userStream, 601 *statusChangeStream, 602 } 603 604 err = c.tstore.RecordSave(tstoreToken, p.RecordMetadata, 605 metadataStreams, p.Files) 606 if err != nil { 607 return err 608 } 609 610 switch status { 611 case backend.StatusPublic: 612 // This is a public proposal. There is nothing else 613 // that needs to be done. 614 return nil 615 616 case backend.StatusArchived: 617 // This is an abandoned proposal. Continue so that the 618 // status is updated below. 619 620 default: 621 // This should not happen. There should only be public 622 // and abandoned proposals. 623 return fmt.Errorf("invalid record status %v", status) 624 } 625 626 // This is an abandoned proposal. Update the record metadata, 627 // add the abandoned status to the status changes metadata 628 // stream, and freeze the tstore record. This is what would 629 // happen under regular operating conditions. The timestamp 630 // is incremented by 1 second so that it is unique. 631 p.RecordMetadata.Status = backend.StatusArchived 632 p.RecordMetadata.Iteration += 1 633 p.RecordMetadata.Timestamp += 1 634 635 abandonedStream, err := convertStatusChangeToMetadataStream(*abandonedStatus) 636 if err != nil { 637 return err 638 } 639 640 metadataStreams = []backend.MetadataStream{ 641 *userStream, 642 appendMetadataStream(*statusChangeStream, *abandonedStream), 643 } 644 645 return c.tstore.RecordFreeze(tstoreToken, p.RecordMetadata, 646 metadataStreams, p.Files) 647 } 648 649 // importCommentPluginData imports the comment plugin data into tstore for 650 // the provided proposal. 651 func (c *importCmd) importCommentPluginData(p proposal, tstoreToken []byte) error { 652 for i, v := range p.CommentAdds { 653 s := fmt.Sprintf(" Comment add %v/%v", i+1, len(p.CommentAdds)) 654 printInPlace(s) 655 656 err := c.saveCommentAdd(tstoreToken, v) 657 if err != nil { 658 return err 659 } 660 661 if i == len(p.CommentAdds)-1 { 662 fmt.Printf("\n") 663 } 664 } 665 for i, v := range p.CommentDels { 666 s := fmt.Sprintf(" Comment del %v/%v", i+1, len(p.CommentDels)) 667 printInPlace(s) 668 669 err := c.saveCommentDel(tstoreToken, v) 670 if err != nil { 671 return err 672 } 673 674 if i == len(p.CommentDels)-1 { 675 fmt.Printf("\n") 676 } 677 } 678 for i, v := range p.CommentVotes { 679 s := fmt.Sprintf(" Comment vote %v/%v", i+1, len(p.CommentVotes)) 680 printInPlace(s) 681 682 err := c.saveCommentVote(tstoreToken, v) 683 if err != nil { 684 return err 685 } 686 687 if i == len(p.CommentVotes)-1 { 688 fmt.Printf("\n") 689 } 690 } 691 return nil 692 } 693 694 // importTicketvotePluginData imports the ticketvote plugin data into tstore 695 // for the provided proposal. 696 // 697 // Some proposals we're never voted on and therefor do not have any ticketvote 698 // plugin data that needs to be imported. 699 func (c *importCmd) importTicketvotePluginData(p proposal, tstoreToken []byte) error { 700 // Save the auth details 701 if p.AuthDetails == nil { 702 return nil 703 } 704 705 fmt.Printf(" Auth details\n") 706 707 err := c.saveAuthDetails(tstoreToken, *p.AuthDetails) 708 if err != nil { 709 return err 710 } 711 712 // Save the vote details 713 if p.VoteDetails == nil { 714 return nil 715 } 716 717 fmt.Printf(" Vote details\n") 718 719 err = c.saveVoteDetails(tstoreToken, *p.VoteDetails) 720 if err != nil { 721 return err 722 } 723 724 // Save the cast votes. These are saved concurrently in batches 725 // to get around the tlog signer performance bottleneck. The tlog 726 // signer will only append queued leaves onto a tlog tree every 727 // xxx interval, where xxx is a config setting that is currently 728 // configured to 200ms for politeia. If we did not submit the 729 // votes concurrently, each vote would take at least 200ms to 730 // be appended, which is unacceptably slow when you have tens of 731 // thousands of votes to import. 732 // 733 // tlog is incredibly finicky. I think there is a deadlock bug 734 // somewhere in the trillian log server that gets hit when a large 735 // number of leaves are being appended. A batch size of 50 was 736 // found during testing to be a good balance between performance 737 // and errors. Increasing the batch size speeds up the importing, 738 // but also results in more deadlocks. 739 var ( 740 batchSize = 50 741 startIdx = 0 742 743 t = time.Now() 744 ) 745 for startIdx < len(p.CastVotes) { 746 endIdx := startIdx + batchSize 747 if endIdx > len(p.CastVotes) { 748 endIdx = len(p.CastVotes) 749 } 750 751 s := fmt.Sprintf(" Cast vote %v/%v", endIdx, len(p.CastVotes)) 752 printInPlace(s) 753 754 c.saveVoteBatch(tstoreToken, p.CastVotes[startIdx:endIdx]) 755 756 startIdx += batchSize 757 } 758 fmt.Printf("\n") 759 760 fmt.Printf(" Elapsed vote import time: %v\n", time.Since(t)) 761 762 return nil 763 } 764 765 // SavePluginBlobEntry is a light weight version of the TstoreClient BlobSave 766 // method that is used during normal operation of politeiad when saving plugin 767 // data. This light weight function is necessary to increase performance of 768 // a plugin data blob to an acceptable speed for this command. 769 func (c *importCmd) savePluginBlobEntry(token []byte, be store.BlobEntry) error { 770 // Prepare key-value store blob 771 digest, err := hex.DecodeString(be.Digest) 772 if err != nil { 773 return err 774 } 775 blob, err := store.Blobify(be) 776 if err != nil { 777 return err 778 } 779 key := uuid.New().String() 780 kv := map[string][]byte{key: blob} 781 782 // Save the blob to store 783 err = c.kv.Put(kv, false) 784 if err != nil { 785 return err 786 } 787 788 // Setup the tlog leaf extra data 789 type extraData struct { 790 Key string `json:"k"` 791 Desc string `json:"d"` 792 State backend.StateT `json:"s,omitempty"` 793 } 794 b, err := base64.StdEncoding.DecodeString(be.DataHint) 795 if err != nil { 796 return err 797 } 798 var dd store.DataDescriptor 799 err = json.Unmarshal(b, &dd) 800 if err != nil { 801 return err 802 } 803 ed := extraData{ 804 Key: key, 805 Desc: dd.Descriptor, 806 State: backend.StateVetted, 807 } 808 extraDataB, err := json.Marshal(ed) 809 if err != nil { 810 return err 811 } 812 813 // Append log leaf to trillian tree 814 var ( 815 treeID = int64(binary.LittleEndian.Uint64(token)) 816 leaves = []*trillian.LogLeaf{ 817 tlog.NewLogLeaf(digest, extraDataB), 818 } 819 ) 820 queued, _, err := c.tlogClient.LeavesAppend(treeID, leaves) 821 if err != nil { 822 return err 823 } 824 if len(queued) != 1 { 825 return fmt.Errorf("got %v queued leaves, want 1", len(queued)) 826 } 827 code := codes.Code(queued[0].QueuedLeaf.GetStatus().GetCode()) 828 switch code { 829 case codes.OK: 830 // This is ok; continue 831 case codes.AlreadyExists: 832 return backend.ErrDuplicatePayload 833 default: 834 return fmt.Errorf("queued leaf error: %v", c) 835 } 836 837 return nil 838 } 839 840 // saveVoteBatch saves a batch of cast votes to tstore. This includes appending 841 // leaves onto the tlog tree and saving the data blobs to the key-value store. 842 // 843 // tlog is incredibly finicky. I think there is a deadlock bug somewhere in the 844 // trillian log server that gets hit when a large number of leaves are being 845 // appended. The tlog server will periodically freeze up without throwing any 846 // errors and will require a hard restart. This function was written in a way 847 // that mitigates this issue as much as possible. If the trillian log server 848 // freezes up, this function will be stuck in a rety loop until the trillian 849 // lop server is reset. 850 func (c *importCmd) saveVoteBatch(tstoreToken []byte, votes []ticketvote.CastVoteDetails) { 851 var wg sync.WaitGroup 852 for _, v := range votes { 853 // Increment the wait group 854 wg.Add(1) 855 856 go func(cvd ticketvote.CastVoteDetails) { 857 // Decrement the wait group on successful completion 858 defer func() { 859 wg.Done() 860 }() 861 862 var voteSaved bool 863 for !voteSaved { 864 err := c.saveCastVoteDetails(tstoreToken, cvd) 865 switch { 866 case err == nil: 867 voteSaved = true 868 869 case strings.Contains(err.Error(), "duplicate payload"): 870 fmt.Printf("\n") 871 fmt.Printf("%v: %v\n", cvd.Ticket, err) 872 fmt.Printf("Vote %v already saved; skipping\n", cvd.Ticket) 873 874 voteSaved = true 875 876 default: 877 fmt.Printf("\n") 878 fmt.Printf("Failed to save cast vote %v: %v\n", cvd.Ticket, err) 879 fmt.Printf("Retrying cast vote %v\n", cvd.Ticket) 880 time.Sleep(50 * time.Millisecond) 881 } 882 } 883 884 // Not exactly sure why, but this reduces the number of failed 885 // tlog appends. 886 time.Sleep(50 * time.Millisecond) 887 888 var colliderSaved bool 889 for !colliderSaved { 890 vc := voteCollider{ 891 Token: cvd.Token, 892 Ticket: cvd.Ticket, 893 } 894 err := c.saveVoteCollider(tstoreToken, vc) 895 switch { 896 case err == nil: 897 colliderSaved = true 898 899 case strings.Contains(err.Error(), "duplicate payload"): 900 fmt.Printf("\n") 901 fmt.Printf("%v: %v\n", cvd.Ticket, err) 902 fmt.Printf("Vote collider %v already saved; skipping\n", cvd.Ticket) 903 904 colliderSaved = true 905 906 default: 907 fmt.Printf("\n") 908 fmt.Printf("Failed to save vote collider %v: %v\n", cvd.Ticket, err) 909 fmt.Printf("Retrying vote collider %v\n", cvd.Ticket) 910 time.Sleep(50 * time.Millisecond) 911 } 912 } 913 }(v) 914 } 915 916 // Wait for all votes to be successfully saved 917 wg.Wait() 918 } 919 920 const ( 921 // The following data descriptors were pulled from the plugins. They're not 922 // exported from the plugins and under normal circumstances there's no reason 923 // to have them as exported variables, so we duplicate them here. 924 925 // comments plugin data descriptors 926 dataDescriptorCommentAdd = comments.PluginID + "-add-v1" 927 dataDescriptorCommentDel = comments.PluginID + "-del-v1" 928 dataDescriptorCommentVote = comments.PluginID + "-vote-v1" 929 930 // ticketvote plugin data descriptors 931 dataDescriptorAuthDetails = ticketvote.PluginID + "-auth-v1" 932 dataDescriptorVoteDetails = ticketvote.PluginID + "-vote-v1" 933 dataDescriptorCastVoteDetails = ticketvote.PluginID + "-castvote-v1" 934 dataDescriptorVoteCollider = ticketvote.PluginID + "-vcollider-v1" 935 dataDescriptorStartRunoff = ticketvote.PluginID + "-startrunoff-v1" 936 ) 937 938 // saveCommentAdd saves a CommentAdd to tstore as a plugin data blob. 939 func (c *importCmd) saveCommentAdd(tstoreToken []byte, ca comments.CommentAdd) error { 940 data, err := json.Marshal(ca) 941 if err != nil { 942 return err 943 } 944 hint, err := json.Marshal( 945 store.DataDescriptor{ 946 Type: store.DataTypeStructure, 947 Descriptor: dataDescriptorCommentAdd, 948 }) 949 if err != nil { 950 return err 951 } 952 be := store.NewBlobEntry(hint, data) 953 return c.savePluginBlobEntry(tstoreToken, be) 954 } 955 956 // saveCommentDel saves a CommentDel to tstore as a plugin data blob. 957 func (c *importCmd) saveCommentDel(tstoreToken []byte, cd comments.CommentDel) error { 958 data, err := json.Marshal(cd) 959 if err != nil { 960 return err 961 } 962 hint, err := json.Marshal( 963 store.DataDescriptor{ 964 Type: store.DataTypeStructure, 965 Descriptor: dataDescriptorCommentDel, 966 }) 967 if err != nil { 968 return err 969 } 970 be := store.NewBlobEntry(hint, data) 971 return c.savePluginBlobEntry(tstoreToken, be) 972 } 973 974 // saveCommentVote saves a CommentVote to tstore as a plugin data blob. 975 func (c *importCmd) saveCommentVote(tstoreToken []byte, cv comments.CommentVote) error { 976 data, err := json.Marshal(cv) 977 if err != nil { 978 return err 979 } 980 hint, err := json.Marshal( 981 store.DataDescriptor{ 982 Type: store.DataTypeStructure, 983 Descriptor: dataDescriptorCommentVote, 984 }) 985 if err != nil { 986 return err 987 } 988 be := store.NewBlobEntry(hint, data) 989 return c.savePluginBlobEntry(tstoreToken, be) 990 } 991 992 // saveAuthDetails saves a AuthDetails to tstore as a plugin data blob. 993 func (c *importCmd) saveAuthDetails(tstoreToken []byte, ad ticketvote.AuthDetails) error { 994 data, err := json.Marshal(ad) 995 if err != nil { 996 return err 997 } 998 hint, err := json.Marshal( 999 store.DataDescriptor{ 1000 Type: store.DataTypeStructure, 1001 Descriptor: dataDescriptorAuthDetails, 1002 }) 1003 if err != nil { 1004 return err 1005 } 1006 be := store.NewBlobEntry(hint, data) 1007 return c.savePluginBlobEntry(tstoreToken, be) 1008 } 1009 1010 // saveVoteDetails saves a VoteDetails to tstore as a plugin data blob. 1011 func (c *importCmd) saveVoteDetails(tstoreToken []byte, vd ticketvote.VoteDetails) error { 1012 data, err := json.Marshal(vd) 1013 if err != nil { 1014 return err 1015 } 1016 hint, err := json.Marshal( 1017 store.DataDescriptor{ 1018 Type: store.DataTypeStructure, 1019 Descriptor: dataDescriptorVoteDetails, 1020 }) 1021 if err != nil { 1022 return err 1023 } 1024 be := store.NewBlobEntry(hint, data) 1025 return c.savePluginBlobEntry(tstoreToken, be) 1026 } 1027 1028 // saveCastVoteDetails saves a CastVoteDetails to tstore as a plugin data blob. 1029 func (c *importCmd) saveCastVoteDetails(tstoreToken []byte, cvd ticketvote.CastVoteDetails) error { 1030 data, err := json.Marshal(cvd) 1031 if err != nil { 1032 return err 1033 } 1034 hint, err := json.Marshal( 1035 store.DataDescriptor{ 1036 Type: store.DataTypeStructure, 1037 Descriptor: dataDescriptorCastVoteDetails, 1038 }) 1039 if err != nil { 1040 return err 1041 } 1042 be := store.NewBlobEntry(hint, data) 1043 return c.savePluginBlobEntry(tstoreToken, be) 1044 } 1045 1046 // saveVoteCollider saves a voteCollider to tstore as a plugin data blob. 1047 func (c *importCmd) saveVoteCollider(tstoreToken []byte, vc voteCollider) error { 1048 data, err := json.Marshal(vc) 1049 if err != nil { 1050 return err 1051 } 1052 hint, err := json.Marshal( 1053 store.DataDescriptor{ 1054 Type: store.DataTypeStructure, 1055 Descriptor: dataDescriptorVoteCollider, 1056 }) 1057 if err != nil { 1058 return err 1059 } 1060 be := store.NewBlobEntry(hint, data) 1061 return c.savePluginBlobEntry(tstoreToken, be) 1062 } 1063 1064 // saveStartRunoffRecord saves a startRunoffRecord to tstore as a plugin data 1065 // blob. 1066 func (c *importCmd) saveStartRunoffRecord(tstoreToken []byte, srr startRunoffRecord) error { 1067 data, err := json.Marshal(srr) 1068 if err != nil { 1069 return err 1070 } 1071 hint, err := json.Marshal( 1072 store.DataDescriptor{ 1073 Type: store.DataTypeStructure, 1074 Descriptor: dataDescriptorStartRunoff, 1075 }) 1076 if err != nil { 1077 return err 1078 } 1079 be := store.NewBlobEntry(hint, data) 1080 return c.savePluginBlobEntry(tstoreToken, be) 1081 } 1082 1083 // stubProposalUsers creates a stub in the user database for all user IDs and 1084 // public keys found in any of the proposal data. 1085 func (c *importCmd) stubProposalUsers(p proposal) error { 1086 fmt.Printf(" Stubbing proposal users...\n") 1087 1088 // Stub the proposal author 1089 err := c.stubUser(p.UserMetadata.UserID, p.UserMetadata.PublicKey) 1090 if err != nil { 1091 return err 1092 } 1093 1094 // Stub the comment and comment vote authors. A user 1095 // ID may be associated with multiple public keys. 1096 pks := make(map[string]string, 256) // [publicKey]userID 1097 for _, v := range p.CommentAdds { 1098 pks[v.PublicKey] = v.UserID 1099 } 1100 for _, v := range p.CommentDels { 1101 pks[v.PublicKey] = v.UserID 1102 } 1103 for _, v := range p.CommentVotes { 1104 pks[v.PublicKey] = v.UserID 1105 } 1106 for publicKey, userID := range pks { 1107 err := c.stubUser(userID, publicKey) 1108 if err != nil { 1109 return err 1110 } 1111 } 1112 1113 return nil 1114 } 1115 1116 // stubUser creates a stub in the user database for the provided user ID. 1117 // 1118 // If a user stub already exists, this function verifies that the stub contains 1119 // the provided public key. If it doesn't, the function will add the missing 1120 // public key to the user and update the stub in the database. 1121 func (c *importCmd) stubUser(userID, publicKey string) error { 1122 // Check if this user already exists in the user database 1123 uid, err := uuid.Parse(userID) 1124 if err != nil { 1125 return err 1126 } 1127 dbu, err := c.userDB.UserGetById(uid) 1128 switch { 1129 case err == nil: 1130 // User already exist. Update the user if the provided 1131 // public key is not part of the user stub. 1132 for _, id := range dbu.Identities { 1133 if id.String() == publicKey { 1134 // This user stub already contains the provided 1135 // public key. Nothing else to do. 1136 return nil 1137 } 1138 } 1139 1140 fmt.Printf(" Updating stubbed user %v %v\n", uid, dbu.Username) 1141 1142 updatedIDs, err := addIdentity(dbu.Identities, publicKey) 1143 if err != nil { 1144 return err 1145 } 1146 1147 dbu.Identities = updatedIDs 1148 return c.userDB.UserUpdate(*dbu) 1149 1150 case errors.Is(err, user.ErrUserNotFound): 1151 // User doesn't exist. Pull their username from the mainnet 1152 // Politeia API and add them to the user database. 1153 u, err := userByID(c.http, userID) 1154 if err != nil { 1155 return err 1156 } 1157 1158 // Setup the identities 1159 ids, err := addIdentity([]user.Identity{}, publicKey) 1160 if err != nil { 1161 return err 1162 } 1163 1164 fmt.Printf(" Stubbing user %v %v\n", uid, u.Username) 1165 1166 return c.userDB.InsertUser(user.User{ 1167 ID: uid, 1168 Email: u.Username + "@example.com", 1169 Username: u.Username, 1170 HashedPassword: []byte("password"), 1171 Admin: false, 1172 Identities: ids, 1173 }) 1174 1175 default: 1176 // All other errors 1177 return err 1178 } 1179 } 1180 1181 // parseLegacyTokens parses and returns all the unique tokens that are found in 1182 // the file path of the provided directory or any contents of the directory. 1183 // The tokens are returned in alphabetical order. 1184 func parseLegacyTokens(dir string) ([]string, error) { 1185 tokens := make(map[string]struct{}, 1024) 1186 err := filepath.WalkDir(dir, 1187 func(path string, d fs.DirEntry, err error) error { 1188 token, ok := parseProposalToken(path) 1189 if !ok { 1190 return nil 1191 } 1192 tokens[token] = struct{}{} 1193 return nil 1194 }) 1195 if err != nil { 1196 return nil, err 1197 } 1198 1199 // Convert map to a slice and sort alphabetically 1200 legacyTokens := make([]string, 0, len(tokens)) 1201 for token := range tokens { 1202 legacyTokens = append(legacyTokens, token) 1203 } 1204 sort.SliceStable(legacyTokens, func(i, j int) bool { 1205 return legacyTokens[i] < legacyTokens[j] 1206 }) 1207 1208 return legacyTokens, nil 1209 } 1210 1211 // appendMetadataStream appends the addition metadata streams onto the 1212 // base metadata stream. 1213 func appendMetadataStream(base, addition backend.MetadataStream) backend.MetadataStream { 1214 buf := bytes.NewBuffer([]byte(base.Payload)) 1215 buf.WriteString(addition.Payload) 1216 base.Payload = buf.String() 1217 return base 1218 } 1219 1220 // decodeLegacyTokenFromFiles decodes and returns the ProposalMetadata from the 1221 // provided files. 1222 func decodeProposalMetadata(files []backend.File) (*pi.ProposalMetadata, error) { 1223 var f *backend.File 1224 for _, v := range files { 1225 if v.Name == pi.FileNameProposalMetadata { 1226 f = &v 1227 break 1228 } 1229 } 1230 if f == nil { 1231 // This should not happen 1232 return nil, fmt.Errorf("proposal metadata not found") 1233 } 1234 b, err := base64.StdEncoding.DecodeString(f.Payload) 1235 if err != nil { 1236 return nil, err 1237 } 1238 var pm pi.ProposalMetadata 1239 err = json.Unmarshal(b, &pm) 1240 if err != nil { 1241 return nil, err 1242 } 1243 return &pm, nil 1244 } 1245 1246 // convertProposalMetadataToFile converts a pi plugin ProposalMetadata into a 1247 // backend File. 1248 func convertProposalMetadataToFile(pm pi.ProposalMetadata) (*backend.File, error) { 1249 pmb, err := json.Marshal(pm) 1250 if err != nil { 1251 return nil, err 1252 } 1253 return &backend.File{ 1254 Name: pi.FileNameProposalMetadata, 1255 MIME: mime.DetectMimeType(pmb), 1256 Digest: hex.EncodeToString(util.Digest(pmb)), 1257 Payload: base64.StdEncoding.EncodeToString(pmb), 1258 }, nil 1259 } 1260 1261 // convertVoteMetadataToFile converts a ticketvote plugin VoteMetadata into a 1262 // backend File. 1263 func convertVoteMetadataToFile(vm ticketvote.VoteMetadata) (*backend.File, error) { 1264 vmb, err := json.Marshal(vm) 1265 if err != nil { 1266 return nil, err 1267 } 1268 return &backend.File{ 1269 Name: ticketvote.FileNameVoteMetadata, 1270 MIME: mime.DetectMimeType(vmb), 1271 Digest: hex.EncodeToString(util.Digest(vmb)), 1272 Payload: base64.StdEncoding.EncodeToString(vmb), 1273 }, nil 1274 } 1275 1276 // convertUserMetadataToMetadataStream converts a usermd plugin UserMetadata 1277 // into a backend MetadataStream. 1278 func convertUserMetadataToMetadataStream(um usermd.UserMetadata) (*backend.MetadataStream, error) { 1279 b, err := json.Marshal(um) 1280 if err != nil { 1281 return nil, err 1282 } 1283 return &backend.MetadataStream{ 1284 PluginID: usermd.PluginID, 1285 StreamID: usermd.StreamIDUserMetadata, 1286 Payload: string(b), 1287 }, nil 1288 } 1289 1290 // convertStatusChangeToMetadataStream converts a usermd plugin 1291 // StatusChangeMetadata into a backend MetadataStream. 1292 func convertStatusChangeToMetadataStream(scm usermd.StatusChangeMetadata) (*backend.MetadataStream, error) { 1293 b, err := json.Marshal(scm) 1294 if err != nil { 1295 return nil, err 1296 } 1297 return &backend.MetadataStream{ 1298 PluginID: usermd.PluginID, 1299 StreamID: usermd.StreamIDStatusChanges, 1300 Payload: string(b), 1301 }, nil 1302 } 1303 1304 // addIdentity converts the provided public key string into a politeiawww user 1305 // identity and adds it to the provided identities list. 1306 // 1307 // The created identities will not mimic what would happen during normal 1308 // operation of the backend and this function should only be used for creating 1309 // test user stubs in the database. 1310 func addIdentity(ids []user.Identity, publicKey string) ([]user.Identity, error) { 1311 if ids == nil { 1312 return nil, fmt.Errorf("identities slice is nil") 1313 } 1314 1315 // Add the identities to the existing identities list 1316 id, err := identity.PublicIdentityFromString(publicKey) 1317 if err != nil { 1318 return nil, err 1319 } 1320 ids = append(ids, user.Identity{ 1321 Key: id.Key, 1322 Activated: time.Now().Unix(), 1323 }) 1324 1325 // Make the last identity the only active identity. 1326 // Not sure if this actually matters, but do it anyway. 1327 for i, v := range ids { 1328 v.Deactivated = v.Activated + 1 1329 ids[i] = v 1330 } 1331 ids[len(ids)-1].Deactivated = 0 1332 1333 return ids, nil 1334 }