diff --git a/src/cli/repo/repo.go b/src/cli/repo/repo.go index d60f9265a..5f768cb8b 100644 --- a/src/cli/repo/repo.go +++ b/src/cli/repo/repo.go @@ -1,12 +1,21 @@ package repo import ( + "strings" + + "github.com/alcionai/clues" "github.com/spf13/cobra" + "golang.org/x/exp/maps" + + "github.com/alcionai/corso/src/cli/print" + "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/control/repository" ) const ( - initCommand = "init" - connectCommand = "connect" + initCommand = "init" + connectCommand = "connect" + maintenanceCommand = "maintenance" ) var repoCommands = []func(cmd *cobra.Command) *cobra.Command{ @@ -18,15 +27,24 @@ func AddCommands(cmd *cobra.Command) { var ( // Get new instances so that setting the context during tests works // properly. - repoCmd = repoCmd() - initCmd = initCmd() - connectCmd = connectCmd() + repoCmd = repoCmd() + initCmd = initCmd() + connectCmd = connectCmd() + maintenanceCmd = maintenanceCmd() ) cmd.AddCommand(repoCmd) repoCmd.AddCommand(initCmd) repoCmd.AddCommand(connectCmd) + utils.AddCommand( + repoCmd, + maintenanceCmd, + utils.HideCommand(), + utils.MarkPreReleaseCommand()) + utils.AddMaintenanceModeFlag(maintenanceCmd) + utils.AddForceMaintenanceFlag(maintenanceCmd) + for _, addRepoTo := range repoCommands { addRepoTo(initCmd) addRepoTo(connectCmd) @@ -84,3 +102,65 @@ func connectCmd() *cobra.Command { func handleConnectCmd(cmd *cobra.Command, args []string) error { return cmd.Help() } + +func maintenanceCmd() *cobra.Command { + return &cobra.Command{ + Use: maintenanceCommand, + Short: "Run maintenance on an existing repository", + Long: `Run maintenance on an existing repository to optimize performance and storage use`, + RunE: handleMaintenanceCmd, + Args: cobra.NoArgs, + } +} + +func handleMaintenanceCmd(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + t, err := getMaintenanceType(utils.MaintenanceModeFV) + if err != nil { + return err + } + + r, _, err := utils.GetAccountAndConnect(ctx) + if err != nil { + return print.Only(ctx, err) + } + + defer utils.CloseRepo(ctx, r) + + m, err := r.NewMaintenance( + ctx, + repository.Maintenance{ + Type: t, + Safety: repository.FullMaintenanceSafety, + Force: utils.ForceMaintenanceFV, + }) + if err != nil { + return print.Only(ctx, err) + } + + err = m.Run(ctx) + if err != nil { + return print.Only(ctx, err) + } + + return nil +} + +func getMaintenanceType(t string) (repository.MaintenanceType, error) { + res, ok := repository.StringToMaintenanceType[t] + if !ok { + modes := maps.Keys(repository.StringToMaintenanceType) + allButLast := []string{} + + for i := 0; i < len(modes)-1; i++ { + allButLast = append(allButLast, string(modes[i])) + } + + valuesStr := strings.Join(allButLast, ", ") + " or " + string(modes[len(modes)-1]) + + return res, clues.New(t + " is an unrecognized maintenance mode; must be one of " + valuesStr) + } + + return res, nil +} diff --git a/src/cli/repo/repo_test.go b/src/cli/repo/repo_test.go new file mode 100644 index 000000000..97497b719 --- /dev/null +++ b/src/cli/repo/repo_test.go @@ -0,0 +1,41 @@ +package repo + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/tester" +) + +type RepoUnitSuite struct { + tester.Suite +} + +func TestRepoUnitSuite(t *testing.T) { + suite.Run(t, &RepoUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *RepoUnitSuite) TestAddRepoCommands() { + t := suite.T() + cmd := &cobra.Command{} + + AddCommands(cmd) + + var found bool + + // This is the repo command. + repoCmds := cmd.Commands() + require.Len(t, repoCmds, 1) + + for _, c := range repoCmds[0].Commands() { + if c.Use == maintenanceCommand { + found = true + } + } + + assert.True(t, found, "looking for maintenance command") +} diff --git a/src/cli/utils/flags.go b/src/cli/utils/flags.go index 3ca50d93e..ab1503034 100644 --- a/src/cli/utils/flags.go +++ b/src/cli/utils/flags.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/pflag" "github.com/alcionai/corso/src/internal/common/dttm" + "github.com/alcionai/corso/src/pkg/control/repository" "github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/selectors" ) @@ -37,6 +38,9 @@ var ( // for selection of data by category. eg: `--data email,contacts` CategoryDataFV []string + + MaintenanceModeFV string + ForceMaintenanceFV bool ) // common flag names (eg: FN) @@ -59,6 +63,10 @@ const ( FileCreatedBeforeFN = "file-created-before" FileModifiedAfterFN = "file-modified-after" FileModifiedBeforeFN = "file-modified-before" + + // Maintenance stuff. + MaintenanceModeFN = "mode" + ForceMaintenanceFN = "force" ) // well-known flag values @@ -168,6 +176,30 @@ func AddSiteFlag(cmd *cobra.Command) { "Backup data by site URL; accepts '"+Wildcard+"' to select all sites.") } +func AddMaintenanceModeFlag(cmd *cobra.Command) { + fs := cmd.Flags() + fs.StringVar( + &MaintenanceModeFV, + MaintenanceModeFN, + repository.CompleteMaintenance.String(), + "Type of maintenance operation to run. Pass '"+ + repository.MetadataMaintenance.String()+"' to run a faster maintenance "+ + "that does minimal clean-up and optimization. Pass '"+ + repository.CompleteMaintenance.String()+"' to fully compact existing "+ + "data and delete unused data.") + cobra.CheckErr(fs.MarkHidden(MaintenanceModeFN)) +} + +func AddForceMaintenanceFlag(cmd *cobra.Command) { + fs := cmd.Flags() + fs.BoolVar( + &ForceMaintenanceFV, + ForceMaintenanceFN, + false, + "Force maintenance. Caution: user must ensure this is not run concurrently on a single repo") + cobra.CheckErr(fs.MarkHidden(ForceMaintenanceFN)) +} + type PopulatedFlags map[string]struct{} func (fs PopulatedFlags) populate(pf *pflag.Flag) { diff --git a/src/internal/events/events.go b/src/internal/events/events.go index f900c50c4..a2f52dc54 100644 --- a/src/internal/events/events.go +++ b/src/internal/events/events.go @@ -28,13 +28,15 @@ const ( tenantIDDeprecated = "m365_tenant_hash_deprecated" // Event Keys - CorsoStart = "Corso Start" - RepoInit = "Repo Init" - RepoConnect = "Repo Connect" - BackupStart = "Backup Start" - BackupEnd = "Backup End" - RestoreStart = "Restore Start" - RestoreEnd = "Restore End" + CorsoStart = "Corso Start" + RepoInit = "Repo Init" + RepoConnect = "Repo Connect" + BackupStart = "Backup Start" + BackupEnd = "Backup End" + RestoreStart = "Restore Start" + RestoreEnd = "Restore End" + MaintenanceStart = "Maintenance Start" + MaintenanceEnd = "Maintenance End" // Event Data Keys BackupCreateTime = "backup_creation_time" diff --git a/src/internal/operations/maintenance.go b/src/internal/operations/maintenance.go index aa2a5bebd..9233cc0b2 100644 --- a/src/internal/operations/maintenance.go +++ b/src/internal/operations/maintenance.go @@ -7,6 +7,7 @@ import ( "github.com/alcionai/clues" "github.com/alcionai/corso/src/internal/common/crash" + "github.com/alcionai/corso/src/internal/common/dttm" "github.com/alcionai/corso/src/internal/events" "github.com/alcionai/corso/src/internal/kopia" "github.com/alcionai/corso/src/internal/stats" @@ -49,20 +50,45 @@ func (op *MaintenanceOperation) Run(ctx context.Context) (err error) { if crErr := crash.Recovery(ctx, recover(), "maintenance"); crErr != nil { err = crErr } - - // TODO(ashmrtn): Send success/failure usage stat? - - op.Results.CompletedAt = time.Now() }() op.Results.StartedAt = time.Now() - // TODO(ashmrtn): Send usage statistics? + op.bus.Event( + ctx, + events.MaintenanceStart, + map[string]any{ + events.StartTime: op.Results.StartedAt, + }) - err = op.operation.kopia.RepoMaintenance(ctx, op.mOpts) + defer func() { + op.bus.Event( + ctx, + events.MaintenanceEnd, + map[string]any{ + events.StartTime: op.Results.StartedAt, + events.Duration: op.Results.CompletedAt.Sub(op.Results.StartedAt), + events.EndTime: dttm.Format(op.Results.CompletedAt), + events.Status: op.Status.String(), + events.Resources: op.mOpts.Type.String(), + }) + }() + + return op.do(ctx) +} + +func (op *MaintenanceOperation) do(ctx context.Context) error { + defer func() { + op.Results.CompletedAt = time.Now() + }() + + err := op.operation.kopia.RepoMaintenance(ctx, op.mOpts) if err != nil { + op.Status = Failed return clues.Wrap(err, "running maintenance operation") } + op.Status = Completed + return nil } diff --git a/src/pkg/control/repository/repo.go b/src/pkg/control/repository/repo.go index 6153b7422..aab97948a 100644 --- a/src/pkg/control/repository/repo.go +++ b/src/pkg/control/repository/repo.go @@ -26,6 +26,11 @@ const ( MetadataMaintenance // metadata ) +var StringToMaintenanceType = map[string]MaintenanceType{ + CompleteMaintenance.String(): CompleteMaintenance, + MetadataMaintenance.String(): MetadataMaintenance, +} + type MaintenanceSafety int // Can't be reordered as we rely on iota for numbering. @@ -33,5 +38,9 @@ type MaintenanceSafety int //go:generate stringer -type=MaintenanceSafety -linecomment const ( FullMaintenanceSafety MaintenanceSafety = iota + //nolint:lll + // Use only if there's no other kopia instances accessing the repo and the + // storage backend is strongly consistent. + // https://github.com/kopia/kopia/blob/f9de453efc198b6e993af8922f953a7e5322dc5f/repo/maintenance/maintenance_safety.go#L42 NoMaintenanceSafety )