fix(R-1187 a,b,d,f,g): .fab — links carried and restored safely, FIFOs refused, owners kept, busy and bad-path refusals, postgis + ON_ERROR_STOP
Red-proved (6 tests). Commit only.
This commit is contained in:
@@ -23,6 +23,17 @@ hub answers 400 to both and nothing else changes.
|
|||||||
/api/escrow/start` answers 409 with `api.escrow.recovery_offer_open` (hu + en) before anything is staged or started.
|
/api/escrow/start` answers 409 with `api.escrow.recovery_offer_open` (hu + en) before anything is staged or started.
|
||||||
Tests `TestR1219_*` (the bar with a real session, and the same box without an offer as the control), red-proved (bar
|
Tests `TestR1219_*` (the bar with a real session, and the same box without an offer as the control), red-proved (bar
|
||||||
shown, start 200).
|
shown, start 200).
|
||||||
|
- **R-1187 (P2, `.fab` export/import) — (a), (b), (d), (f), (g):** (a) the export carries a symlink AS A LINK (never its
|
||||||
|
target's bytes) and stops with `err.appexport.kulonleges_fajl` naming the path on a FIFO, socket or device (a FIFO used
|
||||||
|
to hang it); a drive folder that cannot be packed now FAILS the export (it was left out with a WARN while the export
|
||||||
|
ended „done"). (b) the import keeps owners (`Lchown`) and modes and restores a link that stays inside the app's folder;
|
||||||
|
a link pointing outside is refused and listed in a WARN. (d) `StartImport` refuses (`err.appexport.import_foglalt`)
|
||||||
|
while another operation holds the app-stop marker (`BusyWith`). (f) a bundle whose `app.yaml` carries an empty,
|
||||||
|
relative or unclean `HDD_PATH` is refused before anything is touched (`err.appexport.hdd_path_hibas`). (g) the import
|
||||||
|
classifies databases with the backup side's predicate (`appbackup.DBTypeForImage`: postgis, pgvector, timescaledb) and
|
||||||
|
runs `psql -v ON_ERROR_STOP=1`. Tests `internal/appexport/r1187_abdfg_test.go` (6), red-proved with the old behaviour
|
||||||
|
behind the new function shapes (link packing error, a 10-s hang on the FIFO, link not restored, import not refused,
|
||||||
|
bad paths accepted, postgis missed).
|
||||||
- `settings.RestoreHold.Why()` / `WireReason()`: the hold sentence moved out of `cmd/controller` (`holdWhy` calls it), so
|
- `settings.RestoreHold.Why()` / `WireReason()`: the hold sentence moved out of `cmd/controller` (`holdWhy` calls it), so
|
||||||
`--restore-holds` and the hub's list say the same thing.
|
`--restore-holds` and the hub's list say the same thing.
|
||||||
- Tests `internal/holdreport/holdreport_test.go` (8): the report field, the daily repeat, cleared once, cleared while
|
- Tests `internal/holdreport/holdreport_test.go` (8): the report field, the daily repeat, cleared once, cleared while
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ func dbTypeForImage(image string) (DBType, bool) {
|
|||||||
return DBType(""), false
|
return DBType(""), false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DBTypeForImage is dbTypeForImage for other packages (R-1187 g: the .fab import classifies with the SAME predicate).
|
||||||
|
func DBTypeForImage(image string) (DBType, bool) { return dbTypeForImage(image) }
|
||||||
|
|
||||||
// composeServicesDoc is the minimal view of a compose file needed here: the `services:` MAP and each
|
// composeServicesDoc is the minimal view of a compose file needed here: the `services:` MAP and each
|
||||||
// service's `image:`. Deliberately a real YAML parse and not a line scan — a top-level `volumes:`
|
// service's `image:`. Deliberately a real YAML parse and not a line scan — a top-level `volumes:`
|
||||||
// block (immich's `immich_ml_cache:`) has exactly the shape a naive scan misreads as a service, and
|
// block (immich's `immich_ml_cache:`) has exactly the shape a naive scan misreads as a service, and
|
||||||
|
|||||||
@@ -728,10 +728,11 @@ func (e *Exporter) exportHDDData(req ExportRequest, dataDir string, manifest *Ma
|
|||||||
e.debugf("tarring HDD mount: %s → %s (%d exclude(s))", mount, tarPath, len(excludes))
|
e.debugf("tarring HDD mount: %s → %s (%d exclude(s))", mount, tarPath, len(excludes))
|
||||||
tarStart := time.Now()
|
tarStart := time.Now()
|
||||||
if err := tarDirectoryExcluding(mount, tarPath, excludes); err != nil {
|
if err := tarDirectoryExcluding(mount, tarPath, excludes); err != nil {
|
||||||
// v0.125.0: claim the subdir ONLY on success — a claimed-but-absent tar would trip
|
// R-1187 (a): a folder that cannot be packed FAILS the export. It used to be left out with a WARN while the
|
||||||
// the packaging assertion; an honestly-skipped mount stays out of the manifest.
|
// export ended „done" — a bundle silently missing a whole drive folder. Pinned by TestR1187a_*.
|
||||||
e.logger.Printf("[WARN] Export: failed to tar %s (excluded from the bundle): %v", mount, err)
|
e.logger.Printf("[WARN] Export: failed to pack %s — the export FAILS rather than leave it out (R-1187 a): %v", mount, err)
|
||||||
os.Remove(tarPath)
|
os.Remove(tarPath)
|
||||||
|
return err
|
||||||
} else {
|
} else {
|
||||||
manifest.HDDSubdirs = append(manifest.HDDSubdirs, subdir)
|
manifest.HDDSubdirs = append(manifest.HDDSubdirs, subdir)
|
||||||
if info, _ := os.Stat(tarPath); info != nil {
|
if info, _ := os.Stat(tarPath); info != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package appexport
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -289,7 +290,20 @@ func tarDirectoryExcluding(sourceDir, outputPath string, excludeRels []string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
header, err := tar.FileInfoHeader(info, "")
|
// R-1187 (a): a symlink is carried AS A LINK (its target's bytes never — the walk used to copy them into the link
|
||||||
|
// header, the tar failed, and the whole folder was left out); a FIFO, socket or device stops the export with a
|
||||||
|
// sentence naming the path (os.Open on a FIFO used to HANG the export). Pinned by TestR1187a_*.
|
||||||
|
mode := info.Mode()
|
||||||
|
if mode&(os.ModeNamedPipe|os.ModeSocket|os.ModeDevice|os.ModeCharDevice|os.ModeIrregular) != 0 {
|
||||||
|
return util.MsgError("err.appexport.kulonleges_fajl", path)
|
||||||
|
}
|
||||||
|
link := ""
|
||||||
|
if mode&os.ModeSymlink != 0 {
|
||||||
|
if link, err = os.Readlink(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header, err := tar.FileInfoHeader(info, link)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -297,7 +311,7 @@ func tarDirectoryExcluding(sourceDir, outputPath string, excludeRels []string) e
|
|||||||
if err := tw.WriteHeader(header); err != nil {
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if info.IsDir() {
|
if info.IsDir() || link != "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package appexport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1187 (a), (b), (d), (f), (g) — the open parts of the `.fab` data-loss row. Each test asserts the CONSEQUENCE for
|
||||||
|
// the household's files. Red-proved 2026-10-26 night against the code before the fix (function shapes added first with
|
||||||
|
// the old behaviour, so every failure below was a behaviour failure, not a compile error).
|
||||||
|
|
||||||
|
func r1187TarEntries(t *testing.T, path string) map[string]*tar.Header {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
out := map[string]*tar.Header{}
|
||||||
|
tr := tar.NewReader(f)
|
||||||
|
for {
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading %s: %v", path, err)
|
||||||
|
}
|
||||||
|
out[h.Name] = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) A symlink in a drive folder is carried AS A LINK — before, the walk wrote a link header and then copied the
|
||||||
|
// target's bytes into it, the tar failed, and the whole folder was left out with only a WARN (the export ended „done").
|
||||||
|
func TestR1187a_ASymlinkIsCarriedAsALink(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(src, "photo.jpg"), []byte("jpeg"), 0o644)
|
||||||
|
os.Symlink("photo.jpg", filepath.Join(src, "latest.jpg"))
|
||||||
|
os.Symlink("/etc/hostname", filepath.Join(src, "outside"))
|
||||||
|
out := filepath.Join(t.TempDir(), "x.tar")
|
||||||
|
if err := tarDirectoryExcluding(src, out, nil); err != nil {
|
||||||
|
t.Fatalf("a folder with symlinks could not be packed: %v", err)
|
||||||
|
}
|
||||||
|
e := r1187TarEntries(t, out)
|
||||||
|
if h := e["latest.jpg"]; h == nil || h.Typeflag != tar.TypeSymlink || h.Linkname != "photo.jpg" {
|
||||||
|
t.Fatalf("the inside link is not carried as a link: %+v", h)
|
||||||
|
}
|
||||||
|
if h := e["outside"]; h == nil || h.Typeflag != tar.TypeSymlink || h.Size != 0 {
|
||||||
|
t.Fatalf("the outside link must be carried as a bare link (never its target's bytes): %+v", h)
|
||||||
|
}
|
||||||
|
if h := e["photo.jpg"]; h == nil || h.Typeflag != tar.TypeReg {
|
||||||
|
t.Fatalf("the regular file is missing: %+v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) A FIFO stops the export with a sentence naming the path — never a hang on os.Open, never a silent drop. The
|
||||||
|
// export of the folder FAILS as a whole (exportHDDData returns the error; the job then fails).
|
||||||
|
func TestR1187a_AFifoFailsTheExportNamingThePath(t *testing.T) {
|
||||||
|
mount := filepath.Join(t.TempDir(), "appdata-x")
|
||||||
|
os.MkdirAll(mount, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(mount, "keep.txt"), []byte("k"), 0o644)
|
||||||
|
fifo := filepath.Join(mount, "pipe")
|
||||||
|
if err := syscall.Mkfifo(fifo, 0o644); err != nil {
|
||||||
|
t.Skipf("no FIFO here: %v", err)
|
||||||
|
}
|
||||||
|
prov := &r1187HDDProvider{rtProvider: rtProvider{stackDir: t.TempDir()}, mounts: []string{mount}}
|
||||||
|
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- e.exportHDDData(ExportRequest{StackName: "x"}, t.TempDir(), &Manifest{}) }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil || !strings.Contains(err.Error(), fifo) {
|
||||||
|
t.Fatalf("a FIFO must fail the export naming %s, got %v", fifo, err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the export HUNG on a FIFO")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type r1187HDDProvider struct {
|
||||||
|
rtProvider
|
||||||
|
mounts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *r1187HDDProvider) GetStackHDDMounts(string) []string { return p.mounts }
|
||||||
|
|
||||||
|
// (b) The import keeps owners and modes, restores a link that stays inside the app's folder, and refuses (and lists) a
|
||||||
|
// link that points outside it. Before: only folders and regular files were written — owners lost, every link dropped.
|
||||||
|
func TestR1187b_TheImportKeepsOwnersModesAndInsideLinks(t *testing.T) {
|
||||||
|
tarPath := filepath.Join(t.TempDir(), "d.tar")
|
||||||
|
f, _ := os.Create(tarPath)
|
||||||
|
tw := tar.NewWriter(f)
|
||||||
|
tw.WriteHeader(&tar.Header{Name: "sub/", Typeflag: tar.TypeDir, Mode: 0o750, Uid: 1000, Gid: 1000})
|
||||||
|
tw.WriteHeader(&tar.Header{Name: "sub/a.txt", Typeflag: tar.TypeReg, Mode: 0o640, Size: 2, Uid: 1000, Gid: 1001})
|
||||||
|
tw.Write([]byte("hi"))
|
||||||
|
tw.WriteHeader(&tar.Header{Name: "link-in", Typeflag: tar.TypeSymlink, Linkname: "sub/a.txt", Uid: 1000, Gid: 1000})
|
||||||
|
tw.WriteHeader(&tar.Header{Name: "link-out", Typeflag: tar.TypeSymlink, Linkname: "../../etc/passwd"})
|
||||||
|
tw.WriteHeader(&tar.Header{Name: "link-abs", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"})
|
||||||
|
tw.Close()
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
var chowned []string
|
||||||
|
old := lchownFn
|
||||||
|
lchownFn = func(p string, uid, gid int) error {
|
||||||
|
chowned = append(chowned, filepath.Base(p)+":"+strconv.Itoa(uid)+":"+strconv.Itoa(gid))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer func() { lchownFn = old }()
|
||||||
|
|
||||||
|
dest := t.TempDir()
|
||||||
|
refused, err := extractTar(tarPath, dest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fi, err := os.Stat(filepath.Join(dest, "sub/a.txt")); err != nil || fi.Mode().Perm() != 0o640 {
|
||||||
|
t.Fatalf("mode not kept: %v %v", fi, err)
|
||||||
|
}
|
||||||
|
if l, err := os.Readlink(filepath.Join(dest, "link-in")); err != nil || l != "sub/a.txt" {
|
||||||
|
t.Fatalf("the inside link was not restored: %q %v", l, err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"link-out", "link-abs"} {
|
||||||
|
if _, err := os.Lstat(filepath.Join(dest, bad)); err == nil {
|
||||||
|
t.Fatalf("a link pointing OUTSIDE the app's folder was restored: %s", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(refused, ",") != "link-out,link-abs" {
|
||||||
|
t.Fatalf("the refused links must be listed, got %v", refused)
|
||||||
|
}
|
||||||
|
got := strings.Join(chowned, ",")
|
||||||
|
for _, want := range []string{"sub:1000:1000", "a.txt:1000:1001", "link-in:1000:1000"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("owner %s not restored (got %s)", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (d) An import is refused while another operation holds the app-stop marker (a restore, a put-back, a stop-first
|
||||||
|
// export) — before, StartImport checked only its own job slot.
|
||||||
|
func TestR1187d_AnImportWaitsForARestore(t *testing.T) {
|
||||||
|
e := NewExporter(&rtProvider{stackDir: t.TempDir()}, log.New(io.Discard, "", 0), "test")
|
||||||
|
e.SetStopGuard(&busyGuard{})
|
||||||
|
fab := filepath.Join(t.TempDir(), "x.fab")
|
||||||
|
os.WriteFile(fab, []byte("x"), 0o644)
|
||||||
|
err := e.StartImport(ImportRequest{FABPath: fab})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unit-restore:bookstack") {
|
||||||
|
t.Fatalf("an import started while a restore holds the box: %v", err)
|
||||||
|
}
|
||||||
|
if e.activeJob != nil {
|
||||||
|
t.Fatal("a refused import created a job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (f) A bundle whose app.yaml carries an empty or relative HDD_PATH is refused BEFORE anything is touched — before, an
|
||||||
|
// empty one made the drive tars extract into a RELATIVE path (the controller's working directory).
|
||||||
|
func TestR1187f_AnEmptyOrRelativeHDDPathIsRefused(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
hdd string
|
||||||
|
ok bool
|
||||||
|
}{{"", false}, {"data", false}, {"/mnt/drive/../../etc", false}, {"/mnt/sys_drive", true}} {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(tmp, "config"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(tmp, "config", "app.yaml"), []byte("env:\n HDD_PATH: \""+tc.hdd+"\"\n"), 0o644)
|
||||||
|
os.MkdirAll(filepath.Join(tmp, "data", "hdd"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(tmp, "data", "hdd", "appdata.tar"), []byte("not empty"), 0o644)
|
||||||
|
err := validateBundleData(tmp, &Manifest{HDDSubdirs: []string{"appdata"}, HasHDDData: true})
|
||||||
|
if tc.ok != (err == nil) {
|
||||||
|
t.Errorf("HDD_PATH %q: refused=%v, want refused=%v (%v)", tc.hdd, err != nil, !tc.ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (g) PostGIS / pgvector / timescaledb images are PostgreSQL for the import too (the backup side's predicate, R-484),
|
||||||
|
// and psql stops on the first error (the dump is --clean --if-exists, so ON_ERROR_STOP is safe).
|
||||||
|
func TestR1187g_PostgisIsPostgresAndPsqlStopsOnError(t *testing.T) {
|
||||||
|
for _, img := range []string{"postgis/postgis:16-3.5-alpine", "pgvector/pgvector:pg16", "timescale/timescaledb:2.14.2-pg16", "postgres:16"} {
|
||||||
|
if info := classifyDBImage("db", img, "c"); info == nil || info.DBType != "postgres" {
|
||||||
|
t.Errorf("%s is not classified as PostgreSQL: %+v", img, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := strings.Join(psqlImportArgs("c", "u", "d"), " ")
|
||||||
|
if !strings.Contains(args, "-v ON_ERROR_STOP=1") {
|
||||||
|
t.Fatalf("psql does not stop on error: %s", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||||
"io"
|
"io"
|
||||||
@@ -184,6 +185,16 @@ func (e *Exporter) StartImport(req ImportRequest) error {
|
|||||||
e.debugf("StartImport rejected: another job is already running")
|
e.debugf("StartImport rejected: another job is already running")
|
||||||
return fmt.Errorf("export or import already in progress")
|
return fmt.Errorf("export or import already in progress")
|
||||||
}
|
}
|
||||||
|
// R-1187 (d): another operation holds the app-stop marker (a restore, a put-back, a stop-first export) — an import
|
||||||
|
// now would stop and rewrite an app that operation is moving. Refused before anything is read. Pinned by
|
||||||
|
// TestR1187d_AnImportWaitsForARestore.
|
||||||
|
if e.stopGuard != nil {
|
||||||
|
if busy := e.stopGuard.BusyWith(); busy != "" {
|
||||||
|
e.mu.Unlock()
|
||||||
|
e.logger.Printf("[WARN] Import: refused — another operation holds the box (%s) (R-1187 d)", busy)
|
||||||
|
return util.MsgError("err.appexport.import_foglalt", busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate file exists
|
// Validate file exists
|
||||||
info, err := os.Stat(req.FABPath)
|
info, err := os.Stat(req.FABPath)
|
||||||
@@ -674,9 +685,14 @@ func (e *Exporter) restoreHDDData(tmpDir string, manifest *Manifest, composePath
|
|||||||
|
|
||||||
e.logger.Printf("[INFO] Import: extracting HDD data %s → %s", subdir, destPath)
|
e.logger.Printf("[INFO] Import: extracting HDD data %s → %s", subdir, destPath)
|
||||||
extractStart := time.Now()
|
extractStart := time.Now()
|
||||||
if err := extractTar(tarPath, destPath); err != nil {
|
refused, err := extractTar(tarPath, destPath)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("extracting %s: %w", subdir, err)
|
return fmt.Errorf("extracting %s: %w", subdir, err)
|
||||||
}
|
}
|
||||||
|
if len(refused) > 0 {
|
||||||
|
e.logger.Printf("[WARN] Import: %s — %d link(s) pointing outside the app's folder were NOT restored (R-1187 b): %s",
|
||||||
|
subdir, len(refused), strings.Join(refused, ", "))
|
||||||
|
}
|
||||||
e.debugf("restoreHDDData: extracted %s in %v", subdir, time.Since(extractStart))
|
e.debugf("restoreHDDData: extracted %s in %v", subdir, time.Since(extractStart))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,6 +742,16 @@ func resolveHDDMounts(composePath string, env map[string]string) []string {
|
|||||||
// validateBundleData asserts every manifest-claimed data tar exists non-empty in the extracted
|
// validateBundleData asserts every manifest-claimed data tar exists non-empty in the extracted
|
||||||
// bundle (v0.125.0, scenario C). Pure read — called before ANY destructive import step.
|
// bundle (v0.125.0, scenario C). Pure read — called before ANY destructive import step.
|
||||||
func validateBundleData(tmpDir string, manifest *Manifest) error {
|
func validateBundleData(tmpDir string, manifest *Manifest) error {
|
||||||
|
// R-1187 (f): drive data needs an ABSOLUTE, clean HDD_PATH from the bundle's app.yaml — an empty one made the drive
|
||||||
|
// tars extract into a relative path (the controller's working directory). Refused before anything is touched.
|
||||||
|
// Pinned by TestR1187f_AnEmptyOrRelativeHDDPathIsRefused.
|
||||||
|
if len(manifest.HDDSubdirs) > 0 {
|
||||||
|
env, _ := readPlaintextAppYaml(filepath.Join(tmpDir, "config", "app.yaml"))
|
||||||
|
hdd := strings.TrimSpace(env["HDD_PATH"])
|
||||||
|
if hdd == "" || !filepath.IsAbs(hdd) || filepath.Clean(hdd) != hdd || strings.Contains(hdd, "..") {
|
||||||
|
return util.MsgError("err.appexport.hdd_path_hibas", hdd)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, v := range manifest.VolumeNames {
|
for _, v := range manifest.VolumeNames {
|
||||||
if err := ValidateSegment("volume_name", v); err != nil {
|
if err := ValidateSegment("volume_name", v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -967,11 +993,12 @@ func findDBServiceInCompose(composePath string) *dbServiceInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func classifyDBImage(service, image, container string) *dbServiceInfo {
|
func classifyDBImage(service, image, container string) *dbServiceInfo {
|
||||||
img := strings.ToLower(image)
|
// R-1187 (g): the backup side's predicate (appbackup.DBTypeForImage, R-484) — postgis / pgvector / timescaledb are
|
||||||
if strings.Contains(img, "postgres") {
|
// PostgreSQL too. The import used its own narrower list and skipped adventurelog's and dawarich's database.
|
||||||
|
switch t, ok := appbackup.DBTypeForImage(image); {
|
||||||
|
case ok && t == appbackup.DBTypePostgres:
|
||||||
return &dbServiceInfo{ServiceName: service, DBType: "postgres", ContainerName: container}
|
return &dbServiceInfo{ServiceName: service, DBType: "postgres", ContainerName: container}
|
||||||
}
|
case ok && t == appbackup.DBTypeMariaDB:
|
||||||
if strings.Contains(img, "mariadb") || strings.Contains(img, "mysql") {
|
|
||||||
return &dbServiceInfo{ServiceName: service, DBType: "mariadb", ContainerName: container}
|
return &dbServiceInfo{ServiceName: service, DBType: "mariadb", ContainerName: container}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1056,6 +1083,13 @@ func waitForDB(containerID, dbType string, env map[string]string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// psqlImportArgs is the `docker` argv that loads a Postgres dump.
|
||||||
|
func psqlImportArgs(containerID, user, dbName string) []string {
|
||||||
|
// R-1187 (g): stop on the first error — the dump is --clean --if-exists, so a failing statement is a real failure,
|
||||||
|
// and without ON_ERROR_STOP psql went on and the import reported success over a half-loaded database.
|
||||||
|
return []string{"exec", "-i", containerID, "psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", dbName}
|
||||||
|
}
|
||||||
|
|
||||||
// importDBDump imports a (possibly gzipped) SQL dump into the running database container.
|
// importDBDump imports a (possibly gzipped) SQL dump into the running database container.
|
||||||
func importDBDump(containerID, dumpPath, dbType string, env map[string]string) error {
|
func importDBDump(containerID, dumpPath, dbType string, env map[string]string) error {
|
||||||
// Open the dump file
|
// Open the dump file
|
||||||
@@ -1090,8 +1124,7 @@ func importDBDump(containerID, dumpPath, dbType string, env map[string]string) e
|
|||||||
if dbName == "" {
|
if dbName == "" {
|
||||||
dbName = user
|
dbName = user
|
||||||
}
|
}
|
||||||
cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
|
cmd = dockerexec.CommandContext(ctx, "docker", psqlImportArgs(containerID, user, dbName)...)
|
||||||
"psql", "-U", user, "-d", dbName)
|
|
||||||
case "mariadb":
|
case "mariadb":
|
||||||
password := env["MYSQL_ROOT_PASSWORD"]
|
password := env["MYSQL_ROOT_PASSWORD"]
|
||||||
if password == "" {
|
if password == "" {
|
||||||
@@ -1170,50 +1203,80 @@ func extractTarGz(tgzPath, destDir string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractTar extracts a plain tar archive to a directory.
|
// lchownFn is the owner seam (a test records it; production is os.Lchown).
|
||||||
func extractTar(tarPath, destDir string) error {
|
var lchownFn = os.Lchown
|
||||||
|
|
||||||
|
// extractTar extracts a plain tar archive (one drive folder) into destDir and returns the entries it REFUSED.
|
||||||
|
//
|
||||||
|
// R-1187 (b): owners and modes are kept (Lchown — the controller runs as root on a box), and a symlink is restored when
|
||||||
|
// it stays inside destDir; a link pointing outside (absolute, or climbing out with ..) is refused and listed — never
|
||||||
|
// followed, never created. Only folders, regular files and links exist in a drive tar (the export refuses the rest).
|
||||||
|
// Pinned by TestR1187b_TheImportKeepsOwnersModesAndInsideLinks.
|
||||||
|
func extractTar(tarPath, destDir string) ([]string, error) {
|
||||||
f, err := os.Open(tarPath)
|
f, err := os.Open(tarPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
root := filepath.Clean(destDir)
|
||||||
|
var refused []string
|
||||||
tr := tar.NewReader(f)
|
tr := tar.NewReader(f)
|
||||||
for {
|
for {
|
||||||
hdr, err := tr.Next()
|
hdr, err := tr.Next()
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
return nil
|
return refused, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return refused, err
|
||||||
}
|
}
|
||||||
|
target := filepath.Join(root, hdr.Name)
|
||||||
target := filepath.Join(destDir, hdr.Name)
|
if !strings.HasPrefix(filepath.Clean(target), root+string(os.PathSeparator)) {
|
||||||
|
continue // path traversal
|
||||||
// Security: prevent path traversal
|
|
||||||
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch hdr.Typeflag {
|
switch hdr.Typeflag {
|
||||||
case tar.TypeDir:
|
case tar.TypeDir:
|
||||||
if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil {
|
if err := os.MkdirAll(target, os.FileMode(hdr.Mode).Perm()); err != nil {
|
||||||
return err
|
return refused, err
|
||||||
}
|
}
|
||||||
|
os.Chmod(target, os.FileMode(hdr.Mode).Perm())
|
||||||
case tar.TypeReg:
|
case tar.TypeReg:
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||||
return err
|
return refused, err
|
||||||
}
|
}
|
||||||
outFile, err := os.Create(target)
|
outFile, err := os.Create(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return refused, err
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(outFile, tr); err != nil {
|
if _, err := io.Copy(outFile, tr); err != nil {
|
||||||
outFile.Close()
|
outFile.Close()
|
||||||
return err
|
return refused, err
|
||||||
}
|
}
|
||||||
outFile.Close()
|
outFile.Close()
|
||||||
os.Chmod(target, os.FileMode(hdr.Mode))
|
os.Chmod(target, os.FileMode(hdr.Mode).Perm())
|
||||||
|
case tar.TypeSymlink:
|
||||||
|
dst := hdr.Linkname
|
||||||
|
if filepath.IsAbs(dst) {
|
||||||
|
refused = append(refused, hdr.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolved := filepath.Clean(filepath.Join(filepath.Dir(target), dst))
|
||||||
|
if resolved != root && !strings.HasPrefix(resolved, root+string(os.PathSeparator)) {
|
||||||
|
refused = append(refused, hdr.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||||
|
return refused, err
|
||||||
|
}
|
||||||
|
os.Remove(target)
|
||||||
|
if err := os.Symlink(dst, target); err != nil {
|
||||||
|
return refused, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
refused = append(refused, hdr.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := lchownFn(target, hdr.Uid, hdr.Gid); err != nil && !os.IsPermission(err) {
|
||||||
|
return refused, fmt.Errorf("setting the owner of %s: %w", hdr.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1258,6 +1258,9 @@
|
|||||||
"err.appexport.a_z_kotet_adata_hianyzik_a": "the data of volume %q is missing from the package",
|
"err.appexport.a_z_kotet_adata_hianyzik_a": "the data of volume %q is missing from the package",
|
||||||
"err.appexport.jelszo_hibas_vagy_a_fajl_serult": "wrong password, or the file is damaged",
|
"err.appexport.jelszo_hibas_vagy_a_fajl_serult": "wrong password, or the file is damaged",
|
||||||
"err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es": "two data folders end in the same name (%q: %s and %s) — the package could not tell them apart",
|
"err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es": "two data folders end in the same name (%q: %s and %s) — the package could not tell them apart",
|
||||||
|
"err.appexport.kulonleges_fajl": "%s is a special file (a pipe, socket or device) that cannot be saved — the export stopped; nothing was left out silently",
|
||||||
|
"err.appexport.import_foglalt": "a package cannot be loaded right now: another operation is working on the box (%s). Wait until it finishes and try again.",
|
||||||
|
"err.appexport.hdd_path_hibas": "the data-drive path in the package is not valid (“%s”) — the import did not start, nothing changed",
|
||||||
"err.appexport.visszafejtes_sikertelen": "decryption failed: %s",
|
"err.appexport.visszafejtes_sikertelen": "decryption failed: %s",
|
||||||
"err.backup.whole_restore_space": "There is not enough room on the drive to bring the files back: %.1f GB needed, %.1f GB free. Nothing was changed.",
|
"err.backup.whole_restore_space": "There is not enough room on the drive to bring the files back: %.1f GB needed, %.1f GB free. Nothing was changed.",
|
||||||
"err.backup.whole_restore_no_proven_copy": "The second drive holds no checked copy that can bring the app back together with its files. Nothing was changed.",
|
"err.backup.whole_restore_no_proven_copy": "The second drive holds no checked copy that can bring the app back together with its files. Nothing was changed.",
|
||||||
|
|||||||
@@ -1253,6 +1253,9 @@
|
|||||||
"err.appexport.a_z_kotet_adata_hianyzik_a": "a(z) %q kötet adata hiányzik a csomagból",
|
"err.appexport.a_z_kotet_adata_hianyzik_a": "a(z) %q kötet adata hiányzik a csomagból",
|
||||||
"err.appexport.jelszo_hibas_vagy_a_fajl_serult": "jelszó hibás vagy a fájl sérült",
|
"err.appexport.jelszo_hibas_vagy_a_fajl_serult": "jelszó hibás vagy a fájl sérült",
|
||||||
"err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es": "két adatkönyvtár azonos névvel végződik (%q: %s és %s) — a csomag nem tudná megkülönböztetni őket",
|
"err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es": "két adatkönyvtár azonos névvel végződik (%q: %s és %s) — a csomag nem tudná megkülönböztetni őket",
|
||||||
|
"err.appexport.kulonleges_fajl": "a(z) %s különleges fájl (cső, foglalat vagy eszköz), ezt nem lehet menteni — az exportálás leállt, semmi nem maradt ki csendben",
|
||||||
|
"err.appexport.import_foglalt": "most nem tölthetsz vissza csomagot: egy másik művelet dolgozik a dobozon (%s). Várd meg, míg befejeződik, és próbáld újra.",
|
||||||
|
"err.appexport.hdd_path_hibas": "a csomag adatmeghajtó-útvonala hibás („%s”) — a betöltés el sem indult, semmi nem változott",
|
||||||
"err.appexport.visszafejtes_sikertelen": "visszafejtés sikertelen: %s",
|
"err.appexport.visszafejtes_sikertelen": "visszafejtés sikertelen: %s",
|
||||||
"err.backup.whole_restore_space": "Nincs elég hely a meghajtón a fájlok visszaállításához: %.1f GB kellene, %.1f GB szabad. Semmi nem változott.",
|
"err.backup.whole_restore_space": "Nincs elég hely a meghajtón a fájlok visszaállításához: %.1f GB kellene, %.1f GB szabad. Semmi nem változott.",
|
||||||
"err.backup.whole_restore_no_proven_copy": "A második meghajtón nincs olyan ellenőrzött másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. Semmi nem változott.",
|
"err.backup.whole_restore_no_proven_copy": "A második meghajtón nincs olyan ellenőrzött másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. Semmi nem változott.",
|
||||||
|
|||||||
Reference in New Issue
Block a user