7c05b59708
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
154 lines
6.3 KiB
Go
154 lines
6.3 KiB
Go
package backup
|
|
|
|
import (
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Verification copies — the listing/delete surface for `<nsRoot>/backups/offsite-restore/<app>`.
|
|
//
|
|
// WHY THIS EXISTS (v0.147.0, feedback slice 4a): an offsite verification restore wrote its result to
|
|
// a path the customer was never told, and nothing anywhere listed what had accumulated. Pressing
|
|
// „Ellenőrző visszaállítás" produced a flash saying it had been restored "to a verification folder
|
|
// on the drive" — which folder, on which drive, and how much space it was now using were all
|
|
// invisible. So copies piled up and the only way to find them was SSH.
|
|
//
|
|
// The path segments were already open-coded in three places; offsiteRestoreRootFor() is now the one
|
|
// place `backups/offsite-restore` is spelled, and offboxRestoreScratchDir() builds on it.
|
|
|
|
// OffsiteRestoreCopy is one verification copy on disk.
|
|
type OffsiteRestoreCopy struct {
|
|
Stack string `json:"stack"` // app slug, or SharesPseudoStack for the shares copy
|
|
Path string `json:"path"` // absolute path — the thing the customer could not see
|
|
Size int64 `json:"size"` // bytes
|
|
SizeHuman string `json:"size_human"` // pre-humanized for the template
|
|
Created time.Time `json:"created"` // dir mtime; restic writes the tree once, so this is the restore time
|
|
}
|
|
|
|
// offsiteRestoreRootFor returns `<nsRoot>/backups/offsite-restore` for a drive path. THE single place
|
|
// these segments are written.
|
|
func (m *Manager) offsiteRestoreRootFor(drivePath string) string {
|
|
return filepath.Join(m.namespaceRoot(drivePath), "backups", "offsite-restore")
|
|
}
|
|
|
|
// offsiteProofRootFor returns `<nsRoot>/backups/offsite-proof` for a drive path. THE single place
|
|
// these segments are written (R-87), and deliberately a SIBLING of offsite-restore rather than a
|
|
// subdirectory of it: nothing that lists, offers or deletes a customer verification copy walks this
|
|
// root, which is the whole point — see offboxProofScratchDir for the delete it prevents.
|
|
func (m *Manager) offsiteProofRootFor(drivePath string) string {
|
|
return filepath.Join(m.namespaceRoot(drivePath), "backups", "offsite-proof")
|
|
}
|
|
|
|
// offsiteRestoreDriveRoots returns every drive path a verification copy could live under, in the same
|
|
// preference order offboxRestoreScratchDir uses to CHOOSE one — so listing can never miss a copy the
|
|
// restore path was capable of creating. Deduplicated, order preserved.
|
|
func (m *Manager) offsiteRestoreDriveRoots() []string {
|
|
seen := map[string]bool{}
|
|
var roots []string
|
|
add := func(p string) {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" || seen[p] {
|
|
return
|
|
}
|
|
seen[p] = true
|
|
roots = append(roots, p)
|
|
}
|
|
// App HDDs first (offboxRestoreScratchDir's rule 1), then every schedulable path (rules 2 and 3).
|
|
if m.stackProvider != nil {
|
|
for _, s := range m.stackProvider.ListDeployedStacks() {
|
|
add(m.stackProvider.GetStackHDDPath(s.Name))
|
|
}
|
|
}
|
|
if m.settings != nil {
|
|
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
|
add(sp.Path)
|
|
}
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// ListOffsiteRestoreCopies enumerates every verification copy across every candidate drive, newest
|
|
// first. Missing directories are not an error — "none yet" is the normal state.
|
|
func (m *Manager) ListOffsiteRestoreCopies() []OffsiteRestoreCopy {
|
|
sizer := m.offboxSize()
|
|
var out []OffsiteRestoreCopy
|
|
seen := map[string]bool{}
|
|
for _, drive := range m.offsiteRestoreDriveRoots() {
|
|
root := m.offsiteRestoreRootFor(drive)
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
continue // no copies on this drive (or the drive is not mounted) — not an error
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
p := filepath.Join(root, e.Name())
|
|
if seen[p] {
|
|
continue // two stacks can resolve to the same drive; list each path once
|
|
}
|
|
seen[p] = true
|
|
c := OffsiteRestoreCopy{Stack: e.Name(), Path: p}
|
|
if fi, err := e.Info(); err == nil {
|
|
c.Created = fi.ModTime()
|
|
}
|
|
c.Size = sizer(p)
|
|
c.SizeHuman = humanizeBytes(c.Size)
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
|
|
return out
|
|
}
|
|
|
|
// DeleteOffsiteRestoreCopy removes ONE verification copy.
|
|
//
|
|
// This is the only delete path v0.147.0 adds, so it is guarded twice over. The stack name must pass
|
|
// isSafeStackName (no separators, no traversal), and the resolved path must sit STRICTLY INSIDE a
|
|
// `backups/offsite-restore` root that this Manager itself computed — a path that merely looks right
|
|
// is refused. Both checks are on the RESOLVED path, not the input, so a symlinked scratch cannot
|
|
// walk the delete out of the sandbox.
|
|
func (m *Manager) DeleteOffsiteRestoreCopy(stack string) error {
|
|
if !isSafeStackName(stack) {
|
|
return util.MsgError("err.backup.ervenytelen_alkalmazasnev")
|
|
}
|
|
for _, drive := range m.offsiteRestoreDriveRoots() {
|
|
root := m.offsiteRestoreRootFor(drive)
|
|
target := filepath.Join(root, stack)
|
|
|
|
fi, err := os.Stat(target)
|
|
if err != nil || !fi.IsDir() {
|
|
continue
|
|
}
|
|
// Prefix safety: only ever remove strictly inside `backups/offsite-restore/`. Same shape as
|
|
// the F5 stale-primary prune (backup.go) — refuse loudly rather than best-effort skip, since
|
|
// reaching here with an out-of-sandbox path means a helper above is wrong.
|
|
cleanTarget := filepath.Clean(target)
|
|
cleanRoot := filepath.Clean(root) + string(filepath.Separator)
|
|
if !strings.HasPrefix(cleanTarget+string(filepath.Separator), cleanRoot) {
|
|
m.logger.Printf("[WARN] [offbox] refusing to delete verification copy outside %s: %s", root, cleanTarget)
|
|
return util.MsgError("err.backup.a_torles_utvonala_kivul_esik_az")
|
|
}
|
|
if err := os.RemoveAll(cleanTarget); err != nil {
|
|
return util.MsgError("err.backup.a_masolat_torlese_nem_sikerult", err)
|
|
}
|
|
m.logger.Printf("[INFO] [offbox] deleted verification copy: %s", cleanTarget)
|
|
return nil
|
|
}
|
|
return util.MsgError("err.backup.nincs_ilyen_ellenorzo_masolat")
|
|
}
|
|
|
|
// OffsiteRestoreScratchPath exposes WHERE a verification restore for stack would land, so the UI can
|
|
// name the full path in the completion message instead of saying "a verification folder somewhere".
|
|
func (m *Manager) OffsiteRestoreScratchPath(stack string) string {
|
|
scratch, _, err := m.offboxRestoreScratchDir(stack)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return scratch
|
|
}
|