feat: unified scheduling model (#282)

This commit is contained in:
Gareth
2024-05-19 15:52:16 -07:00
committed by GitHub
parent df4be0f7bc
commit 531cd286d8
32 changed files with 1155 additions and 519 deletions
+7 -6
View File
@@ -19,6 +19,7 @@ import (
"github.com/garethgeorge/backrest/internal/api"
"github.com/garethgeorge/backrest/internal/auth"
"github.com/garethgeorge/backrest/internal/config"
"github.com/garethgeorge/backrest/internal/env"
"github.com/garethgeorge/backrest/internal/oplog"
"github.com/garethgeorge/backrest/internal/orchestrator"
"github.com/garethgeorge/backrest/internal/resticinstaller"
@@ -64,7 +65,7 @@ func main() {
var wg sync.WaitGroup
// Create / load the operation log
oplogFile := path.Join(config.DataDir(), "oplog.boltdb")
oplogFile := path.Join(env.DataDir(), "oplog.boltdb")
oplog, err := oplog.NewOpLog(oplogFile)
if err != nil {
if !errors.Is(err, bbolt.ErrTimeout) {
@@ -76,7 +77,7 @@ func main() {
defer oplog.Close()
// Create rotating log storage
logStore := rotatinglog.NewRotatingLog(path.Join(config.DataDir(), "rotatinglogs"), 14) // 14 days of logs
logStore := rotatinglog.NewRotatingLog(path.Join(env.DataDir(), "rotatinglogs"), 14) // 14 days of logs
if err != nil {
zap.S().Fatalf("error creating rotating log storage: %v", err)
}
@@ -112,7 +113,7 @@ func main() {
// Serve the HTTP gateway
server := &http.Server{
Addr: config.BindAddress(),
Addr: env.BindAddress(),
Handler: h2c.NewHandler(mux, &http2.Server{}), // h2c is HTTP/2 without TLS for grpc-connect support.
}
@@ -146,7 +147,7 @@ func init() {
func createConfigProvider() config.ConfigStore {
return &config.CachingValidatingStore{
ConfigStore: &config.JsonFileStore{Path: config.ConfigFilePath()},
ConfigStore: &config.JsonFileStore{Path: env.ConfigFilePath()},
}
}
@@ -160,7 +161,7 @@ func onterm(s os.Signal, callback func()) {
}
func getSecret() []byte {
secretFile := path.Join(config.DataDir(), "jwt-secret")
secretFile := path.Join(env.DataDir(), "jwt-secret")
data, err := os.ReadFile(secretFile)
if err == nil {
zap.L().Debug("loading auth secret from file")
@@ -172,7 +173,7 @@ func getSecret() []byte {
if n, err := rand.Read(secret); err != nil || n != 64 {
zap.S().Fatalf("error generating secret: %v", err)
}
if err := os.MkdirAll(config.DataDir(), 0700); err != nil {
if err := os.MkdirAll(env.DataDir(), 0700); err != nil {
zap.S().Fatalf("error creating data directory: %v", err)
}
if err := os.WriteFile(secretFile, secret, 0600); err != nil {
+3 -1
View File
@@ -35,12 +35,14 @@ Variables
- `SnapshotId:string` - the snapshot ID associated with the operation or empty string if none is associated.
- `SnapshotStats:restic.BackupProgressEntry` - summary of the current backup operation. This is a struct. See examples below for details.
- `CurTime:time.Time` - the current time. This is a struct. Format as `{{ .FormatTime .CurTime }}`.
- `Duration:time.Duration` - the duration of the triggering operation. Format as `{{ .FormatDuration .Duration }}`.
- `Error:string` - the error message if an error occurred, or empty string if successful.
Functions
- `.Summary` - prints a default summary of the current event.
- `.FormatTime <time>` - formats a time.Time object e.g. as `2024-02-08T03:00:37Z`
- `.FormatTime <time>` - formats a time.Time object e.g. as `2024-02-08T03:00:37Z`.
- `.FormatDuration <duration>` - formats a time.Duration object e.g. as `1h2m3s`.
- `.FormatSizeBytes <int>` - formats a number as a size in bytes (e.g. 5MB, 10GB, 30TB, etc...)
- `.ShellEscape <string>` - escapes a string to safely be used in most shell environments. Should not be relied upon as secure for arbitrary input.
- `.JsonMarshal <any>` - attempts to marshall any value as JSON. Can also be used with literals e.g. to quote a string with escapes i.e. `hello"world` -becomes `"hello\"world"`.
+472 -280
View File
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -332,19 +332,16 @@ func (s *BackrestHandler) Forget(ctx context.Context, req *connect.Request[v1.Fo
}
func (s *BackrestHandler) Prune(ctx context.Context, req *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) {
plan, err := s.orchestrator.GetPlan(req.Msg.Value)
if err != nil {
return nil, fmt.Errorf("failed to get plan %q: %w", req.Msg.Value, err)
}
at := time.Now()
var err error
wait := make(chan struct{})
s.orchestrator.ScheduleTask(tasks.NewOneoffPruneTask(plan.Repo, plan.Id, at, true), tasks.TaskPriorityInteractive+tasks.TaskPriorityPrune, func(e error) {
s.orchestrator.ScheduleTask(tasks.NewPruneTask(req.Msg.Value, tasks.PlanForSystemTasks, true), tasks.TaskPriorityInteractive+tasks.TaskPriorityPrune, func(e error) {
err = e
close(wait)
})
<-wait
if err != nil {
return nil, err
}
return connect.NewResponse(&emptypb.Empty{}), nil
}
@@ -561,7 +558,7 @@ func opSelectorToQuery(sel *v1.OpSelector) (oplog.Query, error) {
SnapshotId: sel.SnapshotId,
FlowId: sel.FlowId,
}
if len(sel.Ids) > 0 && reflect.DeepEqual(q, oplog.Query{}) {
if len(sel.Ids) > 0 && !reflect.DeepEqual(q, oplog.Query{}) {
return oplog.Query{}, errors.New("cannot specify both query and ids")
}
q.Ids = sel.Ids
+19 -10
View File
@@ -109,7 +109,9 @@ func TestBackup(t *testing.T) {
Paths: []string{
t.TempDir(),
},
Cron: "0 0 1 1 *",
Schedule: &v1.Schedule{
Schedule: &v1.Schedule_Disabled{Disabled: true},
},
Retention: &v1.RetentionPolicy{
KeepHourly: 1,
},
@@ -197,7 +199,9 @@ func TestMultipleBackup(t *testing.T) {
Paths: []string{
t.TempDir(),
},
Cron: "0 0 1 1 *",
Schedule: &v1.Schedule{
Schedule: &v1.Schedule_Disabled{Disabled: true},
},
Retention: &v1.RetentionPolicy{
Policy: &v1.RetentionPolicy_PolicyKeepLastN{
PolicyKeepLastN: 1,
@@ -266,7 +270,9 @@ func TestHookExecution(t *testing.T) {
Paths: []string{
t.TempDir(),
},
Cron: "0 0 1 1 *",
Schedule: &v1.Schedule{
Schedule: &v1.Schedule_Disabled{Disabled: true},
},
Hooks: []*v1.Hook{
{
Conditions: []v1.Hook_Condition{
@@ -351,9 +357,13 @@ func TestCancelBackup(t *testing.T) {
Paths: []string{
t.TempDir(),
},
Cron: "0 0 1 1 *",
Schedule: &v1.Schedule{
Schedule: &v1.Schedule_Disabled{Disabled: true},
},
Retention: &v1.RetentionPolicy{
KeepHourly: 1,
Policy: &v1.RetentionPolicy_PolicyKeepLastN{
PolicyKeepLastN: 1,
},
},
},
},
@@ -370,10 +380,7 @@ func TestCancelBackup(t *testing.T) {
var errgroup errgroup.Group
errgroup.Go(func() error {
backupReq := connect.NewRequest(&types.StringValue{Value: "test"})
_, err := sut.handler.Backup(context.Background(), backupReq)
if err != nil {
return fmt.Errorf("Backup() error = %v", err)
}
sut.handler.Backup(context.Background(), backupReq)
return nil
})
@@ -438,7 +445,9 @@ func TestRestore(t *testing.T) {
Paths: []string{
backupDataDir,
},
Cron: "0 0 1 1 *",
Schedule: &v1.Schedule{
Schedule: &v1.Schedule_Disabled{Disabled: true},
},
Retention: &v1.RetentionPolicy{
KeepHourly: 1,
},
+40 -2
View File
@@ -15,13 +15,24 @@ func TestConfig(t *testing.T) {
Id: "test-repo",
Uri: "/tmp/test",
Password: "test",
PrunePolicy: &v1.PrunePolicy{
Schedule: &v1.Schedule{
Schedule: &v1.Schedule_MaxFrequencyDays{
MaxFrequencyDays: 14,
},
},
},
}
testPlan := &v1.Plan{
Id: "test-plan",
Repo: "test-repo",
Paths: []string{"/tmp/foo"},
Cron: "* * * * *",
Schedule: &v1.Schedule{
Schedule: &v1.Schedule_Cron{
Cron: "0 0 * * *",
},
},
}
tests := []struct {
@@ -76,7 +87,11 @@ func TestConfig(t *testing.T) {
Id: "test-plan",
Repo: "test-repo",
Paths: []string{"/tmp/foo"},
Cron: "bad cron",
Schedule: &v1.Schedule{
Schedule: &v1.Schedule_Cron{
Cron: "bad cron",
},
},
},
},
},
@@ -84,6 +99,29 @@ func TestConfig(t *testing.T) {
wantErr: true,
wantErrContains: "invalid cron \"bad cron\"",
},
{
name: "plan with bad interval days",
config: &v1.Config{
Repos: []*v1.Repo{
testRepo,
},
Plans: []*v1.Plan{
{
Id: "test-plan",
Repo: "test-repo",
Paths: []string{"/tmp/foo"},
Schedule: &v1.Schedule{
Schedule: &v1.Schedule_MaxFrequencyDays{
MaxFrequencyDays: 0,
},
},
},
},
},
store: &CachingValidatingStore{ConfigStore: &JsonFileStore{Path: dir + "/invalid-config3.json"}},
wantErr: true,
wantErrContains: "invalid max frequency days",
},
}
for _, tc := range tests {
@@ -0,0 +1,42 @@
package migrations
import (
v1 "github.com/garethgeorge/backrest/gen/go/v1"
)
func migration002Schedules(config *v1.Config) {
// loop over plans and examine prune policy's
for _, repo := range config.Repos {
prunePolicy := repo.GetPrunePolicy()
if prunePolicy == nil {
continue
}
if prunePolicy.MaxFrequencyDays != 0 {
prunePolicy.Schedule = &v1.Schedule{
Schedule: &v1.Schedule_MaxFrequencyDays{
MaxFrequencyDays: prunePolicy.MaxFrequencyDays,
},
}
prunePolicy.MaxFrequencyDays = 0
}
}
// loop over plans and convert 'cron' and 'disabled' fields to schedule
for _, plan := range config.Plans {
if plan.Disabled {
plan.Schedule = &v1.Schedule{
Schedule: &v1.Schedule_Disabled{
Disabled: true,
},
}
} else if plan.Cron != "" {
plan.Schedule = &v1.Schedule{
Schedule: &v1.Schedule_Cron{
Cron: plan.Cron,
},
}
plan.Cron = ""
}
}
}
+1
View File
@@ -7,6 +7,7 @@ import (
var migrations = []func(*v1.Config){
migration001PrunePolicy,
migration002Schedules,
}
var CurrentVersion = int32(len(migrations))
+18 -6
View File
@@ -8,7 +8,7 @@ import (
v1 "github.com/garethgeorge/backrest/gen/go/v1"
"github.com/garethgeorge/backrest/internal/config/validationutil"
"github.com/gitploy-io/cronexpr"
"github.com/garethgeorge/backrest/internal/protoutil"
"github.com/hashicorp/go-multierror"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
@@ -29,7 +29,7 @@ func ValidateConfig(c *v1.Config) error {
if c.Repos != nil {
for _, repo := range c.Repos {
if e := validateRepo(repo); e != nil {
err = multierror.Append(e, fmt.Errorf("repo %s: %w", repo.GetId(), err))
err = multierror.Append(err, fmt.Errorf("repo %s: %w", repo.GetId(), e))
}
if _, ok := repos[repo.Id]; ok {
err = multierror.Append(err, fmt.Errorf("repo %s: duplicate id", repo.GetId()))
@@ -76,6 +76,18 @@ func validateRepo(repo *v1.Repo) error {
err = multierror.Append(err, errors.New("uri is required"))
}
if repo.PrunePolicy.GetSchedule() != nil {
if e := protoutil.ValidateSchedule(repo.PrunePolicy.GetSchedule()); e != nil {
err = multierror.Append(err, fmt.Errorf("prune policy schedule: %w", e))
}
}
if repo.CheckPolicy.GetSchedule() != nil {
if e := protoutil.ValidateSchedule(repo.CheckPolicy.GetSchedule()); e != nil {
err = multierror.Append(err, fmt.Errorf("check policy schedule: %w", e))
}
}
for _, env := range repo.Env {
if !strings.Contains(env, "=") {
err = multierror.Append(err, fmt.Errorf("invalid env var %s, must take format KEY=VALUE", env))
@@ -93,6 +105,10 @@ func validatePlan(plan *v1.Plan, repos map[string]*v1.Repo) error {
err = multierror.Append(err, fmt.Errorf("id %q invalid: %w", plan.Id, e))
}
if e := protoutil.ValidateSchedule(plan.Schedule); e != nil {
err = multierror.Append(err, fmt.Errorf("schedule: %w", e))
}
for idx, p := range plan.Paths {
if p == "" {
err = multierror.Append(err, fmt.Errorf("path[%d] cannot be empty", idx))
@@ -107,10 +123,6 @@ func validatePlan(plan *v1.Plan, repos map[string]*v1.Repo) error {
err = multierror.Append(err, fmt.Errorf("repo %q not found", plan.Repo))
}
if _, e := cronexpr.Parse(plan.Cron); e != nil {
err = multierror.Append(err, fmt.Errorf("invalid cron %q: %w", plan.Cron, e))
}
if plan.Retention != nil && plan.Retention.Policy == nil {
err = multierror.Append(err, errors.New("retention policy must be nil or must specify a policy"))
} else if policyTimeBucketed, ok := plan.Retention.GetPolicy().(*v1.RetentionPolicy_PolicyTimeBucketed); ok {
@@ -1,4 +1,4 @@
package config
package env
import (
"flag"
+5
View File
@@ -23,6 +23,7 @@ type HookVars struct {
SnapshotId string // the snapshot ID that triggered the hook.
SnapshotStats *restic.BackupProgressEntry // the summary of the backup operation.
CurTime time.Time // the current time as time.Time
Duration time.Duration // the duration of the operation that triggered the hook.
Error string // the error that caused the hook to run as a string.
}
@@ -45,6 +46,10 @@ func (v HookVars) FormatTime(t time.Time) string {
return t.Format(time.RFC3339)
}
func (v HookVars) FormatDuration(d time.Duration) string {
return d.String()
}
func (v HookVars) number(n any) int {
switch n := n.(type) {
case int:
+5 -2
View File
@@ -390,10 +390,13 @@ func (o *OpLog) ForEach(query Query, collector indexutil.Collector, do func(op *
if query.InstanceId != "" {
iterators = append(iterators, indexutil.IndexSearchByteValue(tx.Bucket(InstanceIndexBucket), []byte(query.InstanceId)))
}
if len(iterators) == 0 {
var ids []int64
if len(iterators) == 0 && len(query.Ids) == 0 {
return errors.New("no query parameters provided")
} else if len(iterators) > 0 {
ids = collector(indexutil.NewJoinIterator(iterators...))
}
ids := collector(indexutil.NewJoinIterator(iterators...))
if len(query.Ids) > 0 {
ids = append(ids, query.Ids...)
}
+17 -3
View File
@@ -152,6 +152,8 @@ func (o *Orchestrator) ScheduleDefaultTasks(config *v1.Config) error {
if plan.Disabled {
continue
}
// Schedule a backup task for the plan
t, err := tasks.NewScheduledBackupTask(plan)
if err != nil {
return fmt.Errorf("schedule backup task for plan %q: %w", plan.Id, err)
@@ -161,6 +163,14 @@ func (o *Orchestrator) ScheduleDefaultTasks(config *v1.Config) error {
}
}
for _, repo := range config.Repos {
// Schedule a prune task for the repo
t := tasks.NewPruneTask(repo.GetId(), tasks.PlanForSystemTasks, false)
if err := o.ScheduleTask(t, tasks.TaskPriorityDefault); err != nil {
return fmt.Errorf("schedule prune task for repo %q: %w", repo.GetId(), err)
}
}
return nil
}
@@ -346,13 +356,14 @@ func (o *Orchestrator) Run(ctx context.Context) {
}
o.mu.Lock()
if t.configModno == o.config.Modno {
curCfgModno := o.config.Modno
o.mu.Unlock()
if t.configModno == curCfgModno {
// Only reschedule tasks if the config hasn't changed since the task was scheduled.
if err := o.ScheduleTask(t.Task, tasks.TaskPriorityDefault); err != nil {
zap.L().Error("reschedule task", zap.String("task", t.Task.Name()), zap.Error(err))
}
}
o.mu.Unlock()
cancelTaskCtx()
go func() {
@@ -370,7 +381,10 @@ func (o *Orchestrator) ScheduleTask(t tasks.Task, priority int, callbacks ...fun
}
func (o *Orchestrator) scheduleTaskHelper(t tasks.Task, priority int, curTime time.Time, callbacks ...func(error)) error {
nextRun := t.Next(curTime, newTaskRunnerImpl(o, t, nil))
nextRun, err := t.Next(curTime, newTaskRunnerImpl(o, t, nil))
if err != nil {
return fmt.Errorf("finding run time for task %q: %w", t.Name(), err)
}
if nextRun.Eq(tasks.NeverScheduledTask) {
return nil
}
+3 -3
View File
@@ -29,15 +29,15 @@ func newTestTask(onRun func() error, onNext func(curTime time.Time) *time.Time)
onNext: onNext,
}
}
func (t *testTask) Next(curTime time.Time, runner tasks.TaskRunner) tasks.ScheduledTask {
func (t *testTask) Next(curTime time.Time, runner tasks.TaskRunner) (tasks.ScheduledTask, error) {
at := t.onNext(curTime)
if at == nil {
return tasks.NeverScheduledTask
return tasks.NeverScheduledTask, nil
}
return tasks.ScheduledTask{
Task: t,
RunAt: *at,
}
}, nil
}
func (t *testTask) Run(ctx context.Context, st tasks.ScheduledTask, runner tasks.TaskRunner) error {
+1 -1
View File
@@ -169,7 +169,7 @@ func TestEnvVarPropagation(t *testing.T) {
}
_, err = orchestrator.Backup(context.Background(), plan, nil)
if err == nil || !strings.Contains(err.Error(), "an empty password is not a password") {
if err == nil || !strings.Contains(err.Error(), "password") {
t.Fatalf("expected error about RESTIC_PASSWORD, got: %v", err)
}
+7
View File
@@ -1,6 +1,8 @@
package orchestrator
import (
"time"
v1 "github.com/garethgeorge/backrest/gen/go/v1"
"github.com/garethgeorge/backrest/internal/hook"
"github.com/garethgeorge/backrest/internal/oplog"
@@ -65,6 +67,11 @@ func (t *taskRunnerImpl) OpLog() *oplog.OpLog {
}
func (t *taskRunnerImpl) ExecuteHooks(events []v1.Hook_Condition, vars hook.HookVars) error {
vars.Task = t.t.Name()
if t.op != nil {
vars.Duration = time.Since(time.UnixMilli(t.op.UnixTimeStartMs))
}
repoID := t.t.RepoID()
planID := t.t.PlanID()
var repo *v1.Repo
+5 -4
View File
@@ -15,6 +15,7 @@ var NeverScheduledTask = ScheduledTask{}
const (
PlanForUnassociatedOperations = "_unassociated_"
PlanForSystemTasks = "_system_" // plan for system tasks e.g. garbage collection, prune, stats, etc.
TaskPriorityStats = -1
TaskPriorityDefault = 0
@@ -67,7 +68,7 @@ func (s ScheduledTask) Less(other ScheduledTask) bool {
// Task is a task that can be scheduled to run at a specific time.
type Task interface {
Name() string // human readable name for this task.
Next(now time.Time, runner TaskRunner) ScheduledTask // returns the next scheduled task.
Next(now time.Time, runner TaskRunner) (ScheduledTask, error) // returns the next scheduled task.
Run(ctx context.Context, st ScheduledTask, runner TaskRunner) error // run the task.
PlanID() string // the ID of the plan this task is associated with.
RepoID() string // the ID of the repo this task is associated with.
@@ -99,9 +100,9 @@ type OneoffTask struct {
ProtoOp *v1.Operation // the prototype operation for this class of task.
}
func (o *OneoffTask) Next(now time.Time, runner TaskRunner) ScheduledTask {
func (o *OneoffTask) Next(now time.Time, runner TaskRunner) (ScheduledTask, error) {
if o.DidSchedule {
return NeverScheduledTask
return NeverScheduledTask, nil
}
o.DidSchedule = true
@@ -118,7 +119,7 @@ func (o *OneoffTask) Next(now time.Time, runner TaskRunner) ScheduledTask {
return ScheduledTask{
RunAt: o.RunAt,
Op: op,
}
}, nil
}
type GenericOneoffTask struct {
+34 -30
View File
@@ -12,7 +12,6 @@ import (
"github.com/garethgeorge/backrest/internal/hook"
"github.com/garethgeorge/backrest/internal/protoutil"
"github.com/garethgeorge/backrest/pkg/restic"
"github.com/gitploy-io/cronexpr"
"go.uber.org/zap"
)
@@ -21,65 +20,70 @@ var maxBackupErrorHistoryLength = 20 // arbitrary limit on the number of file re
// BackupTask is a scheduled backup operation.
type BackupTask struct {
BaseTask
scheduler func(curTime time.Time) *time.Time
force bool
didRun bool
}
var _ Task = &BackupTask{}
func NewScheduledBackupTask(plan *v1.Plan) (*BackupTask, error) {
sched, err := cronexpr.ParseInLocation(plan.Cron, time.Now().Location().String())
if err != nil {
return nil, fmt.Errorf("failed to parse schedule %q: %w", plan.Cron, err)
}
return &BackupTask{
BaseTask: BaseTask{
TaskName: fmt.Sprintf("backup for plan %q", plan.Id),
TaskRepoID: plan.Repo,
TaskPlanID: plan.Id,
},
scheduler: func(curTime time.Time) *time.Time {
next := sched.Next(curTime)
return &next
},
}, nil
}
func NewOneoffBackupTask(plan *v1.Plan, at time.Time) *BackupTask {
didOnce := false
return &BackupTask{
BaseTask: BaseTask{
TaskName: fmt.Sprintf("backup for plan %q", plan.Id),
TaskRepoID: plan.Repo,
TaskPlanID: plan.Id,
},
scheduler: func(curTime time.Time) *time.Time {
if didOnce {
return nil
}
didOnce = true
return &at
},
force: true,
}
}
func (t *BackupTask) Next(now time.Time, runner TaskRunner) ScheduledTask {
next := t.scheduler(now)
if next == nil {
return NeverScheduledTask
func (t *BackupTask) Next(now time.Time, runner TaskRunner) (ScheduledTask, error) {
if t.force {
if t.didRun {
return NeverScheduledTask, nil
}
t.didRun = true
return ScheduledTask{
Task: t,
RunAt: now,
Op: &v1.Operation{
Op: &v1.Operation_OperationBackup{},
},
}, nil
}
plan, err := runner.GetPlan(t.PlanID())
if err != nil {
return NeverScheduledTask, err
}
if plan.Schedule == nil {
return NeverScheduledTask, nil
}
nextRun, err := protoutil.ResolveSchedule(plan.Schedule, now)
if errors.Is(err, protoutil.ErrScheduleDisabled) {
return NeverScheduledTask, nil
} else if err != nil {
return NeverScheduledTask, fmt.Errorf("resolving schedule: %w", err)
}
return ScheduledTask{
Task: t,
RunAt: *next,
RunAt: nextRun,
Op: &v1.Operation{
PlanId: t.PlanID(),
RepoId: t.RepoID(),
UnixTimeStartMs: (*next).UnixMilli(),
Status: v1.OperationStatus_STATUS_PENDING,
Op: &v1.Operation_OperationBackup{},
Op: &v1.Operation_OperationBackup{},
},
}
}, nil
}
func (t *BackupTask) Run(ctx context.Context, st ScheduledTask, runner TaskRunner) error {
@@ -38,21 +38,21 @@ func NewCollectGarbageTask() *CollectGarbageTask {
var _ Task = &CollectGarbageTask{}
func (t *CollectGarbageTask) Next(now time.Time, runner TaskRunner) ScheduledTask {
func (t *CollectGarbageTask) Next(now time.Time, runner TaskRunner) (ScheduledTask, error) {
if !t.firstRun {
t.firstRun = true
runAt := now.Add(gcStartupDelay)
return ScheduledTask{
Task: t,
RunAt: runAt,
}
}, nil
}
runAt := now.Add(gcInterval)
return ScheduledTask{
Task: t,
RunAt: runAt,
}
}, nil
}
func (t *CollectGarbageTask) Run(ctx context.Context, st ScheduledTask, runner TaskRunner) error {
@@ -115,12 +115,6 @@ func forgetHelper(ctx context.Context, st ScheduledTask, taskRunner TaskRunner)
}
}
if len(forgot) > 0 {
if err := taskRunner.ScheduleTask(NewOneoffPruneTask(t.RepoID(), t.PlanID(), time.Now(), false), TaskPriorityPrune); err != nil {
return fmt.Errorf("schedule prune task: %w", err)
}
}
return err
}
+43 -43
View File
@@ -3,6 +3,7 @@ package tasks
import (
"bytes"
"context"
"errors"
"fmt"
"sync"
"time"
@@ -12,78 +13,77 @@ import (
"github.com/garethgeorge/backrest/internal/ioutil"
"github.com/garethgeorge/backrest/internal/oplog"
"github.com/garethgeorge/backrest/internal/oplog/indexutil"
"github.com/garethgeorge/backrest/internal/protoutil"
"go.uber.org/zap"
)
type PruneTask struct {
BaseTask
OneoffTask
force bool
force bool
didRun bool
}
func NewOneoffPruneTask(repoID, planID string, at time.Time, force bool) Task {
func NewPruneTask(repoID, planID string, force bool) Task {
return &PruneTask{
BaseTask: BaseTask{
TaskName: fmt.Sprintf("prune for plan %q in repo %q", planID, repoID),
TaskName: fmt.Sprintf("prune repo %q", repoID),
TaskRepoID: repoID,
TaskPlanID: planID,
},
OneoffTask: OneoffTask{
RunAt: at,
ProtoOp: &v1.Operation{
Op: &v1.Operation_OperationPrune{},
},
},
force: force,
}
}
func (t *PruneTask) Next(now time.Time, runner TaskRunner) ScheduledTask {
func (t *PruneTask) Next(now time.Time, runner TaskRunner) (ScheduledTask, error) {
if t.force {
return t.OneoffTask.Next(now, runner)
if t.didRun {
return NeverScheduledTask, nil
}
t.didRun = true
return ScheduledTask{
Task: t,
RunAt: now,
Op: &v1.Operation{
Op: &v1.Operation_OperationPrune{},
},
}, nil
}
shouldRun, err := t.shouldRun(now, runner)
if err != nil {
zap.S().Errorf("task %v failed to check if it should run: %v", t.Name(), err)
return NeverScheduledTask
}
if !shouldRun {
return NeverScheduledTask
}
return t.OneoffTask.Next(now, runner)
}
func (t *PruneTask) shouldRun(now time.Time, runner TaskRunner) (bool, error) {
repo, err := runner.GetRepo(t.RepoID())
if err != nil {
return false, fmt.Errorf("get repo %v: %w", t.RepoID(), err)
return ScheduledTask{}, fmt.Errorf("get repo %v: %w", t.RepoID(), err)
}
nextPruneTime, err := t.getNextPruneTime(runner, repo.PrunePolicy)
if err != nil {
return false, fmt.Errorf("get next prune time: %w", err)
if repo.PrunePolicy.GetSchedule() == nil {
return NeverScheduledTask, nil
}
return nextPruneTime.Before(now), nil
}
func (t *PruneTask) getNextPruneTime(runner TaskRunner, policy *v1.PrunePolicy) (time.Time, error) {
var lastPruneTime time.Time
runner.OpLog().ForEach(oplog.Query{RepoId: t.RepoID()}, indexutil.Reversed(indexutil.CollectAll()), func(op *v1.Operation) error {
var lastRan time.Time
if err := runner.OpLog().ForEach(oplog.Query{RepoId: t.RepoID()}, indexutil.Reversed(indexutil.CollectAll()), func(op *v1.Operation) error {
if _, ok := op.Op.(*v1.Operation_OperationPrune); ok {
lastPruneTime = time.Unix(0, op.UnixTimeStartMs*int64(time.Millisecond))
lastRan = time.Unix(0, op.UnixTimeEndMs*int64(time.Millisecond))
return oplog.ErrStopIteration
}
return nil
})
if policy != nil {
return lastPruneTime.Add(time.Duration(policy.MaxFrequencyDays) * 24 * time.Hour), nil
} else {
return lastPruneTime.Add(7 * 24 * time.Hour), nil // default to 7 days.
}); err != nil {
return NeverScheduledTask, fmt.Errorf("finding last backup run time: %w", err)
}
zap.L().Debug("last prune time", zap.Time("time", lastRan), zap.String("repo", t.RepoID()))
runAt, err := protoutil.ResolveSchedule(repo.PrunePolicy.GetSchedule(), lastRan)
if errors.Is(err, protoutil.ErrScheduleDisabled) {
return NeverScheduledTask, nil
} else if err != nil {
return NeverScheduledTask, fmt.Errorf("resolve schedule: %w", err)
}
return ScheduledTask{
Task: t,
RunAt: runAt,
Op: &v1.Operation{
Op: &v1.Operation_OperationPrune{},
},
}, nil
}
func (t *PruneTask) Run(ctx context.Context, st ScheduledTask, runner TaskRunner) error {
+61
View File
@@ -0,0 +1,61 @@
package protoutil
import (
"errors"
"fmt"
"time"
v1 "github.com/garethgeorge/backrest/gen/go/v1"
"github.com/gitploy-io/cronexpr"
)
var ErrScheduleDisabled = errors.New("never")
// ResolveSchedule resolves a schedule to the next time it should run based on last execution.
// note that this is different from backup behavior which is always relative to the current time.
func ResolveSchedule(sched *v1.Schedule, lastRan time.Time) (time.Time, error) {
switch s := sched.GetSchedule().(type) {
case *v1.Schedule_Disabled:
return time.Time{}, ErrScheduleDisabled
case *v1.Schedule_MaxFrequencyDays:
return lastRan.Add(time.Duration(s.MaxFrequencyDays) * 24 * time.Hour), nil
case *v1.Schedule_MaxFrequencyHours:
return lastRan.Add(time.Duration(s.MaxFrequencyHours) * time.Hour), nil
case *v1.Schedule_Cron:
cron, err := cronexpr.ParseInLocation(s.Cron, time.Now().Location().String())
if err != nil {
return time.Time{}, fmt.Errorf("parse cron %q: %w", s.Cron, err)
}
return cron.Next(lastRan), nil
default:
return time.Time{}, fmt.Errorf("unknown schedule type: %T", s)
}
}
func ValidateSchedule(sched *v1.Schedule) error {
switch s := sched.GetSchedule().(type) {
case *v1.Schedule_MaxFrequencyDays:
if s.MaxFrequencyDays < 1 {
return errors.New("invalid max frequency days")
}
case *v1.Schedule_MaxFrequencyHours:
if s.MaxFrequencyHours < 1 {
return errors.New("invalid max frequency hours")
}
case *v1.Schedule_Cron:
if s.Cron == "" {
return errors.New("empty cron expression")
}
_, err := cronexpr.ParseInLocation(s.Cron, time.Now().Location().String())
if err != nil {
return fmt.Errorf("invalid cron %q: %w", s.Cron, err)
}
case *v1.Schedule_Disabled:
if !s.Disabled {
return errors.New("disabled boolean must be set to true")
}
default:
return fmt.Errorf("unknown schedule type: %T", s)
}
return nil
}
+4 -4
View File
@@ -17,7 +17,7 @@ import (
"strings"
"sync"
"github.com/garethgeorge/backrest/internal/config"
"github.com/garethgeorge/backrest/internal/env"
"go.uber.org/zap"
)
@@ -168,7 +168,7 @@ func downloadFile(url string, downloadPath string) (string, error) {
func installResticIfNotExists(resticInstallPath string) error {
// withFlock is used to ensure tests pass; when running on CI multiple tests may try to install restic at the same time.
return withFlock(path.Join(config.DataDir(), "install.lock"), func() error {
return withFlock(path.Join(env.DataDir(), "install.lock"), func() error {
if _, err := os.Stat(resticInstallPath); err == nil {
// file is now installed, probably by another process. We can return.
return nil
@@ -224,7 +224,7 @@ func FindOrInstallResticBinary() (string, error) {
defer findResticMu.Unlock()
// Check if restic is provided.
resticBin := config.ResticBinPath()
resticBin := env.ResticBinPath()
if resticBin != "" {
if _, err := os.Stat(resticBin); err != nil {
if !errors.Is(err, os.ErrNotExist) {
@@ -245,7 +245,7 @@ func FindOrInstallResticBinary() (string, error) {
}
// Check for restic installation in data directory.
resticInstallPath := path.Join(config.DataDir(), resticBinName)
resticInstallPath := path.Join(env.DataDir(), resticBinName)
if runtime.GOOS == "windows" {
programFiles := os.Getenv("programfiles")
resticInstallPath = path.Join(programFiles, "backrest", resticBinName)
+21 -7
View File
@@ -38,6 +38,7 @@ message Repo {
repeated string env = 4 [json_name="env"]; // extra environment variables to set for restic.
repeated string flags = 5 [json_name="flags"]; // extra flags set on the restic command.
PrunePolicy prune_policy = 6 [json_name="prunePolicy"]; // policy for when to run prune.
CheckPolicy check_policy = 9 [json_name="checkPolicy"]; // policy for when to run check.
repeated Hook hooks = 7 [json_name="hooks"]; // hooks to run on events for this repo.
bool auto_unlock = 8 [json_name="autoUnlock"]; // automatically unlock the repo when needed.
}
@@ -45,11 +46,12 @@ message Repo {
message Plan {
string id = 1 [json_name="id"]; // unique but human readable ID for this plan.
string repo = 2 [json_name="repo"]; // ID of the repo to use.
bool disabled = 11 [json_name="disabled"];
bool disabled = 11 [json_name="disabled", deprecated=true]; // disable the plan.
repeated string paths = 4 [json_name="paths"]; // paths to include in the backup.
repeated string excludes = 5 [json_name="excludes"]; // glob patterns to exclude.
repeated string iexcludes = 9 [json_name="iexcludes"]; // case insensitive glob patterns to exclude.
string cron = 6 [json_name="cron"]; // cron expression describing the backup schedule.
string cron = 6 [json_name="cron", deprecated=true]; // cron expression describing the backup schedule.
Schedule schedule = 12 [json_name="schedule"]; // schedule for the backup.
RetentionPolicy retention = 7 [json_name="retention"]; // retention policy for snapshots.
repeated Hook hooks = 8 [json_name="hooks"]; // hooks to run on events for this plan.
repeated string backup_flags = 10 [json_name="backup_flags"]; // extra flags to set when running a backup command.
@@ -82,14 +84,26 @@ message RetentionPolicy {
}
message PrunePolicy {
int32 max_frequency_days = 1 [json_name="maxFrequencyDays"]; // max frequency of prune runs in days. If 0, prune will be run on every backup.
int32 max_unused_percent = 100 [json_name="maxUnusedPercent"]; // max percentage of repo size that can be unused before prune is run.
int32 max_unused_bytes = 101 [json_name="maxUnusedBytes"]; // max number of bytes that can be unused before prune is run.
int32 max_frequency_days = 1 [json_name="maxFrequencyDays", deprecated = true]; // max frequency of prune runs in days.
Schedule schedule = 2 [json_name="schedule"];
int32 max_unused_bytes = 3 [json_name="maxUnusedBytes"]; // max unused bytes before running prune.
int32 max_unused_percent = 4 [json_name="maxUnusedPercent"]; // max unused percent before running prune.
}
message CheckPolicy {
oneof policy {
int32 max_frequency_days = 1 [json_name="maxFrequencyDays"];
Schedule schedule = 1 [json_name="schedule"];
oneof read_policy {
int32 read_percent = 11 [json_name="readPercent"]; // check a percentage of snapshots.
}
}
message Schedule {
oneof schedule {
bool disabled = 1 [json_name="disabled"]; // disable the schedule.
string cron = 2 [json_name="cron"]; // cron expression describing the schedule.
int32 maxFrequencyDays = 3 [json_name="maxFrequencyDays"]; // max frequency of runs in days.
int32 maxFrequencyHours = 4 [json_name="maxFrequencyHours"]; // max frequency of runs in hours.
}
}
+124 -17
View File
@@ -210,6 +210,13 @@ export class Repo extends Message<Repo> {
*/
prunePolicy?: PrunePolicy;
/**
* policy for when to run check.
*
* @generated from field: v1.CheckPolicy check_policy = 9;
*/
checkPolicy?: CheckPolicy;
/**
* hooks to run on events for this repo.
*
@@ -238,6 +245,7 @@ export class Repo extends Message<Repo> {
{ no: 4, name: "env", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
{ no: 5, name: "flags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
{ no: 6, name: "prune_policy", kind: "message", T: PrunePolicy },
{ no: 9, name: "check_policy", kind: "message", T: CheckPolicy },
{ no: 7, name: "hooks", kind: "message", T: Hook, repeated: true },
{ no: 8, name: "auto_unlock", kind: "scalar", T: 8 /* ScalarType.BOOL */ },
]);
@@ -278,7 +286,10 @@ export class Plan extends Message<Plan> {
repo = "";
/**
* @generated from field: bool disabled = 11;
* disable the plan.
*
* @generated from field: bool disabled = 11 [deprecated = true];
* @deprecated
*/
disabled = false;
@@ -306,10 +317,18 @@ export class Plan extends Message<Plan> {
/**
* cron expression describing the backup schedule.
*
* @generated from field: string cron = 6;
* @generated from field: string cron = 6 [deprecated = true];
* @deprecated
*/
cron = "";
/**
* schedule for the backup.
*
* @generated from field: v1.Schedule schedule = 12;
*/
schedule?: Schedule;
/**
* retention policy for snapshots.
*
@@ -346,6 +365,7 @@ export class Plan extends Message<Plan> {
{ no: 5, name: "excludes", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
{ no: 9, name: "iexcludes", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
{ no: 6, name: "cron", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 12, name: "schedule", kind: "message", T: Schedule },
{ no: 7, name: "retention", kind: "message", T: RetentionPolicy },
{ no: 8, name: "hooks", kind: "message", T: Hook, repeated: true },
{ no: 10, name: "backup_flags", jsonName: "backup_flags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
@@ -559,26 +579,32 @@ export class RetentionPolicy_TimeBucketedCounts extends Message<RetentionPolicy_
*/
export class PrunePolicy extends Message<PrunePolicy> {
/**
* max frequency of prune runs in days. If 0, prune will be run on every backup.
* max frequency of prune runs in days.
*
* @generated from field: int32 max_frequency_days = 1;
* @generated from field: int32 max_frequency_days = 1 [deprecated = true];
* @deprecated
*/
maxFrequencyDays = 0;
/**
* max percentage of repo size that can be unused before prune is run.
*
* @generated from field: int32 max_unused_percent = 100;
* @generated from field: v1.Schedule schedule = 2;
*/
maxUnusedPercent = 0;
schedule?: Schedule;
/**
* max number of bytes that can be unused before prune is run.
* max unused bytes before running prune.
*
* @generated from field: int32 max_unused_bytes = 101;
* @generated from field: int32 max_unused_bytes = 3;
*/
maxUnusedBytes = 0;
/**
* max unused percent before running prune.
*
* @generated from field: int32 max_unused_percent = 4;
*/
maxUnusedPercent = 0;
constructor(data?: PartialMessage<PrunePolicy>) {
super();
proto3.util.initPartial(data, this);
@@ -588,8 +614,9 @@ export class PrunePolicy extends Message<PrunePolicy> {
static readonly typeName = "v1.PrunePolicy";
static readonly fields: FieldList = proto3.util.newFieldList(() => [
{ no: 1, name: "max_frequency_days", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 100, name: "max_unused_percent", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 101, name: "max_unused_bytes", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 2, name: "schedule", kind: "message", T: Schedule },
{ no: 3, name: "max_unused_bytes", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 4, name: "max_unused_percent", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
]);
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): PrunePolicy {
@@ -614,14 +641,21 @@ export class PrunePolicy extends Message<PrunePolicy> {
*/
export class CheckPolicy extends Message<CheckPolicy> {
/**
* @generated from oneof v1.CheckPolicy.policy
* @generated from field: v1.Schedule schedule = 1;
*/
policy: {
schedule?: Schedule;
/**
* @generated from oneof v1.CheckPolicy.read_policy
*/
readPolicy: {
/**
* @generated from field: int32 max_frequency_days = 1;
* check a percentage of snapshots.
*
* @generated from field: int32 read_percent = 11;
*/
value: number;
case: "maxFrequencyDays";
case: "readPercent";
} | { case: undefined; value?: undefined } = { case: undefined };
constructor(data?: PartialMessage<CheckPolicy>) {
@@ -632,7 +666,8 @@ export class CheckPolicy extends Message<CheckPolicy> {
static readonly runtime: typeof proto3 = proto3;
static readonly typeName = "v1.CheckPolicy";
static readonly fields: FieldList = proto3.util.newFieldList(() => [
{ no: 1, name: "max_frequency_days", kind: "scalar", T: 5 /* ScalarType.INT32 */, oneof: "policy" },
{ no: 1, name: "schedule", kind: "message", T: Schedule },
{ no: 11, name: "read_percent", kind: "scalar", T: 5 /* ScalarType.INT32 */, oneof: "read_policy" },
]);
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CheckPolicy {
@@ -652,6 +687,78 @@ export class CheckPolicy extends Message<CheckPolicy> {
}
}
/**
* @generated from message v1.Schedule
*/
export class Schedule extends Message<Schedule> {
/**
* @generated from oneof v1.Schedule.schedule
*/
schedule: {
/**
* disable the schedule.
*
* @generated from field: bool disabled = 1;
*/
value: boolean;
case: "disabled";
} | {
/**
* cron expression describing the schedule.
*
* @generated from field: string cron = 2;
*/
value: string;
case: "cron";
} | {
/**
* max frequency of runs in days.
*
* @generated from field: int32 maxFrequencyDays = 3;
*/
value: number;
case: "maxFrequencyDays";
} | {
/**
* max frequency of runs in hours.
*
* @generated from field: int32 maxFrequencyHours = 4;
*/
value: number;
case: "maxFrequencyHours";
} | { case: undefined; value?: undefined } = { case: undefined };
constructor(data?: PartialMessage<Schedule>) {
super();
proto3.util.initPartial(data, this);
}
static readonly runtime: typeof proto3 = proto3;
static readonly typeName = "v1.Schedule";
static readonly fields: FieldList = proto3.util.newFieldList(() => [
{ no: 1, name: "disabled", kind: "scalar", T: 8 /* ScalarType.BOOL */, oneof: "schedule" },
{ no: 2, name: "cron", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "schedule" },
{ no: 3, name: "maxFrequencyDays", kind: "scalar", T: 5 /* ScalarType.INT32 */, oneof: "schedule" },
{ no: 4, name: "maxFrequencyHours", kind: "scalar", T: 5 /* ScalarType.INT32 */, oneof: "schedule" },
]);
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Schedule {
return new Schedule().fromBinary(bytes, options);
}
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Schedule {
return new Schedule().fromJson(jsonValue, options);
}
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Schedule {
return new Schedule().fromJsonString(jsonString, options);
}
static equals(a: Schedule | PlainMessage<Schedule> | undefined, b: Schedule | PlainMessage<Schedule> | undefined): boolean {
return proto3.util.equals(Schedule, a, b);
}
}
/**
* @generated from message v1.Hook
*/
+1
View File
@@ -3,6 +3,7 @@ import { createConnectTransport } from "@connectrpc/connect-web";
import { createPromiseClient } from "@connectrpc/connect";
import { Backrest } from "../gen/ts/v1/service_connect";
import { Authentication } from "../gen/ts/v1/authentication_connect";
import { Schedule } from "../gen/ts/v1/config_pb";
const tokenKey = "backrest-ui-authToken";
+4 -1
View File
@@ -38,6 +38,7 @@ import {
ClearHistoryRequest,
ForgetRequest,
GetOperationsRequest,
OpSelector,
} from "../../gen/ts/v1/service_pb";
import { isMobile } from "../lib/browserutil";
import { useShowModal } from "./ModalManager";
@@ -342,7 +343,9 @@ const BackupView = ({ backup }: { backup?: BackupInfo }) => {
onClickAsync={async () => {
backrestService.clearHistory(
new ClearHistoryRequest({
ops: backup.operations.map((op) => op.id),
selector: new OpSelector({
ids: backup.operations.map((op) => op.id),
}),
})
);
}}
+161
View File
@@ -0,0 +1,161 @@
import {
Checkbox,
Col,
Form,
InputNumber,
Radio,
Row,
Segmented,
Tooltip,
} from "antd";
import { NamePath } from "antd/es/form/interface";
import React from "react";
import Cron from "react-js-cron";
export const ScheduleFormItem = ({ name }: { name: string[] }) => {
const form = Form.useFormInstance();
const retention = Form.useWatch(name, { form, preserve: true }) as any;
const determineMode = () => {
if (!retention || retention.disabled) {
return "disabled";
} else if (retention.maxFrequencyDays) {
return "maxFrequencyDays";
} else if (retention.maxFrequencyHours) {
return "maxFrequencyHours";
} else if (retention.cron) {
return "cron";
}
};
const mode = determineMode();
let elem: React.ReactNode = null;
if (mode === "cron") {
elem = (
<Form.Item
name={name.concat(["cron"])}
initialValue={"0 * * * *"}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
message: "Please provide a valid cron schedule.",
},
]}
>
<Cron
value={form.getFieldValue(name.concat(["cron"]))}
setValue={(val: string) => {
form.setFieldValue(name.concat(["cron"]), val);
}}
allowedDropdowns={[
"period",
"months",
"month-days",
"hours",
"minutes",
]}
allowedPeriods={["day", "hour", "month"]}
clearButton={false}
/>
</Form.Item>
);
} else if (mode === "maxFrequencyDays") {
elem = (
<Form.Item
name={name.concat(["maxFrequencyDays"])}
initialValue={0}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
message: "Please input an interval in days",
},
]}
>
<InputNumber
addonBefore={<div style={{ width: "10em" }}>Interval in Days</div>}
type="number"
/>
</Form.Item>
);
} else if (mode === "maxFrequencyHours") {
elem = (
<Form.Item
name={name.concat(["maxFrequencyHours"])}
initialValue={0}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
message: "Please input an interval in hours",
},
]}
>
<InputNumber
addonBefore={<div style={{ width: "10em" }}>Interval in Hours</div>}
type="number"
/>
</Form.Item>
);
} else if (mode === "disabled") {
elem = (
<Form.Item
name={name.concat(["disabled"])}
valuePropName="checked"
initialValue={true}
hidden={true}
>
<Checkbox />
</Form.Item>
);
}
return (
<>
<Row>
<Radio.Group
value={mode}
onChange={(e) => {
const selected = e.target.value;
if (selected === "maxFrequencyDays") {
form.setFieldValue(name, { maxFrequencyDays: 1 });
} else if (selected === "maxFrequencyHours") {
form.setFieldValue(name, { maxFrequencyHours: 1 });
} else if (selected === "cron") {
form.setFieldValue(name, { cron: "0 * * * *" });
} else {
form.setFieldValue(name, { disabled: true });
}
}}
>
<Radio.Button value={"disabled"}>
<Tooltip title="Schedule is disabled, will never run.">
Disabled
</Tooltip>
</Radio.Button>
<Radio.Button value={"maxFrequencyHours"}>
<Tooltip title="Schedule will run at the specified interval in hours (e.g. N hours after the last run).">
Max Frequency Hours
</Tooltip>
</Radio.Button>
<Radio.Button value={"maxFrequencyDays"}>
<Tooltip title="Schedule will run at the specified interval in days (e.g. N days after the last run).">
Max Frequency Days
</Tooltip>
</Radio.Button>
<Radio.Button value={"cron"}>
<Tooltip title="Schedule will run based on a cron schedule.">
Cron
</Tooltip>
</Radio.Button>
</Radio.Group>
</Row>
<div style={{ height: "0.5em" }} />
<Row>
<Form.Item>{elem}</Form.Item>
</Row>
</>
);
};
+5 -40
View File
@@ -30,6 +30,7 @@ import {
import { ConfirmButton, SpinButton } from "../components/SpinButton";
import { useConfig } from "../components/ConfigProvider";
import { backrestService } from "../api";
import { ScheduleFormItem } from "../components/ScheduleFormItem";
export const AddPlanModal = ({ template }: { template: Plan | null }) => {
const [confirmLoading, setConfirmLoading] = useState(false);
@@ -123,7 +124,7 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
open={true}
onCancel={handleCancel}
title={template ? "Update Plan" : "Add Plan"}
width="40vw"
width="60vw"
footer={[
<Button loading={confirmLoading} key="back" onClick={handleCancel}>
Cancel
@@ -394,28 +395,9 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
</Tooltip>
{/* Plan.cron */}
<Tooltip title="Cron expression to schedule the plan in 24 hour time">
<Form.Item<Plan>
name="cron"
label="Schedule"
initialValue={template ? template.cron : "0 0 * * *"}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
message: "Please input schedule",
},
]}
>
<Cron
value={form.getFieldValue("cron")}
setValue={(val: string) => {
form.setFieldValue("cron", val);
}}
clearButton={false}
/>
</Form.Item>
</Tooltip>
<Form.Item label="Backup Schedule">
<ScheduleFormItem name={["schedule"]} />
</Form.Item>
{/* Plan.backup_flags */}
<Form.Item
@@ -479,23 +461,6 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
<HooksFormList />
</Form.Item>
{/* Disabled? toggles whether the plan will be scheduled. */}
<Form.Item
label={
<Tooltip
title={
"Toggles whether the plan's scheduling is enabled. If disabled no scheduled operations will be run."
}
>
Disable Scheduling
</Tooltip>
}
name="disabled"
valuePropName="checked"
>
<Checkbox />
</Form.Item>
<Form.Item shouldUpdate label="Preview">
{() => (
<Collapse
+15 -24
View File
@@ -29,6 +29,8 @@ import {
} from "../components/HooksFormList";
import { ConfirmButton } from "../components/SpinButton";
import { useConfig } from "../components/ConfigProvider";
import Cron from "react-js-cron";
import { ScheduleFormItem } from "../components/ScheduleFormItem";
export const AddRepoModal = ({ template }: { template: Repo | null }) => {
const [confirmLoading, setConfirmLoading] = useState(false);
@@ -76,7 +78,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
"Deleted repo " +
template.id +
" from config but files remain. To release storage delete the files manually. URI: " +
template.uri,
template.uri
);
} catch (e: any) {
alertsApi.error("Operation failed: " + e.message, 15);
@@ -132,7 +134,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
open={true}
onCancel={handleCancel}
title={template ? "Edit Restic Repository" : "Add Restic Repository"}
width="40vw"
width="60vw"
footer={[
<Button loading={confirmLoading} key="back" onClick={handleCancel}>
Cancel
@@ -161,8 +163,8 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
<Form
autoComplete="off"
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}
labelCol={{ span: 4 }}
wrapperCol={{ span: 18 }}
disabled={confirmLoading}
>
{/* Repo.id */}
@@ -412,7 +414,6 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
{/* Repo.prunePolicy */}
<Form.Item
required={false}
label={
<Tooltip
title={
@@ -433,17 +434,6 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
</Tooltip>
}
>
<Form.Item
name={["prunePolicy", "maxFrequencyDays"]}
initialValue={7}
required={false}
>
<InputNumber
addonBefore={
<div style={{ width: "12em" }}>Max Frequency Days</div>
}
/>
</Form.Item>
<Form.Item
name={["prunePolicy", "maxUnusedPercent"]}
initialValue={25}
@@ -452,11 +442,12 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
<InputNumber
addonBefore={
<Tooltip title="The maximum percentage of the repo size that may be unused after a prune operation completes. High values reduce copying at the expense of storage.">
<div style={{ width: "12em" }}>Max Unused Percent</div>
<div style={{ width: "12" }}>Max Unused % After Prune</div>
</Tooltip>
}
/>
</Form.Item>
<ScheduleFormItem name={["prunePolicy", "schedule"]} />
</Form.Item>
<Form.Item
@@ -523,7 +514,7 @@ const expectedEnvVars: { [scheme: string]: string[][] } = {
const envVarSetValidator = (
form: FormInstance<FormData>,
envVars: string[],
envVars: string[]
) => {
if (!envVars) {
return Promise.resolve();
@@ -555,8 +546,8 @@ const envVarSetValidator = (
) {
return Promise.reject(
new Error(
"Missing repo password. Either provide a password or set one of the env variables RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND, RESTIC_PASSWORD_FILE.",
),
"Missing repo password. Either provide a password or set one of the env variables RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND, RESTIC_PASSWORD_FILE."
)
);
}
@@ -579,7 +570,7 @@ const cryptoRandomPassword = (): string => {
const checkSchemeEnvVars = (
scheme: string,
envVarNames: string[],
envVarNames: string[]
): Promise<void> => {
let expected = expectedEnvVars[scheme];
if (!expected) {
@@ -590,7 +581,7 @@ const checkSchemeEnvVars = (
for (let possibility of expected) {
const missingVars = possibility.filter(
(envVar) => !envVarNames.includes(envVar),
(envVar) => !envVarNames.includes(envVar)
);
// If no env vars are missing, we have a full match and are good
@@ -614,8 +605,8 @@ const checkSchemeEnvVars = (
"Missing env vars " +
formatMissingEnvVars(missingVarsCollection) +
" for scheme " +
scheme,
),
scheme
)
);
};
-14
View File
@@ -24,15 +24,6 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
}
};
const handlePruneNow = async () => {
try {
await backrestService.prune({ value: plan.id });
alertsApi.success("Prune scheduled.");
} catch (e: any) {
alertsApi.error("Failed to schedule prune: " + e.message);
}
};
const handleUnlockNow = async () => {
try {
alertsApi.info("Unlocking repo...");
@@ -76,11 +67,6 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
Run Command
</Button>
</Tooltip>
<Tooltip title="Runs a prune operation on the repository that will remove old snapshots and free up space">
<SpinButton type="default" onClickAsync={handlePruneNow}>
Prune Now
</SpinButton>
</Tooltip>
<Tooltip title="Removes lockfiles and checks the repository for errors. Only run if you are sure the repo is not being accessed by another system">
<SpinButton type="default" onClickAsync={handleUnlockNow}>
Unlock Repo
+27 -2
View File
@@ -39,14 +39,33 @@ import { useShowModal } from "../components/ModalManager";
export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
const [config, setConfig] = useConfig();
const showModal = useShowModal();
const alertsApi = useAlertApi()!;
// Task handlers
const handleIndexNow = async () => {
await backrestService.indexSnapshots(new StringValue({ value: repo.id! }));
try {
await backrestService.indexSnapshots(
new StringValue({ value: repo.id! })
);
} catch (e: any) {
alertsApi.error("Failed to index snapshots: " + e.message);
}
};
const handleStatsNow = async () => {
await backrestService.stats(new StringValue({ value: repo.id! }));
try {
await backrestService.stats(new StringValue({ value: repo.id! }));
} catch (e: any) {
alertsApi.error("Failed to compute stats: " + e.message);
}
};
const handlePruneNow = async () => {
try {
await backrestService.prune({ value: repo.id });
} catch (e: any) {
alertsApi.error("Failed to prune: " + e.message);
}
};
// Gracefully handle deletions by checking if the plan is still in the config.
@@ -139,6 +158,12 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
</SpinButton>
</Tooltip>
<Tooltip title="Runs a prune operation on the repository that will remove old snapshots and free up space">
<SpinButton type="default" onClickAsync={handlePruneNow}>
Prune Now
</SpinButton>
</Tooltip>
<Tooltip title="Runs restic stats on the repository, this may be a slow operation">
<SpinButton type="default" onClickAsync={handleStatsNow}>
Compute Stats