Files
backrest/internal/api/backresthandler.go
T
garethgeorge 03e69c98dd
Release Please / release-please (push) Has been cancelled
Release Preview / call-reusable-release (push) Has been cancelled
Test / test-nix (push) Has been cancelled
Test / test-win (push) Has been cancelled
fix: batch of webui fixes, update webui deps, apply formatter, and regen protos
2026-07-08 01:04:30 -07:00

882 lines
29 KiB
Go

package api
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"connectrpc.com/connect"
"github.com/garethgeorge/backrest/gen/go/types"
v1 "github.com/garethgeorge/backrest/gen/go/v1"
"github.com/garethgeorge/backrest/gen/go/v1/v1connect"
"github.com/garethgeorge/backrest/internal/api/sftputil"
syncapi "github.com/garethgeorge/backrest/internal/api/syncapi"
"github.com/garethgeorge/backrest/internal/config"
"github.com/garethgeorge/backrest/internal/cryptoutil"
"github.com/garethgeorge/backrest/internal/env"
"github.com/garethgeorge/backrest/internal/logstore"
"github.com/garethgeorge/backrest/internal/oplog"
"github.com/garethgeorge/backrest/internal/orchestrator"
"github.com/garethgeorge/backrest/internal/orchestrator/repo"
"github.com/garethgeorge/backrest/internal/orchestrator/tasks"
"github.com/garethgeorge/backrest/internal/protoutil"
"github.com/garethgeorge/backrest/internal/resticinstaller"
"github.com/garethgeorge/backrest/pkg/restic"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/emptypb"
)
type BackrestHandler struct {
v1connect.UnimplementedBackrestHandler
config config.ConfigStore
orchestrator *orchestrator.Orchestrator
oplog *oplog.OpLog
logStore *logstore.LogStore
peerStateManager syncapi.PeerStateManager
}
var _ v1connect.BackrestHandler = &BackrestHandler{}
func NewBackrestHandler(config config.ConfigStore, peerStateManager syncapi.PeerStateManager, orchestrator *orchestrator.Orchestrator, oplog *oplog.OpLog, logStore *logstore.LogStore) *BackrestHandler {
s := &BackrestHandler{
config: config,
orchestrator: orchestrator,
oplog: oplog,
logStore: logStore,
peerStateManager: peerStateManager,
}
return s
}
// GetConfig implements GET /v1/config
func (s *BackrestHandler) GetConfig(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[v1.Config], error) {
c, err := s.config.Get()
if err != nil {
return nil, fmt.Errorf("failed to get config: %w", err)
}
return connect.NewResponse(config.SanitizeForNetwork(c)), nil
}
// SetConfig implements POST /v1/config
func (s *BackrestHandler) SetConfig(ctx context.Context, req *connect.Request[v1.Config]) (*connect.Response[v1.Config], error) {
if err := s.config.Transform(func(cfg *v1.Config) (*v1.Config, error) {
if cfg.Modno != req.Msg.Modno {
return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("config modno mismatch, reload and try again"))
}
rehydrated := config.RehydrateNetworkSanitizedConfig(req.Msg, cfg)
if err := config.ValidateConfig(rehydrated); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation error: %w", err))
}
rehydrated.Modno++
return rehydrated, nil
}); err != nil {
return nil, fmt.Errorf("failed to update config: %w", err)
}
newConfig, err := s.config.Get()
if err != nil {
return nil, fmt.Errorf("failed to get newly set config: %w", err)
}
return connect.NewResponse(config.SanitizeForNetwork(newConfig)), nil
}
// cloneConfigWithRepo fetches the current config, sanitizes and upserts r into a clone of it,
// then locates the restic binary and builds a RepoOrchestrator for r against the cloned config.
// oldRepo is the previously-existing repo with the same ID, or nil if r is new.
func (s *BackrestHandler) cloneConfigWithRepo(r *v1.Repo) (cfg *v1.Config, oldRepo *v1.Repo, orch *repo.RepoOrchestrator, err error) {
cfg, err = s.config.Get()
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to get config: %w", err)
}
sanitizeRepoFlags(r)
cfg = proto.Clone(cfg).(*v1.Config)
if idx := slices.IndexFunc(cfg.Repos, func(x *v1.Repo) bool { return x.Id == r.Id }); idx != -1 {
oldRepo = cfg.Repos[idx]
cfg.Repos[idx] = r
} else {
cfg.Repos = append(cfg.Repos, r)
}
bin, err := resticinstaller.FindOrInstallResticBinary()
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to find or install restic binary: %w", err)
}
orch, err = repo.NewRepoOrchestrator(cfg, r, bin)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to configure repo: %w", err)
}
return cfg, oldRepo, orch, nil
}
func (s *BackrestHandler) CheckRepoExists(ctx context.Context, req *connect.Request[v1.CheckRepoExistsRequest]) (*connect.Response[v1.CheckRepoExistsResponse], error) {
if req.Msg.Repo.Guid == "" {
req.Msg.Repo.Guid = cryptoutil.MustRandomID(cryptoutil.DefaultIDBits)
}
c, _, r, err := s.cloneConfigWithRepo(req.Msg.Repo)
if err != nil {
return nil, err
}
if err := config.ValidateConfig(c); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation error: %w", err))
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := r.Exists(ctx); err != nil {
zap.S().Debugf("repo %q exists or not: %v", req.Msg.Repo.Id, err)
if errors.Is(err, restic.ErrRepoNotFound) {
zap.S().Debugf("repo %q does not exist", req.Msg.Repo.Id)
return connect.NewResponse(&v1.CheckRepoExistsResponse{Exists: false}), nil
}
return nil, err
}
return connect.NewResponse(&v1.CheckRepoExistsResponse{Exists: true}), nil
}
// AddRepo implements POST /v1/config/repo, it includes validation that the repo can be initialized.
func (s *BackrestHandler) AddRepo(ctx context.Context, req *connect.Request[v1.AddRepoRequest]) (*connect.Response[v1.Config], error) {
newRepo := req.Msg.Repo
// Perform all of the (slow) restic I/O against a candidate config BEFORE
// committing any config mutation. This avoids clobbering a concurrent
// SetConfig that lands during the I/O window: the actual config mutation is
// applied atomically via s.config.Transform against the current config below.
//
// Ensure the Repo GUID is set to the correct value.
// This is derived from 'restic cat config' for local repos.
// For remote repos, the GUID is derived from the remote config's value for the repo.
_, _, r, err := s.cloneConfigWithRepo(newRepo)
if err != nil {
return nil, err
}
if err := r.Init(ctx); err != nil {
return nil, fmt.Errorf("failed to init repo: %w", err)
}
guid, err := r.RepoGUID()
zap.S().Debugf("GUID for repo %q is %q from restic", newRepo.Id, guid)
if err != nil {
return nil, fmt.Errorf("failed to get repo config: %w", err)
}
newRepo.Guid = guid
// Commit the config mutation atomically. Re-upsert the GUID-stamped repo into
// the CURRENT config (not the pre-I/O snapshot) so a concurrent update during
// the restic I/O above is preserved. oldRepo and instanceID are captured from
// the transform-time config for correct GUID-change detection and oplog migration.
var oldRepo *v1.Repo
var instanceID string
zap.L().Debug("updating config")
if err := s.config.Transform(func(cfg *v1.Config) (*v1.Config, error) {
instanceID = cfg.Instance
if idx := slices.IndexFunc(cfg.Repos, func(x *v1.Repo) bool { return x.Id == newRepo.Id }); idx != -1 {
oldRepo = cfg.Repos[idx]
cfg.Repos[idx] = newRepo
} else {
oldRepo = nil
cfg.Repos = append(cfg.Repos, newRepo)
}
if err := config.ValidateConfig(cfg); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation error: %w", err))
}
return cfg, nil
}); err != nil {
return nil, fmt.Errorf("failed to update config: %w", err)
}
// If the GUID has changed, and we just successfully updated the config in storage, then we need to migrate the oplog.
if oldRepo != nil && newRepo.Guid != oldRepo.Guid {
migratedCount := 0
q := oplog.Query{}.
SetInstanceID(instanceID)
// we use RepoID here to _ensure_ we consolidate all operations to the most recent GUID.
// this provides some resiliancy in the case of a previous partial update.
q.DeprecatedRepoID = &oldRepo.Id
if err := s.oplog.Transform(q, func(op *v1.Operation) (*v1.Operation, error) {
op.RepoGuid = newRepo.Guid
migratedCount++
return op, nil
}); err != nil {
return nil, fmt.Errorf("failed to get operations for repo: %w", err)
}
zap.S().Infof("updated GUID for repo %q from %q to %q, migrated %d operations to reference the new GUID", newRepo.Id, oldRepo.Guid, newRepo.Guid, migratedCount)
}
// index snapshots for the newly added repository.
zap.L().Debug("scheduling index snapshots task")
s.orchestrator.ScheduleTask(tasks.NewOneoffIndexSnapshotsTask(newRepo, time.Now()), tasks.TaskPriorityInteractive+tasks.TaskPriorityIndexSnapshots)
newConfig, err := s.config.Get()
if err != nil {
return nil, fmt.Errorf("failed to get newly set config: %w", err)
}
zap.L().Debug("done add repo")
return connect.NewResponse(config.SanitizeForNetwork(newConfig)), nil
}
func (s *BackrestHandler) RemoveRepo(ctx context.Context, req *connect.Request[v1.RemoveRepoRequest]) (*connect.Response[v1.Config], error) {
var instanceID string
if err := s.config.Transform(func(cfg *v1.Config) (*v1.Config, error) {
instanceID = cfg.Instance
cfg.Repos = slices.DeleteFunc(cfg.Repos, func(r *v1.Repo) bool {
return r.Id == req.Msg.RepoId
})
return cfg, nil
}); err != nil {
return nil, fmt.Errorf("failed to update config: %w", err)
}
// Query for all operations for the repo
q := oplog.Query{}.SetInstanceID(instanceID)
q.DeprecatedRepoID = &req.Msg.RepoId
var opIDs []int64
if err := s.oplog.Query(q, func(op *v1.Operation) error {
opIDs = append(opIDs, op.Id)
return nil
}); err != nil {
return nil, fmt.Errorf("failed to get operations for repo: %w", err)
}
// Delete operations referencing the repo from the oplog in batches
for len(opIDs) > 0 {
batchSize := min(256, len(opIDs))
if err := s.oplog.Delete(opIDs[:batchSize]...); err != nil {
return nil, fmt.Errorf("failed to delete operations: %w", err)
}
opIDs = opIDs[batchSize:]
}
newConfig, err := s.config.Get()
if err != nil {
return nil, fmt.Errorf("failed to get config: %w", err)
}
return connect.NewResponse(config.SanitizeForNetwork(newConfig)), nil
}
// SetupSftp implements SetupSftp RPC
func (s *BackrestHandler) SetupSftp(ctx context.Context, req *connect.Request[v1.SetupSftpRequest]) (*connect.Response[v1.SetupSftpResponse], error) {
host := req.Msg.Host
port := req.Msg.Port
if port == "" {
port = "22"
}
if runtime.GOOS == "windows" {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("automated SFTP setup is not supported on Windows"))
}
// 1. Generate key pair (local, always succeeds)
_, pubBytes, keyPath, err := sftputil.GenerateKey(host, env.SSHDir())
if err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
// 2. Scan remote host key into known_hosts (network, non-fatal)
var hostKeyWarning string
if err := sftputil.AddHostKey(host, port, env.SSHDir()); err != nil {
zap.S().Warnf("SFTP host key scan failed for %s: %v", host, err)
hostKeyWarning = fmt.Sprintf("Could not scan host key (%v). Add the host key to known_hosts manually or ensure the host is reachable.", err)
}
return connect.NewResponse(&v1.SetupSftpResponse{
PublicKey: string(pubBytes),
KeyPath: keyPath,
KnownHostsPath: filepath.Join(env.SSHDir(), "known_hosts"),
Error: hostKeyWarning,
}), nil
}
// ListSnapshots implements POST /v1/snapshots
func (s *BackrestHandler) ListSnapshots(ctx context.Context, req *connect.Request[v1.ListSnapshotsRequest]) (*connect.Response[v1.ResticSnapshotList], error) {
query := req.Msg
repo, err := s.orchestrator.GetRepoOrchestrator(query.RepoId)
if err != nil {
return nil, withLookupCode(fmt.Errorf("failed to get repo: %w", err))
}
var snapshots []*restic.Snapshot
if query.PlanId != "" {
var plan *v1.Plan
plan, err = s.orchestrator.GetPlan(query.PlanId)
if err != nil {
return nil, withLookupCode(fmt.Errorf("failed to get plan %q: %w", query.PlanId, err))
}
snapshots, err = repo.SnapshotsForPlan(ctx, plan)
} else {
snapshots, err = repo.Snapshots(ctx)
}
if err != nil {
return nil, fmt.Errorf("failed to list snapshots: %w", err)
}
// Transform the snapshots and return them.
var rs []*v1.ResticSnapshot
for _, snapshot := range snapshots {
rs = append(rs, protoutil.SnapshotToProto(snapshot))
}
return connect.NewResponse(&v1.ResticSnapshotList{
Snapshots: rs,
}), nil
}
func (s *BackrestHandler) ListSnapshotFiles(ctx context.Context, req *connect.Request[v1.ListSnapshotFilesRequest]) (*connect.Response[v1.ListSnapshotFilesResponse], error) {
query := req.Msg
repo, err := s.orchestrator.GetRepoOrchestrator(query.RepoId)
if err != nil {
return nil, withLookupCode(fmt.Errorf("failed to get repo: %w", err))
}
entries, err := repo.ListSnapshotFiles(ctx, query.SnapshotId, query.Path)
if err != nil {
return nil, fmt.Errorf("failed to list snapshot files: %w", err)
}
return connect.NewResponse(&v1.ListSnapshotFilesResponse{
Path: query.Path,
Entries: entries,
}), nil
}
// GetOperationEvents implements GET /v1/events/operations
func (s *BackrestHandler) GetOperationEvents(ctx context.Context, req *connect.Request[emptypb.Empty], resp *connect.ServerStream[v1.OperationEvent]) error {
errChan := make(chan error, 1)
events := make(chan *v1.OperationEvent, 100)
timer := time.NewTicker(60 * time.Second)
defer timer.Stop()
callback := func(ops []*v1.Operation, eventType oplog.OperationEvent) {
var event *v1.OperationEvent
switch eventType {
case oplog.OPERATION_ADDED:
event = &v1.OperationEvent{
Event: &v1.OperationEvent_CreatedOperations{
CreatedOperations: &v1.OperationList{
Operations: ops,
},
},
}
case oplog.OPERATION_UPDATED:
event = &v1.OperationEvent{
Event: &v1.OperationEvent_UpdatedOperations{
UpdatedOperations: &v1.OperationList{
Operations: ops,
},
},
}
case oplog.OPERATION_DELETED:
ids := make([]int64, len(ops))
for i, o := range ops {
ids[i] = o.Id
}
event = &v1.OperationEvent{
Event: &v1.OperationEvent_DeletedOperations{
DeletedOperations: &types.Int64List{
Values: ids,
},
},
}
default:
zap.L().Error("Unknown event type")
}
select {
case events <- event:
default:
select {
case errChan <- errors.New("event buffer overflow, closing stream for client retry and catchup"):
default:
}
}
}
s.oplog.Subscribe(oplog.SelectAll, &callback)
defer func() {
if err := s.oplog.Unsubscribe(&callback); err != nil {
zap.L().Error("failed to unsubscribe from oplog", zap.Error(err))
}
}()
for {
select {
case <-timer.C:
if err := resp.Send(&v1.OperationEvent{
Event: &v1.OperationEvent_KeepAlive{},
}); err != nil {
return err
}
case err := <-errChan:
return err
case <-ctx.Done():
return nil
case event := <-events:
if err := resp.Send(event); err != nil {
return err
}
}
}
}
func (s *BackrestHandler) GetOperations(ctx context.Context, req *connect.Request[v1.GetOperationsRequest]) (*connect.Response[v1.OperationList], error) {
q, err := protoutil.OpSelectorToQuery(req.Msg.Selector)
if err != nil {
return nil, err
}
if req.Msg.LastN != 0 {
q.Reversed = true
q.Limit = int(req.Msg.LastN)
}
var ops []*v1.Operation
opCollector := func(op *v1.Operation) error {
ops = append(ops, op)
return nil
}
err = s.oplog.Query(q, opCollector)
if err != nil {
return nil, fmt.Errorf("failed to get operations: %w", err)
}
slices.SortFunc(ops, func(i, j *v1.Operation) int {
if i.Id < j.Id {
return -1
}
return 1
})
return connect.NewResponse(&v1.OperationList{
Operations: ops,
}), nil
}
// scheduleTaskAndWait schedules the task and blocks until it completes, returning the task's error.
func (s *BackrestHandler) scheduleTaskAndWait(t tasks.Task, priority int) error {
var taskErr error
wait := make(chan struct{})
if _, err := s.orchestrator.ScheduleTask(t, priority, func(e error) { taskErr = e; close(wait) }); err != nil {
return err
}
<-wait
return taskErr
}
func (s *BackrestHandler) Backup(ctx context.Context, req *connect.Request[v1.BackupRequest]) (*connect.Response[emptypb.Empty], error) {
plan, err := s.orchestrator.GetPlan(req.Msg.Value)
if err != nil {
return nil, withLookupCode(err)
}
repo, err := s.orchestrator.GetRepo(plan.Repo)
if err != nil {
return nil, withLookupCode(err)
}
if err := s.scheduleTaskAndWait(tasks.NewOneoffBackupTask(repo, plan, time.Now(), req.Msg.DryRun), tasks.TaskPriorityInteractive); err != nil {
return nil, err
}
return connect.NewResponse(&emptypb.Empty{}), nil
}
func (s *BackrestHandler) Forget(ctx context.Context, req *connect.Request[v1.ForgetRequest]) (*connect.Response[v1.ScheduleTaskResponse], error) {
at := time.Now()
repo, err := s.orchestrator.GetRepo(req.Msg.RepoId)
if err != nil {
return nil, withLookupCode(err)
}
if req.Msg.PlanId == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("must specify repoId and planId and (optionally) snapshotId"))
}
var task tasks.Task
if req.Msg.SnapshotId != "" {
task = tasks.NewOneoffForgetSnapshotTask(repo, req.Msg.PlanId, 0, at, req.Msg.SnapshotId)
} else {
task = tasks.NewOneoffForgetTask(repo, req.Msg.PlanId, 0, at)
}
id, err := s.orchestrator.ScheduleTask(task, tasks.TaskPriorityInteractive+tasks.TaskPriorityForget)
if err != nil {
return nil, fmt.Errorf("failed to schedule forget task: %w", err)
}
return connect.NewResponse(&v1.ScheduleTaskResponse{OperationId: id}), nil
}
func (s BackrestHandler) DoRepoTask(ctx context.Context, req *connect.Request[v1.DoRepoTaskRequest]) (*connect.Response[v1.ScheduleTaskResponse], error) {
var task tasks.Task
repo, err := s.orchestrator.GetRepo(req.Msg.RepoId)
if err != nil {
return nil, withLookupCode(err)
}
priority := tasks.TaskPriorityInteractive
switch req.Msg.Task {
case v1.DoRepoTaskRequest_TASK_CHECK:
task = tasks.NewCheckTask(repo, tasks.PlanForSystemTasks, true)
case v1.DoRepoTaskRequest_TASK_PRUNE:
task = tasks.NewPruneTask(repo, tasks.PlanForSystemTasks, true)
priority |= tasks.TaskPriorityPrune
case v1.DoRepoTaskRequest_TASK_FORGET:
if repo.GetForgetPolicy() == nil {
return nil, fmt.Errorf("repo %q has no forget policy configured", req.Msg.RepoId)
}
task = tasks.NewScheduledForgetTask(repo, tasks.PlanForSystemTasks, true)
priority |= tasks.TaskPriorityForget
case v1.DoRepoTaskRequest_TASK_STATS:
task = tasks.NewStatsTask(repo, tasks.PlanForSystemTasks, true)
priority |= tasks.TaskPriorityStats
case v1.DoRepoTaskRequest_TASK_INDEX_SNAPSHOTS:
task = tasks.NewOneoffIndexSnapshotsTask(repo, time.Now())
priority |= tasks.TaskPriorityIndexSnapshots
case v1.DoRepoTaskRequest_TASK_UNLOCK:
repo, err := s.orchestrator.GetRepoOrchestrator(req.Msg.RepoId)
if err != nil {
return nil, withLookupCode(err)
}
if err := repo.Unlock(ctx); err != nil {
return nil, fmt.Errorf("failed to unlock repo %q: %w", req.Msg.RepoId, err)
}
return connect.NewResponse(&v1.ScheduleTaskResponse{OperationId: 0}), nil
default:
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unknown task %v", req.Msg.Task.String()))
}
id, err := s.orchestrator.ScheduleTask(task, priority)
if err != nil {
return nil, fmt.Errorf("failed to schedule task: %w", err)
}
return connect.NewResponse(&v1.ScheduleTaskResponse{OperationId: id}), nil
}
func (s *BackrestHandler) Restore(ctx context.Context, req *connect.Request[v1.RestoreSnapshotRequest]) (*connect.Response[v1.ScheduleTaskResponse], error) {
req.Msg.Target = strings.TrimSpace(req.Msg.Target)
req.Msg.Path = strings.TrimSpace(req.Msg.Path)
if req.Msg.Target == "" {
req.Msg.Target = path.Join(os.Getenv("HOME"), "Downloads", fmt.Sprintf("restic-restore-%v", time.Now().Format("2006-01-02T15-04-05")))
}
if req.Msg.Path == "" {
req.Msg.Path = "/"
}
// prevent restoring to a directory that already exists
if _, err := os.Stat(req.Msg.Target); err == nil {
return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("target directory %q already exists", req.Msg.Target))
}
repo, err := s.orchestrator.GetRepo(req.Msg.RepoId)
if err != nil {
return nil, withLookupCode(err)
}
at := time.Now()
id, err := s.orchestrator.ScheduleTask(tasks.NewOneoffRestoreTask(repo, req.Msg.PlanId, 0 /* flowID */, at, req.Msg.SnapshotId, req.Msg.Path, req.Msg.Target), tasks.TaskPriorityInteractive+tasks.TaskPriorityDefault)
if err != nil {
return nil, fmt.Errorf("failed to schedule restore task: %w", err)
}
return connect.NewResponse(&v1.ScheduleTaskResponse{OperationId: id}), nil
}
func (s *BackrestHandler) RunCommand(ctx context.Context, req *connect.Request[v1.RunCommandRequest]) (*connect.Response[v1.RunCommandResponse], error) {
cfg, err := s.config.Get()
if err != nil {
return nil, fmt.Errorf("failed to get config: %w", err)
}
repo := config.FindRepo(cfg, req.Msg.RepoId)
if repo == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("repo %q not found", req.Msg.RepoId))
}
// group commands within the last 24 hours (or 256 operations) into the same flow ID
var flowID int64
if s.oplog.Query(oplog.Query{}.
SetInstanceID(cfg.Instance).
SetRepoGUID(repo.GetGuid()).
SetLimit(256).
SetReversed(true), func(op *v1.Operation) error {
if op.GetOperationRunCommand() != nil && time.Since(time.UnixMilli(op.UnixTimeStartMs)) < 30*time.Minute {
flowID = op.FlowId
}
return nil
}) != nil {
return nil, fmt.Errorf("failed to query operations")
}
task := tasks.NewOneoffRunCommandTask(repo, tasks.PlanForSystemTasks, flowID, time.Now(), req.Msg.Command)
st, err := s.orchestrator.CreateUnscheduledTask(task, tasks.TaskPriorityInteractive, time.Now())
if err != nil {
return nil, fmt.Errorf("failed to create task: %w", err)
}
if err := s.orchestrator.RunTask(context.Background(), st); err != nil {
return nil, fmt.Errorf("failed to run command: %w", err)
}
return connect.NewResponse(&v1.RunCommandResponse{OperationId: st.Op.GetId()}), nil
}
func (s *BackrestHandler) Cancel(ctx context.Context, req *connect.Request[v1.CancelOperationRequest]) (*connect.Response[emptypb.Empty], error) {
if err := s.orchestrator.CancelOperation(req.Msg.OperationId, v1.OperationStatus_STATUS_USER_CANCELLED); err != nil {
return nil, err
}
return connect.NewResponse(&emptypb.Empty{}), nil
}
func (s *BackrestHandler) ClearHistory(ctx context.Context, req *connect.Request[v1.ClearHistoryRequest]) (*connect.Response[emptypb.Empty], error) {
var err error
var ids []int64
opCollector := func(op *v1.Operation) error {
if !req.Msg.OnlyFailed || op.Status == v1.OperationStatus_STATUS_ERROR {
ids = append(ids, op.Id)
}
return nil
}
q, err := protoutil.OpSelectorToQuery(req.Msg.Selector)
if err != nil {
return nil, err
}
if err := s.oplog.Query(q, opCollector); err != nil {
return nil, fmt.Errorf("failed to get operations to delete: %w", err)
}
if err := s.oplog.Delete(ids...); err != nil {
return nil, fmt.Errorf("failed to delete operations: %w", err)
}
return connect.NewResponse(&emptypb.Empty{}), err
}
func (s *BackrestHandler) GetLogs(ctx context.Context, req *connect.Request[v1.LogDataRequest], resp *connect.ServerStream[types.BytesValue]) error {
r, err := s.logStore.Open(req.Msg.Ref)
if err != nil {
if errors.Is(err, logstore.ErrLogNotFound) {
resp.Send(&types.BytesValue{
Value: []byte(fmt.Sprintf("file associated with log %v not found, it may have expired.", req.Msg.GetRef())),
})
return nil
}
return fmt.Errorf("get log data %v: %w", req.Msg.GetRef(), err)
}
go func() {
<-ctx.Done()
r.Close()
}()
var errChan = make(chan error, 1)
var sendChan = make(chan []byte, 1)
var buffer bytes.Buffer
var bufferMu sync.Mutex
go func() {
data := make([]byte, 4*1024)
for {
n, err := r.Read(data)
if n == 0 {
break
} else if err != nil && err != io.EOF {
errChan <- fmt.Errorf("failed to read log data: %w", err)
close(errChan)
break
}
bufferMu.Lock()
buffer.Write(data[:n])
if buffer.Len() > 128*1024 {
sendChan <- bytes.Clone(buffer.Bytes())
buffer.Reset()
}
bufferMu.Unlock()
}
if buffer.Len() > 0 {
bufferMu.Lock()
sendChan <- bytes.Clone(buffer.Bytes())
buffer.Reset()
bufferMu.Unlock()
}
close(sendChan)
}()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case data, ok := <-sendChan:
if !ok {
return nil
}
if err := resp.Send(&types.BytesValue{Value: data}); err != nil {
return err
}
case err := <-errChan:
return err
case <-ticker.C:
bufferMu.Lock()
if buffer.Len() > 0 {
if err := resp.Send(&types.BytesValue{Value: bytes.Clone(buffer.Bytes())}); err != nil {
bufferMu.Unlock()
return err
}
buffer.Reset()
}
bufferMu.Unlock()
}
}
}
func (s *BackrestHandler) GetDownloadURL(ctx context.Context, req *connect.Request[v1.GetDownloadURLRequest]) (*connect.Response[types.StringValue], error) {
op, err := s.oplog.Get(req.Msg.OpId)
if err != nil {
if errors.Is(err, oplog.ErrNotExist) {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("failed to get operation %v: %w", req.Msg.OpId, err))
}
return nil, fmt.Errorf("failed to get operation %v: %w", req.Msg.OpId, err)
}
var opType string
switch op.Op.(type) {
case *v1.Operation_OperationIndexSnapshot:
opType = "snapshot"
case *v1.Operation_OperationRestore:
opType = "restore"
default:
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("operation %v is not a restore or snapshot operation", req.Msg.OpId))
}
token, err := signDownloadToken(DownloadTokenPayload{
OpID: op.Id,
Type: opType,
FilePath: req.Msg.FilePath,
})
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
return connect.NewResponse(&types.StringValue{
Value: fmt.Sprintf("./download/%s/", token),
}), nil
}
func (s *BackrestHandler) PathAutocomplete(ctx context.Context, path *connect.Request[types.StringValue]) (*connect.Response[types.StringList], error) {
ents, err := os.ReadDir(path.Msg.Value)
if errors.Is(err, os.ErrNotExist) {
return connect.NewResponse(&types.StringList{}), nil
} else if err != nil {
return nil, err
}
var paths []string
for _, ent := range ents {
paths = append(paths, ent.Name())
}
return connect.NewResponse(&types.StringList{Values: paths}), nil
}
func (s *BackrestHandler) GeneratePairingToken(ctx context.Context, req *connect.Request[v1.GeneratePairingTokenRequest]) (*connect.Response[v1.GeneratePairingTokenResponse], error) {
// Generate the one-time secret before taking the lock.
secret, err := cryptoutil.GeneratePairingSecret()
if err != nil {
return nil, fmt.Errorf("failed to generate pairing secret: %w", err)
}
now := time.Now().Unix()
var expiresAt int64
if req.Msg.TtlSeconds > 0 {
expiresAt = now + req.Msg.TtlSeconds
}
var tokenStr string
if err := s.config.Transform(func(cfg *v1.Config) (*v1.Config, error) {
if cfg.Instance == "" {
return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("instance name must be set before generating pairing tokens"))
}
identity := cfg.GetMultihost().GetIdentity()
if identity == nil {
return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("multihost identity must be configured before generating pairing tokens"))
}
cfg.Multihost.PairingTokens = append(cfg.Multihost.PairingTokens, &v1.Multihost_PairingToken{
Secret: secret,
Label: req.Msg.Label,
CreatedAtUnix: now,
ExpiresAtUnix: expiresAt,
MaxUses: req.Msg.MaxUses,
Uses: 0,
Permissions: req.Msg.Permissions,
})
cfg.Modno++
tokenStr = cryptoutil.FormatPairingToken(identity.Keyid, secret, cfg.Instance)
return cfg, nil
}); err != nil {
return nil, fmt.Errorf("failed to save pairing token: %w", err)
}
return connect.NewResponse(&v1.GeneratePairingTokenResponse{
Token: tokenStr,
}), nil
}
// withLookupCode tags err with connect.CodeNotFound when it stems from a missing
// repo or plan lookup (orchestrator.ErrRepoNotFound / ErrPlanNotFound), preserving
// the original message. Other errors are returned unchanged.
func withLookupCode(err error) error {
if err == nil {
return nil
}
if errors.Is(err, orchestrator.ErrRepoNotFound) || errors.Is(err, orchestrator.ErrPlanNotFound) {
return connect.NewError(connect.CodeNotFound, err)
}
return err
}
func sanitizeRepoFlags(repo *v1.Repo) {
for i, flag := range repo.Flags {
if strings.HasPrefix(flag, "--option=sftp.args=") {
// Remove the "-i @" workaround since we now handle quoting properly
flag = strings.ReplaceAll(flag, "-i @", "-i ")
// Strip double quotes from sftp.args values that cause restic's
// pflag parser to fail with "bare \" in non-quoted-field".
// The UI wraps paths in double quotes (e.g. -i "/path/to/key"),
// but restic's --option flag parser cannot handle them.
// Paths passed as sftp.args don't need quoting since they are
// passed directly to sftp as individual arguments.
flag = strings.ReplaceAll(flag, "\"", "")
repo.Flags[i] = flag
}
}
}