| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 82227fc commit f459d8d
9 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "github.com/cozy/cozy-stack/client" | |
| 17 | 17 | "github.com/cozy/cozy-stack/client/request" | |
| 18 | 18 | "github.com/cozy/cozy-stack/model/contact" | |
| 19 | + "github.com/cozy/cozy-stack/model/instance/lifecycle" | ||
| 19 | 20 | "github.com/cozy/cozy-stack/model/vfs" | |
| 20 | 21 | "github.com/cozy/cozy-stack/pkg/consts" | |
| 21 | 22 | ||
@@ -427,10 +428,69 @@ this instance are correctly set. | |||
| 427 | 428 | }, | |
| 428 | 429 | } | |
| 429 | 430 | ||
| 431 | + var internalEmailsFixer = &cobra.Command{ | ||
| 432 | + Use: "internal-emails", | ||
| 433 | + Short: "Backfill the internal email of all the instances", | ||
| 434 | + Long: ` | ||
| 435 | + This fixer sets the internal email of each instance from its settings email. | ||
| 436 | + It skips organization instances and keeps an internal email already set. Emails | ||
| 437 | + found on several instances are listed for manual resolution and left unset. | ||
| 438 | + If a settings email can't be read, nothing is written and the command fails. | ||
| 439 | + `, | ||
| 440 | + Example: `$ cozy-stack fix internal-emails --dry-run`, | ||
| 441 | + RunE: func(cmd *cobra.Command, args []string) error { | ||
| 442 | + if !dryRunFlag && !forceFlag { | ||
| 443 | + if err := askForConfirmation("Set the internal email on all instances?"); err != nil { | ||
| 444 | + return err | ||
| 445 | + } | ||
| 446 | + } | ||
| 447 | + c := newAdminClient() | ||
| 448 | + q := url.Values{} | ||
| 449 | + if dryRunFlag { | ||
| 450 | + q.Set("dry_run", "true") | ||
| 451 | + } | ||
| 452 | + res, err := c.Req(&request.Options{ | ||
| 453 | + Method: "POST", | ||
| 454 | + Path: "/instances/fixers/internal-emails", | ||
| 455 | + Queries: q, | ||
| 456 | + }) | ||
| 457 | + if err != nil { | ||
| 458 | + return err | ||
| 459 | + } | ||
| 460 | + defer res.Body.Close() | ||
| 461 | + var report lifecycle.InternalEmailsReport | ||
| 462 | + if err := json.NewDecoder(res.Body).Decode(&report); err != nil { | ||
| 463 | + return err | ||
| 464 | + } | ||
| 465 | + for email, domains := range report.Duplicates { | ||
| 466 | + fmt.Fprintf(os.Stdout, "Duplicate %s: %s\n", email, strings.Join(domains, ", ")) | ||
| 467 | + } | ||
| 468 | + for _, domain := range report.MissingSettings { | ||
| 469 | + fmt.Fprintf(os.Stdout, "Missing settings: %s\n", domain) | ||
| 470 | + } | ||
| 471 | + for _, e := range report.Errors { | ||
| 472 | + fmt.Fprintf(os.Stderr, "Error: %s\n", e) | ||
| 473 | + } | ||
| 474 | + updated := "Updated" | ||
| 475 | + if dryRunFlag { | ||
| 476 | + updated = "Would update" | ||
| 477 | + } | ||
| 478 | + fmt.Fprintf(os.Stdout, "Scanned: %d\nMissing settings: %d\nEmpty emails: %d\nDuplicate groups: %d\n%s: %d\nSkipped: %d\nErrors: %d\n", | ||
| 479 | + report.Scanned, len(report.MissingSettings), report.EmptyEmails, len(report.Duplicates), | ||
| 480 | + updated, report.Updated, report.Skipped, len(report.Errors)) | ||
| 481 | + if len(report.Errors) > 0 { | ||
| 482 | + return fmt.Errorf("%d errors, see above", len(report.Errors)) | ||
| 483 | + } | ||
| 484 | + return nil | ||
| 485 | + }, | ||
| 486 | + } | ||
| 487 | + | ||
| 430 | 488 | func init() { | |
| 431 | 489 | thumbnailsFixer.Flags().BoolVar(&dryRunFlag, "dry-run", false, "Dry run") | |
| 432 | 490 | thumbnailsFixer.Flags().BoolVar(&withMetadataFlag, "with-metadata", false, "Recalculate images metadata") | |
| 433 | 491 | redisFixer.Flags().BoolVar(&forceFlag, "force", false, "Do not ask for confirmation before fixing redis on all instances") | |
| 492 | + internalEmailsFixer.Flags().BoolVar(&dryRunFlag, "dry-run", false, "Report what would change without writing") | ||
| 493 | + internalEmailsFixer.Flags().BoolVar(&forceFlag, "force", false, "Do not ask for confirmation before writing") | ||
| 434 | 494 | ||
| 435 | 495 | fixerCmdGroup.AddCommand(jobsFixer) | |
| 436 | 496 | fixerCmdGroup.AddCommand(mimeFixerCmd) | |
@@ -442,6 +502,7 @@ func init() { | |||
| 442 | 502 | fixerCmdGroup.AddCommand(serviceTriggersFixer) | |
| 443 | 503 | fixerCmdGroup.AddCommand(sharingsMovedFixer) | |
| 444 | 504 | fixerCmdGroup.AddCommand(indexesFixer) | |
| 505 | + fixerCmdGroup.AddCommand(internalEmailsFixer) | ||
| 445 | 506 | ||
| 446 | 507 | RootCmd.AddCommand(fixerCmdGroup) | |
| 447 | 508 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -425,6 +425,48 @@ Delete the accounts which are not linked to a konnector | |||
| 425 | 425 | POST /instances/alice.cozy.localhost/fixers/orphan-account HTTP/1.1 | |
| 426 | 426 | ``` | |
| 427 | 427 | ||
| 428 | + ### POST /instances/fixers/internal-emails | ||
| 429 | + | ||
| 430 | + Set the internal email of every instance from its settings email. Organization | ||
| 431 | + instances are skipped, and an internal email already set is kept. Emails found | ||
| 432 | + on several instances are listed in `duplicates` and left unset, for manual | ||
| 433 | + resolution. If a settings email can't be read, nothing is updated, since | ||
| 434 | + duplicates can't be ruled out. Each scanned instance is counted once in | ||
| 435 | + `missing_settings`, `empty_emails`, `updated`, `skipped` or `errors`. | ||
| 436 | + | ||
| 437 | + #### Query-String | ||
| 438 | + | ||
| 439 | + | Parameter | Description | | ||
| 440 | + | --------- | ---------------------------------------------------- | | ||
| 441 | + | dry_run | `true` to report what would be updated, but not save | | ||
| 442 | + | ||
| 443 | + #### Request | ||
| 444 | + | ||
| 445 | + ```http | ||
| 446 | + POST /instances/fixers/internal-emails?dry_run=true HTTP/1.1 | ||
| 447 | + ``` | ||
| 448 | + | ||
| 449 | + #### Response | ||
| 450 | + | ||
| 451 | + ```http | ||
| 452 | + HTTP/1.1 200 OK | ||
| 453 | + Content-Type: application/json | ||
| 454 | + ``` | ||
| 455 | + | ||
| 456 | + ```json | ||
| 457 | + { | ||
| 458 | + "scanned": 4, | ||
| 459 | + "missing_settings": [], | ||
| 460 | + "empty_emails": 0, | ||
| 461 | + "duplicates": { | ||
| 462 | + "bob@example.com": ["bob.cozy.localhost", "bob2.cozy.localhost"] | ||
| 463 | + }, | ||
| 464 | + "updated": 1, | ||
| 465 | + "skipped": 3, | ||
| 466 | + "errors": [] | ||
| 467 | + } | ||
| 468 | + ``` | ||
| 469 | + | ||
| 428 | 470 | ### POST /instances/:domain/export | |
| 429 | 471 | ||
| 430 | 472 | Starts an export for the given instance. The CouchDB documents will be saved in | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -27,6 +27,7 @@ cozy-stack fix <command> [flags] | |||
| 27 | 27 | * [cozy-stack](cozy-stack.md) - cozy-stack is the main command | |
| 28 | 28 | * [cozy-stack fix contact-emails](cozy-stack_fix_contact-emails.md) - Detect and try to fix invalid emails on contacts | |
| 29 | 29 | * [cozy-stack fix indexes](cozy-stack_fix_indexes.md) - Rebuild the CouchDB views and indexes | |
| 30 | + * [cozy-stack fix internal-emails](cozy-stack_fix_internal-emails.md) - Backfill the internal email of all the instances | ||
| 30 | 31 | * [cozy-stack fix jobs](cozy-stack_fix_jobs.md) - Take a look at the consistency of the jobs | |
| 31 | 32 | * [cozy-stack fix mime](cozy-stack_fix_mime.md) - Fix the class computed from the mime-type | |
| 32 | 33 | * [cozy-stack fix orphan-account](cozy-stack_fix_orphan-account.md) - Remove the orphan accounts | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,45 @@ | |||
| 1 | + ## cozy-stack fix internal-emails | ||
| 2 | + | ||
| 3 | + Backfill the internal email of all the instances | ||
| 4 | + | ||
| 5 | + ### Synopsis | ||
| 6 | + | ||
| 7 | + | ||
| 8 | + This fixer sets the internal email of each instance from its settings email. | ||
| 9 | + It skips organization instances and keeps an internal email already set. Emails | ||
| 10 | + found on several instances are listed for manual resolution and left unset. | ||
| 11 | + If a settings email can't be read, nothing is written and the command fails. | ||
| 12 | + | ||
| 13 | + | ||
| 14 | + ``` | ||
| 15 | + cozy-stack fix internal-emails [flags] | ||
| 16 | + ``` | ||
| 17 | + | ||
| 18 | + ### Examples | ||
| 19 | + | ||
| 20 | + ``` | ||
| 21 | + $ cozy-stack fix internal-emails --dry-run | ||
| 22 | + ``` | ||
| 23 | + | ||
| 24 | + ### Options | ||
| 25 | + | ||
| 26 | + ``` | ||
| 27 | + --dry-run Report what would change without writing | ||
| 28 | + --force Do not ask for confirmation before writing | ||
| 29 | + -h, --help help for internal-emails | ||
| 30 | + ``` | ||
| 31 | + | ||
| 32 | + ### Options inherited from parent commands | ||
| 33 | + | ||
| 34 | + ``` | ||
| 35 | + --admin-host string administration server host (default "localhost") | ||
| 36 | + --admin-port int administration server port (default 6060) | ||
| 37 | + -c, --config string configuration file (default "$HOME/.cozy.yaml") | ||
| 38 | + --host string server host (default "localhost") | ||
| 39 | + -p, --port int server port (default 8080) | ||
| 40 | + ``` | ||
| 41 | + | ||
| 42 | + ### SEE ALSO | ||
| 43 | + | ||
| 44 | + * [cozy-stack fix](cozy-stack_fix.md) - A set of tools to fix issues or migrate content. | ||
| 45 | + | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -41,3 +41,74 @@ func GetInstanceByInternalEmail(email string) (*instance.Instance, error) { | |||
| 41 | 41 | return nil, fmt.Errorf("internal email %s is on several instances", email) | |
| 42 | 42 | } | |
| 43 | 43 | } | |
| 44 | + | ||
| 45 | + // InternalEmailsReport sums up a backfill of internal emails. | ||
| 46 | + type InternalEmailsReport struct { | ||
| 47 | + Scanned int `json:"scanned"` | ||
| 48 | + MissingSettings []string `json:"missing_settings"` | ||
| 49 | + EmptyEmails int `json:"empty_emails"` | ||
| 50 | + Duplicates map[string][]string `json:"duplicates"` | ||
| 51 | + Updated int `json:"updated"` | ||
| 52 | + Skipped int `json:"skipped"` | ||
| 53 | + Errors []string `json:"errors"` | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + // BackfillInternalEmails sets the internal email from the settings email. It | ||
| 57 | + // writes nothing if a settings read fails, as it may hide a duplicate. | ||
| 58 | + func BackfillInternalEmails(insts []*instance.Instance, dryRun bool) *InternalEmailsReport { | ||
| 59 | + report := &InternalEmailsReport{ | ||
| 60 | + MissingSettings: []string{}, | ||
| 61 | + Duplicates: map[string][]string{}, | ||
| 62 | + Errors: []string{}, | ||
| 63 | + } | ||
| 64 | + byEmail := make(map[string][]*instance.Instance) | ||
| 65 | + for _, inst := range insts { | ||
| 66 | + report.Scanned++ | ||
| 67 | + if inst.IsOrganizationInstance() && inst.InternalEmail == "" { | ||
| 68 | + report.Skipped++ | ||
| 69 | + continue | ||
| 70 | + } | ||
| 71 | + email := inst.InternalEmail | ||
| 72 | + if email == "" { | ||
| 73 | + var err error | ||
| 74 | + email, err = inst.SettingsEMail() | ||
| 75 | + if couchdb.IsNotFoundError(err) { | ||
| 76 | + report.MissingSettings = append(report.MissingSettings, inst.Domain) | ||
| 77 | + continue | ||
| 78 | + } | ||
| 79 | + if err != nil { | ||
| 80 | + report.Errors = append(report.Errors, fmt.Sprintf("%s: %s", inst.Domain, err)) | ||
| 81 | + continue | ||
| 82 | + } | ||
| 83 | + } | ||
| 84 | + if email = utils.NormalizeEmail(email); email == "" { | ||
| 85 | + report.EmptyEmails++ | ||
| 86 | + continue | ||
| 87 | + } | ||
| 88 | + byEmail[email] = append(byEmail[email], inst) | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + scanFailed := len(report.Errors) > 0 | ||
| 92 | + for email, owners := range byEmail { | ||
| 93 | + if len(owners) > 1 { | ||
| 94 | + for _, owner := range owners { | ||
| 95 | + report.Duplicates[email] = append(report.Duplicates[email], owner.Domain) | ||
| 96 | + } | ||
| 97 | + report.Skipped += len(owners) | ||
| 98 | + continue | ||
| 99 | + } | ||
| 100 | + inst := owners[0] | ||
| 101 | + if inst.InternalEmail == email || scanFailed { | ||
| 102 | + report.Skipped++ | ||
| 103 | + continue | ||
| 104 | + } | ||
| 105 | + if !dryRun { | ||
| 106 | + if err := SetInternalEmail(inst, email); err != nil { | ||
| 107 | + report.Errors = append(report.Errors, fmt.Sprintf("%s: %s", inst.Domain, err)) | ||
| 108 | + continue | ||
| 109 | + } | ||
| 110 | + } | ||
| 111 | + report.Updated++ | ||
| 112 | + } | ||
| 113 | + return report | ||
| 114 | + } | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2,6 +2,9 @@ package lifecycle_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + "fmt" | ||
| 6 | + "net/url" | ||
| 7 | + "strings" | ||
| 5 | 8 | "testing" | |
| 6 | 9 | "time" | |
| 7 | 10 | ||
@@ -494,6 +497,69 @@ func TestLifecycle(t *testing.T) { | |||
| 494 | 497 | assert.Equal(t, instance.TOSNone, deadline) | |
| 495 | 498 | }) | |
| 496 | 499 | ||
| 500 | + t.Run("BackfillInternalEmails", func(t *testing.T) { | ||
| 501 | + suffix := fmt.Sprintf("%d.example", time.Now().UnixNano()) | ||
| 502 | + create := func(slug, email string) *instance.Instance { | ||
| 503 | + t.Helper() | ||
| 504 | + domain := slug + "-" + suffix | ||
| 505 | + inst, err := lifecycle.Create(&lifecycle.Options{Domain: domain, Email: email}) | ||
| 506 | + require.NoError(t, err) | ||
| 507 | + t.Cleanup(func() { _ = lifecycle.Destroy(domain) }) | ||
| 508 | + return inst | ||
| 509 | + } | ||
| 510 | + internalEmail := func(inst *instance.Instance) string { | ||
| 511 | + t.Helper() | ||
| 512 | + got, err := lifecycle.GetInstance(inst.Domain) | ||
| 513 | + require.NoError(t, err) | ||
| 514 | + return got.InternalEmail | ||
| 515 | + } | ||
| 516 | + | ||
| 517 | + insts := []*instance.Instance{ | ||
| 518 | + create("alice", " Alice@"+suffix), | ||
| 519 | + create("bob", "bob@"+suffix), | ||
| 520 | + create("bob2", "BOB@"+suffix), | ||
| 521 | + create("nobody", ""), | ||
| 522 | + } | ||
| 523 | + | ||
| 524 | + report := lifecycle.BackfillInternalEmails(insts, true) | ||
| 525 | + assert.Equal(t, 4, report.Scanned) | ||
| 526 | + assert.Equal(t, 1, report.Updated) | ||
| 527 | + assert.Equal(t, 2, report.Skipped) | ||
| 528 | + assert.Equal(t, 1, report.EmptyEmails) | ||
| 529 | + assert.ElementsMatch(t, []string{insts[1].Domain, insts[2].Domain}, report.Duplicates["bob@"+suffix]) | ||
| 530 | + assert.Empty(t, report.Errors) | ||
| 531 | + assert.Empty(t, internalEmail(insts[0]), "dry run must not write") | ||
| 532 | + | ||
| 533 | + report = lifecycle.BackfillInternalEmails(insts, false) | ||
| 534 | + assert.Equal(t, 1, report.Updated) | ||
| 535 | + assert.Equal(t, "alice@"+suffix, internalEmail(insts[0])) | ||
| 536 | + assert.Empty(t, internalEmail(insts[1])) | ||
| 537 | + assert.Empty(t, internalEmail(insts[2])) | ||
| 538 | + | ||
| 539 | + orgSlug := "org-" + strings.TrimSuffix(suffix, ".example") | ||
| 540 | + org, err := lifecycle.Create(&lifecycle.Options{Domain: orgSlug + ".example", OrgID: orgSlug}) | ||
| 541 | + require.NoError(t, err) | ||
| 542 | + t.Cleanup(func() { _ = lifecycle.Destroy(org.Domain) }) | ||
| 543 | + require.True(t, org.IsOrganizationInstance()) | ||
| 544 | + require.NoError(t, lifecycle.SetInternalEmail(org, "erin@"+suffix)) | ||
| 545 | + erin := create("erin", "erin@"+suffix) | ||
| 546 | + report = lifecycle.BackfillInternalEmails([]*instance.Instance{org, erin}, false) | ||
| 547 | + assert.Len(t, report.Duplicates["erin@"+suffix], 2) | ||
| 548 | + assert.Empty(t, internalEmail(erin)) | ||
| 549 | + | ||
| 550 | + // A failed settings read blocks every write. | ||
| 551 | + clusters := config.GetConfig().CouchDB.Clusters | ||
| 552 | + t.Cleanup(func() { config.GetConfig().CouchDB.Clusters = clusters }) | ||
| 553 | + unreachable, _ := url.Parse("http://127.0.0.1:1/") | ||
| 554 | + config.GetConfig().CouchDB.Clusters = append(clusters[:len(clusters):len(clusters)], config.CouchDBCluster{URL: unreachable}) | ||
| 555 | + broken := &instance.Instance{Domain: "broken-" + suffix, CouchCluster: len(clusters)} | ||
| 556 | + dave := create("dave", "dave@"+suffix) | ||
| 557 | + report = lifecycle.BackfillInternalEmails([]*instance.Instance{dave, broken}, false) | ||
| 558 | + assert.Len(t, report.Errors, 1) | ||
| 559 | + assert.Equal(t, 0, report.Updated) | ||
| 560 | + assert.Empty(t, internalEmail(dave)) | ||
| 561 | + }) | ||
| 562 | + | ||
| 497 | 563 | t.Run("InstanceDestroy", func(t *testing.T) { | |
| 498 | 564 | _ = lifecycle.Destroy("test.cozycloud.cc") | |
| 499 | 565 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -355,3 +355,12 @@ func indexesFixer(c echo.Context) error { | |||
| 355 | 355 | ||
| 356 | 356 | return c.NoContent(http.StatusNoContent) | |
| 357 | 357 | } | |
| 358 | + | ||
| 359 | + func internalEmailsFixer(c echo.Context) error { | ||
| 360 | + insts, err := instance.List() | ||
| 361 | + if err != nil { | ||
| 362 | + return wrapError(err) | ||
| 363 | + } | ||
| 364 | + dryRun := c.QueryParam("dry_run") == "true" | ||
| 365 | + return c.JSON(http.StatusOK, lifecycle.BackfillInternalEmails(insts, dryRun)) | ||
| 366 | + } | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -825,4 +825,5 @@ func Routes(router *echo.Group) { | |||
| 825 | 825 | router.POST("/:domain/fixers/sharings-moved", sharingsMovedFixer) | |
| 826 | 826 | router.POST("/:domain/fixers/service-triggers", serviceTriggersFixer) | |
| 827 | 827 | router.POST("/:domain/fixers/indexes", indexesFixer) | |
| 828 | + router.POST("/fixers/internal-emails", internalEmailsFixer) | ||
| 828 | 829 | } | |
| Back | FazBrowse Home | New Git URL |
0 commit comments