Skip to content

Commit 6e46edc

Browse files
committed
Treat missing watched sources as deletes
1 parent ee03840 commit 6e46edc

2 files changed

Lines changed: 138 additions & 1 deletion

File tree

‎internal/agent/replication.go‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"net/http"
8+
"net/url"
89
"os"
910
"os/exec"
1011
"path"
@@ -72,6 +73,14 @@ func (ExecRunner) Run(ctx context.Context, spec CommandSpec) error {
7273
return cmd.Run()
7374
}
7475

76+
type sourceExistsResult int
77+
78+
const (
79+
sourceExistsUnknown sourceExistsResult = iota
80+
sourceExistsYes
81+
sourceExistsNo
82+
)
83+
7584
func NormalizeEventPath(p string) (string, error) {
7685
p = strings.TrimSpace(strings.ReplaceAll(p, "\\", "/"))
7786
p = strings.TrimPrefix(p, "/")
@@ -241,18 +250,72 @@ func ApplyEventBatch(ctx context.Context, runner Runner, pool Pool, batch EventB
241250
default:
242251
sourcePath := path.Join(safeSegment(batch.Namespace), safeSegment(batch.Volume), event.Path)
243252
targetPath := filepath.Join(targetRoot, filepath.FromSlash(event.Path))
253+
exists, err := sourcePathExists(ctx, batch.Source, sourcePath)
254+
if err != nil {
255+
return err
256+
}
257+
if exists == sourceExistsNo {
258+
if err := os.RemoveAll(targetPath); err != nil {
259+
return err
260+
}
261+
continue
262+
}
244263
spec, err := BuildRcloneCopyToCommand(batch.Source, sourcePath, targetPath)
245264
if err != nil {
246265
return err
247266
}
248267
if err := runner.Run(ctx, spec); err != nil {
268+
exists, existsErr := sourcePathExists(ctx, batch.Source, sourcePath)
269+
if existsErr != nil {
270+
return err
271+
}
272+
if exists == sourceExistsNo {
273+
if removeErr := os.RemoveAll(targetPath); removeErr != nil {
274+
return removeErr
275+
}
276+
continue
277+
}
249278
return err
250279
}
251280
}
252281
}
253282
return nil
254283
}
255284

