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 `), 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 } // PrepareRestoreImages is prepareRestoreImages for a caller outside this package — the `.fab` import (R-1279), wired in // main.go so the import refuses before it removes anything, in the restores' own words. func (m *Manager) PrepareRestoreImages(stack, composePath string) (func(), error) { return m.prepareRestoreImages(stack, composePath) } // 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 }