mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-09-26 18:05:34 +00:00
feat: unified scheduling model (#282)
This commit is contained in:
+7
-6
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
var migrations = []func(*v1.Config){
|
||||
migration001PrunePolicy,
|
||||
migration002Schedules,
|
||||
}
|
||||
|
||||
var CurrentVersion = int32(len(migrations))
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
})
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user