285+
func sourcePathExists(ctx context.Context, source SourceRef, sourcePath string) (sourceExistsResult, error) {
286+
if strings.TrimSpace(source.WebDAVURL) == "" {
287+
return sourceExistsUnknown, nil
288+
}
289+
base, err := url.Parse(strings.TrimRight(source.WebDAVURL, "/") + "/")
290+
if err != nil {
291+
return sourceExistsUnknown, err
292+
}
293+
rel, err := url.Parse(path.Join(strings.Trim(sourcePath, "/")))
294+
if err != nil {
295+
return sourceExistsUnknown, err
296+
}
297+
req, err := http.NewRequestWithContext(ctx, http.MethodHead, base.ResolveReference(rel).String(), nil)
298+
if err != nil {
299+
return sourceExistsUnknown, err
300+
}
301+
if source.User != "" || source.Password != "" {
302+
req.SetBasicAuth(source.User, source.Password)
303+
}
304+
resp, err := http.DefaultClient.Do(req)
305+
if err != nil {
306+
return sourceExistsUnknown, nil
307+
}
308+
defer resp.Body.Close()
309+
switch resp.StatusCode {
310+
case http.StatusOK, http.StatusNoContent, http.StatusPartialContent:
311+
return sourceExistsYes, nil
312+
case http.StatusNotFound, http.StatusGone:
313+
return sourceExistsNo, nil
314+
default:
315+
return sourceExistsUnknown, nil
316+
}
317+
}
318+
256319
func ApplyFullSync(ctx context.Context, runner Runner, pool Pool, req FullSyncRequest) error {
257320
if runner == nil {
258321
runner = ExecRunner{}

‎internal/agent/replication_test.go‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package agent
22

33
import (
44
"context"
5+
"errors"
6+
"net/http"
7+
"net/http/httptest"
58
"os"
69
"path/filepath"
710
"strings"
@@ -10,11 +13,12 @@ import (
1013

1114
type recordingRunner struct {
1215
specs []CommandSpec
16+
err error
1317
}
1418

1519
func (r *recordingRunner) Run(_ context.Context, spec CommandSpec) error {
1620
r.specs = append(r.specs, spec)
17-
return nil
21+
return r.err
1822
}
1923

2024
func containsArg(args []string, want string) bool {
@@ -179,6 +183,76 @@ func TestApplyEventBatchRunsCopyAndDelete(t *testing.T) {
179183
}
180184
}
181185

186+
func TestApplyEventBatchRemovesTargetWhenSourceAlreadyGone(t *testing.T) {
187+
dir := t.TempDir()
188+
pool := Pool{Name: "default", Path: dir}
189+
if err := EnsurePool(pool, false); err != nil {
190+
t.Fatal(err)
191+
}
192+
target := filepath.Join(dir, "default", "demo", "save", "a.txt")
193+
if err := writeFile(target, "old"); err != nil {
194+
t.Fatal(err)
195+
}
196+
source := httptest.NewServer(http.NotFoundHandler())
197+
defer source.Close()
198+
199+
runner := &recordingRunner{}
200+
err := ApplyEventBatch(context.Background(), runner, pool, EventBatch{
201+
Namespace: "default",
202+
Volume: "demo",
203+
Source: SourceRef{WebDAVURL: source.URL},
204+
Events: []FileEvent{{Path: "save/a.txt", Op: EventOpUpsert}},
205+
})
206+
if err != nil {
207+
t.Fatalf("ApplyEventBatch returned error: %v", err)
208+
}
209+
if len(runner.specs) != 0 {
210+
t.Fatalf("ran %d commands, want none", len(runner.specs))
211+
}
212+
if _, err := os.Stat(target); !os.IsNotExist(err) {
213+
t.Fatalf("target was not removed after missing source: %v", err)
214+
}
215+
}
216+
217+
func TestApplyEventBatchRemovesTargetWhenSourceDisappearsAfterCopyFailure(t *testing.T) {
218+
dir := t.TempDir()
219+
pool := Pool{Name: "default", Path: dir}
220+
if err := EnsurePool(pool, false); err != nil {
221+
t.Fatal(err)
222+
}
223+
target := filepath.Join(dir, "default", "demo", "save", "a.txt")
224+
if err := writeFile(target, "old"); err != nil {
225+
t.Fatal(err)
226+
}
227+
var checks int
228+
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
229+
checks++
230+
if checks == 1 {
231+
w.WriteHeader(http.StatusOK)
232+
return
233+
}
234+
http.NotFound(w, r)
235+
}))
236+
defer source.Close()
237+
238+
runner := &recordingRunner{err: errors.New("copy failed")}
239+
err := ApplyEventBatch(context.Background(), runner, pool, EventBatch{
240+
Namespace: "default",
241+
Volume: "demo",
242+
Source: SourceRef{WebDAVURL: source.URL},
243+
Events: []FileEvent{{Path: "save/a.txt", Op: EventOpUpsert}},
244+
})
245+
if err != nil {
246+
t.Fatalf("ApplyEventBatch returned error: %v", err)
247+
}
248+
if len(runner.specs) != 1 {
249+
t.Fatalf("ran %d commands, want 1", len(runner.specs))
250+
}
251+
if _, err := os.Stat(target); !os.IsNotExist(err) {
252+
t.Fatalf("target was not removed after disappeared source: %v", err)
253+
}
254+
}
255+
182256
func TestApplyFullSyncRunsScopedRcloneSync(t *testing.T) {
183257
dir := t.TempDir()
184258
pool := Pool{Name: "default", Path: dir}

0 commit comments

Comments
 (0)