Stray-copy fix: the capped second-drive copy container never outlives the copy — fixed name + label, removed by name on any error (the timeout included), every leftover removed at start before the scheduler and web server; a restore is refused while a copy runs or may still run; --mount (refuses a path the daemon does not see) and the image ID with --pull never (review); red-proved; unreleased
gates / gates (push) Successful in 1m53s

This commit is contained in:
2026-10-22 20:20:35 +02:00
parent df78b6caae
commit 66a69d4954
9 changed files with 552 additions and 8 deletions
+33 -4
View File
@@ -782,26 +782,53 @@ func (m *Manager) tier2NoTargetReason(err error) string {
// differs for the daemon: a capped `--delete` there would mirror an EMPTY folder over the copy. Those paths, a box
// whose image cannot be read, and a container that cannot START (docker's own exit 125) take the plain rsync, as
// before. Pinned by TestR1214_*.
//
// The container must never outlive the copy (the stray-copy fix, tier2_copy_lifecycle.go): it has a fixed name and a
// label; a leftover of the same copy is removed first; on any error — the timeout included, which kills only the
// docker CLI — it is removed by name. Every copy, capped or in-process, is in the controller's record of copies while
// it runs, and a restore is refused meanwhile. Pinned by TestStrayCopy_*.
func rsyncMirror(src, dst string) error {
if err := os.MkdirAll(dst, 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", dst, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), tier2CopyDeadline())
defer cancel()
// Trailing slashes: copy the CONTENTS of src into dst.
s, d := strings.TrimRight(src, "/"), strings.TrimRight(dst, "/")
rs := []string{"-a", "--delete", s + "/", d + "/"}
name, app := tier2CopyName(d)
tier2CopyBegin(app)
defer tier2CopyEnd(app)
if tier2CappedPath(s) && tier2CappedPath(d) {
if img := tier2SelfImage(); img != "" {
args := append([]string{"run", "--rm", "--memory", tier2CopyMemCap, "--memory-swap", tier2CopyMemCap,
"--network", "none", "-v", s + ":" + s + ":ro", "-v", d + ":" + d, "--entrypoint", "rsync", img}, rs...)
// A leftover of this same copy (one a removal could not stop) never runs beside a new one.
if !tier2RemoveCopy(name, "a leftover of the same copy, before a new one") {
return fmt.Errorf("a leftover copy container %s could not be removed — not copying beside it", name)
}
args := append([]string{"run", "--rm", "--name", name, "--label", tier2CopyLabel + "=1",
"--label", tier2CopyAppLabel + "=" + tier2DockerSafe(app),
"--memory", tier2CopyMemCap, "--memory-swap", tier2CopyMemCap,
"--network", "none", "--pull", "never",
// --mount (not -v): a path the daemon does not see is REFUSED (exit 125 → the in-process copy) instead of
// created empty — an empty source would let `--delete` empty the copy (review 2026-10-22 night, C4).
"--mount", "type=bind,src=" + s + ",dst=" + s + ",readonly", "--mount", "type=bind,src=" + d + ",dst=" + d,
"--entrypoint", "rsync", img}, rs...)
out, code, err := tier2RunCapped(ctx, args)
if err == nil {
return nil
}
// The docker CLI ending does not stop the container (a timeout kills only the CLI): remove it by name.
removed := tier2RemoveCopy(name, "the copy ended with an error")
if ctx.Err() != nil {
tier2Logf("[WARN] [tier2] the copy into %s ran past its time limit (%s) — its container %s was removed (removed=%v)", d, tier2CopyDeadline(), name, removed)
return fmt.Errorf("copy timed out after %s: %v: %s", tier2CopyDeadline(), err, strings.TrimSpace(string(out)))
}
if code != 125 {
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
}
if !removed {
return fmt.Errorf("the copy container %s did not start and could not be removed: %s", name, strings.TrimSpace(string(out)))
}
tier2Logf("[WARN] [tier2] the capped copy container did not start (%s) — copying in-process, as before (R-1214)", strings.TrimSpace(string(out)))
}
}
@@ -835,7 +862,9 @@ var tier2CappedRoot = "/mnt/"
// Seams (tests never reach Docker — tests that do act on the host they run on).
var (
tier2SelfImage = func() string {
out, err := dockerexec.Command("docker", "inspect", "felhom-controller", "--format", "{{.Config.Image}}").Output()
// The image ID, not its tag: a tag can be removed or re-pointed; with --pull never the copy runs exactly the
// image the controller runs (review 2026-10-22 night, C7).
out, err := dockerexec.Command("docker", "inspect", "felhom-controller", "--format", "{{.Image}}").Output()
if err != nil {
return ""
}