D48: a restore downloads first — every restore path resolves and pulls every image of the definition that will run before the stop, under the image-work lock held until the restore returns; a failed pull refuses with the app untouched (a deleted version and a busy registry read differently), one operator line. Red-proved. Commit only.
gates / gates (push) Successful in 1m26s
gates / gates (push) Successful in 1m26s
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// ── D48 (operator ruling 2026-10-25 08:38; `09` §3 decision 257): a restore downloads first ─────────────────────────
|
||||
//
|
||||
// BEFORE: every restore stopped the app and moved its data first; the first pull was inside the database-only start
|
||||
// (`docker compose up -d <db>`), after the stop. A version that could not be downloaded then ended in the D22 put-back —
|
||||
// the whole stop, copy and pour-back for nothing, and an app that was down the while.
|
||||
//
|
||||
// THE RULE. After the existing refusals and before the first stop or change, every image the definition that will RUN
|
||||
// names is resolved: present on the box, or pulled now. Under the image-work lock held SHARED from the first check
|
||||
// until the restore's last start (R-863: the image clean-up cannot delete a pulled image before the start — its pass
|
||||
// takes the lock exclusively with TryLock and simply does not run meanwhile). A failed pull is a REFUSAL: the app is
|
||||
// not stopped and its data is not moved. The household reads which version could not be downloaded, and why — a
|
||||
// version its maker deleted (Docker answers „manifest unknown", „not found", „pull access denied") reads differently
|
||||
// from a registry that is busy or unreachable (anything else, „toomanyrequests" and timeouts included).
|
||||
|
||||
// ErrRestoreImageUnavailable is the KIND of the refusal: an image of the backup's version could not be downloaded.
|
||||
var ErrRestoreImageUnavailable = errors.New("an image of the backup's version cannot be downloaded")
|
||||
|
||||
// restorePullTimeout bounds one image pull of a restore.
|
||||
const restorePullTimeout = 15 * time.Minute
|
||||
|
||||
// imagePresentDefault / imagePullDefault are the production probe and pull (tests replace them in TestMain, so no test
|
||||
// in this package reaches Docker — R-650; a test that needs a missing image sets m.imagePresentFn / m.imagePullFn).
|
||||
var (
|
||||
imagePresentDefault = func(ref string) bool {
|
||||
return dockerexec.Command("docker", "image", "inspect", "--format", "{{.Id}}", ref).Run() == nil
|
||||
}
|
||||
imagePullDefault = func(ctx context.Context, ref string) (string, error) {
|
||||
out, err := dockerexec.CommandContext(ctx, "docker", "pull", ref).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
)
|
||||
|
||||
func (m *Manager) imagePresent(ref string) bool {
|
||||
if m.imagePresentFn != nil {
|
||||
return m.imagePresentFn(ref)
|
||||
}
|
||||
return imagePresentDefault(ref)
|
||||
}
|
||||
|
||||
func (m *Manager) imagePull(ctx context.Context, ref string) (string, error) {
|
||||
if m.imagePullFn != nil {
|
||||
return m.imagePullFn(ctx, ref)
|
||||
}
|
||||
return imagePullDefault(ctx, ref)
|
||||
}
|
||||
|
||||
// pullGone reports whether Docker's answer says the version does not exist (deleted by its maker, a wrong name) rather
|
||||
// than that the registry could not be reached or is busy.
|
||||
func pullGone(out string) bool {
|
||||
low := strings.ToLower(out)
|
||||
for _, s := range []string{"manifest unknown", "not found", "repository does not exist", "pull access denied", "name unknown", "no such manifest"} {
|
||||
if strings.Contains(low, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// prepareRestoreImages resolves every image composePath names and pulls the absent ones, holding the image-work lock
|
||||
// until release(). err = the refusal (the household's sentence); the lock is released already then.
|
||||
func (m *Manager) prepareRestoreImages(stack, composePath string) (release func(), err error) {
|
||||
refs := ParseComposeImages(composePath)
|
||||
end := dockerexec.HoldImageWork()
|
||||
for _, ref := range refs {
|
||||
if m.imagePresent(ref) {
|
||||
continue
|
||||
}
|
||||
start := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), restorePullTimeout)
|
||||
out, perr := m.imagePull(ctx, ref)
|
||||
cancel()
|
||||
if perr != nil {
|
||||
end()
|
||||
tail := strings.TrimSpace(out)
|
||||
if len(tail) > 300 {
|
||||
tail = tail[len(tail)-300:]
|
||||
}
|
||||
key := "err.backup.restore_image_unreachable"
|
||||
why := "the registry did not answer or is busy"
|
||||
if pullGone(out) {
|
||||
key, why = "err.backup.restore_image_gone", "the version does not exist any more"
|
||||
}
|
||||
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s before anything was touched: the image %s could not be downloaded (%s; after %s): %v — %s (D48)",
|
||||
stack, ref, why, time.Since(start).Round(time.Second), perr, tail)
|
||||
if m.restorePullRefusedNotify != nil {
|
||||
m.restorePullRefusedNotify(stack, ref, fmt.Errorf("%s: %v", why, perr))
|
||||
}
|
||||
return func() {}, util.MsgErrorf(ErrRestoreImageUnavailable, key, PinsVersion([]string{ref}))
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] Restore %s: downloaded %s before anything was touched (%s, D48)", stack, ref, time.Since(start).Round(time.Second))
|
||||
}
|
||||
return end, nil
|
||||
}
|
||||
|
||||
// SetRestorePullRefusedNotify wires the operator notice for a restore refused because an image could not be downloaded.
|
||||
func (m *Manager) SetRestorePullRefusedNotify(fn func(stack, ref string, cause error)) {
|
||||
m.restorePullRefusedNotify = fn
|
||||
}
|
||||
Reference in New Issue
Block a user